Compare commits

..
Author SHA1 Message Date
dependabot[bot] 673a285ef6 chore(deps): bump inquire from 0.7.5 to 0.9.4
Bumps [inquire](https://github.com/mikaelmello/inquire) from 0.7.5 to 0.9.4.
- [Release notes](https://github.com/mikaelmello/inquire/releases)
- [Changelog](https://github.com/mikaelmello/inquire/blob/main/CHANGELOG.md)
- [Commits](https://github.com/mikaelmello/inquire/compare/v0.7.5...v0.9.4)

---
updated-dependencies:
- dependency-name: inquire
  dependency-version: 0.9.4
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-02 06:09:29 +00:00
447 changed files with 7966 additions and 58022 deletions

No files matched your search

-3
View File
@@ -59,6 +59,3 @@ flatpak/node-sources.json
# Claude Code local settings
.claude/settings.local.json
.claude/worktrees/
# Headless screenshots taken while working
.shots/
Generated
+20 -154
View File
@@ -1231,7 +1231,7 @@ dependencies = [
"encode_unicode",
"libc",
"once_cell",
"unicode-width 0.2.2",
"unicode-width",
"windows-sys 0.59.0",
]
@@ -1243,7 +1243,7 @@ checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c"
dependencies = [
"encode_unicode",
"libc",
"unicode-width 0.2.2",
"unicode-width",
"windows-sys 0.61.2",
]
@@ -1522,15 +1522,17 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crossterm"
version = "0.25.0"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67"
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
dependencies = [
"bitflags 1.3.2",
"bitflags 2.11.0",
"crossterm_winapi",
"libc",
"mio 0.8.11",
"derive_more 2.1.1",
"document-features",
"mio",
"parking_lot",
"rustix 1.1.5",
"signal-hook",
"signal-hook-mio",
"winapi",
@@ -3655,7 +3657,7 @@ checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c"
dependencies = [
"console 0.16.4",
"portable-atomic",
"unicode-width 0.2.2",
"unicode-width",
"unit-prefix",
"web-time",
]
@@ -3710,20 +3712,17 @@ dependencies = [
[[package]]
name = "inquire"
version = "0.7.5"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fddf93031af70e75410a2511ec04d49e758ed2f26dad3404a934e0fb45cc12a"
checksum = "6654738b8024300cf062d04a1c13c10c8e2cea598ec1c47dc9b6641159429756"
dependencies = [
"bitflags 2.11.0",
"crossterm",
"dyn-clone",
"fuzzy-matcher",
"fxhash",
"newline-converter",
"once_cell",
"tempfile",
"unicode-segmentation",
"unicode-width 0.1.14",
"unicode-width",
]
[[package]]
@@ -4156,7 +4155,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a"
dependencies = [
"cc",
"openssl-sys",
"pkg-config",
"vcpkg",
]
@@ -4468,18 +4466,6 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "mio"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
dependencies = [
"libc",
"log 0.4.34",
"wasi 0.11.0+wasi-snapshot-preview1",
"windows-sys 0.48.0",
]
[[package]]
name = "mio"
version = "1.2.4"
@@ -4575,15 +4561,6 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "newline-converter"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b6b097ecb1cbfed438542d16e84fd7ad9b0c76c8a65b7f9039212a3d14dc7f"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "nibble_vec"
version = "0.1.0"
@@ -4672,7 +4649,7 @@ dependencies = [
"kqueue",
"libc",
"log 0.4.34",
"mio 1.2.4",
"mio",
"notify-types",
"objc2-core-foundation",
"objc2-core-services",
@@ -5305,7 +5282,7 @@ dependencies = [
"textwrap",
"thiserror 2.0.21",
"unicode-segmentation",
"unicode-width 0.2.2",
"unicode-width",
]
[[package]]
@@ -6481,70 +6458,6 @@ dependencies = [
"prost",
]
[[package]]
name = "protoc-bin-vendored"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8760a25b6ff9c620324822737e468478fa092234190d2e449760344354896ed9"
dependencies = [
"protoc-bin-vendored-linux-aarch_64",
"protoc-bin-vendored-linux-ppcle_64",
"protoc-bin-vendored-linux-s390_64",
"protoc-bin-vendored-linux-x86_32",
"protoc-bin-vendored-linux-x86_64",
"protoc-bin-vendored-macos-aarch_64",
"protoc-bin-vendored-macos-x86_64",
"protoc-bin-vendored-win32",
]
[[package]]
name = "protoc-bin-vendored-linux-aarch_64"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73fa2624782ca04cd44f51554566717377acd240e4c0016d757dd74fccc9324f"
[[package]]
name = "protoc-bin-vendored-linux-ppcle_64"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2417e9817fa237dab803ad4dda7357a111656e242959cc6b8f9a1a583367d42"
[[package]]
name = "protoc-bin-vendored-linux-s390_64"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d189c34636356a46a7ed3188233dc8a88c431278cc54d4a19b096a2d270e985"
[[package]]
name = "protoc-bin-vendored-linux-x86_32"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "171e39f1e846e5f322ced1ac3b8d4cd3a3833ca24b6e5d58b3632574fe6204fa"
[[package]]
name = "protoc-bin-vendored-linux-x86_64"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "873cdcc097593432086661aa432b8078f1cd87bfb02847c332e98ae2c119e966"
[[package]]
name = "protoc-bin-vendored-macos-aarch_64"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eeb72df001783b8297847fe8f5f874ee400fd742c843d60583e8c23d96977c7f"
[[package]]
name = "protoc-bin-vendored-macos-x86_64"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b04652167eca899dda05f32f5481adeaf25c623a98ce2fc146a001cc59a2add7"
[[package]]
name = "protoc-bin-vendored-win32"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "263a3f48f01e7309e857138bd47f785585b4a005e8e56c6d2824ce91195999c3"
[[package]]
name = "quick-error"
version = "1.2.3"
@@ -7103,7 +7016,7 @@ version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77dff57c9de498bb1eb5b1ce682c2e3a0ae956b266fa0933c3e151b87b078967"
dependencies = [
"unicode-width 0.2.2",
"unicode-width",
"yansi",
]
@@ -7128,7 +7041,7 @@ dependencies = [
"kqueue",
"libc",
"log 0.4.34",
"mio 1.2.4",
"mio",
"rolldown-notify-types",
"walkdir",
"windows-sys 0.61.2",
@@ -8258,7 +8171,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
dependencies = [
"libc",
"mio 0.8.11",
"mio",
"signal-hook",
]
@@ -9319,7 +9232,7 @@ checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
dependencies = [
"smawk",
"unicode-linebreak",
"unicode-width 0.2.2",
"unicode-width",
]
[[package]]
@@ -9472,7 +9385,7 @@ checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3"
dependencies = [
"bytes",
"libc",
"mio 1.2.4",
"mio",
"pin-project-lite",
"signal-hook-registry",
"socket2",
@@ -10090,12 +10003,6 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "unicode-width"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
[[package]]
name = "unicode-width"
version = "0.2.2"
@@ -10905,15 +10812,6 @@ dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
@@ -11577,7 +11475,6 @@ dependencies = [
name = "yaak-commands"
version = "0.0.0"
dependencies = [
"rusqlite",
"serde_json",
"tempfile",
"thiserror 2.0.21",
@@ -11614,11 +11511,9 @@ dependencies = [
"base32",
"base64 0.23.1",
"chacha20poly1305",
"dbus-secret-service",
"keyring",
"log 0.4.34",
"serde",
"tempfile",
"thiserror 2.0.21",
"yaak-models",
]
@@ -11632,7 +11527,6 @@ dependencies = [
"include_dir",
"log 0.4.34",
"nanoid",
"openssl-sys",
"r2d2",
"r2d2_sqlite",
"rusqlite",
@@ -11640,7 +11534,6 @@ dependencies = [
"sea-query-rusqlite",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.21",
"ts-rs",
"uuid",
@@ -11883,17 +11776,12 @@ dependencies = [
name = "yaak-proxy"
version = "0.1.0"
dependencies = [
"base64 0.23.1",
"block2 0.6.1",
"bytes",
"dispatch2",
"flate2",
"futures-util",
"http",
"http-body-util",
"hyper",
"hyper-util",
"libc",
"log 0.4.34",
"pem",
"rcgen",
@@ -11905,7 +11793,6 @@ dependencies = [
"tokio",
"tokio-rustls",
"tokio-tungstenite",
"tower-service",
]
[[package]]
@@ -11928,10 +11815,7 @@ dependencies = [
"base64 0.23.1",
"bytes",
"chrono",
"core-foundation 0.9.4",
"encoding_rs",
"flate2",
"futures-util",
"http-body-util",
"hyper",
"hyper-util",
@@ -11943,7 +11827,6 @@ dependencies = [
"objc2-foundation 0.3.2",
"objc2-service-management",
"plist",
"protoc-bin-vendored",
"r2d2",
"r2d2_sqlite",
"reqwest",
@@ -11951,39 +11834,22 @@ dependencies = [
"sea-query",
"serde",
"serde_json",
"system-configuration",
"tempfile",
"tokio",
"tonic",
"tonic-reflection",
"ts-rs",
"url",
"yaak-common",
"yaak-crypto",
"yaak-database",
"yaak-filter",
"yaak-grpc",
"yaak-http",
"yaak-lifecycle",
"yaak-models",
"yaak-proxy",
"yaak-proxy-helper",
"yaak-query",
"yaak-rpc",
"yaak-templates",
]
[[package]]
name = "yaak-query"
version = "0.1.0"
dependencies = [
"serde",
"serde-wasm-bindgen",
"serde_json",
"ts-rs",
"wasm-bindgen",
]
[[package]]
name = "yaak-rpc"
version = "0.0.0"
-2
View File
@@ -19,7 +19,6 @@ members = [
"crates/yaak-mcp",
"crates/yaak-models",
"crates/yaak-plugins",
"crates/yaak-query",
"crates/yaak-sse",
"crates/yaak-sync",
"crates/yaak-templates",
@@ -97,7 +96,6 @@ yaak-lifecycle = { path = "crates/yaak-lifecycle" }
yaak-mcp = { path = "crates/yaak-mcp" }
yaak-models = { path = "crates/yaak-models" }
yaak-plugins = { path = "crates/yaak-plugins" }
yaak-query = { path = "crates/yaak-query" }
yaak-sse = { path = "crates/yaak-sse" }
yaak-sync = { path = "crates/yaak-sync" }
yaak-templates = { path = "crates/yaak-templates" }
@@ -1,99 +0,0 @@
import { platform } from "@yaakapp-internal/platform";
import {
Banner,
type EditorHost,
EditorHostProvider,
EmptyStateText,
type HostBodyViewerArgs,
type HttpBodyLoader,
LoadingIcon,
type StoredValue,
type StoredValueNamespace,
} from "@yaakapp-internal/ui";
import { lazy, type ReactNode, Suspense } from "react";
import { useKeyValue } from "../hooks/useKeyValue";
import { copyToClipboard } from "../lib/copy";
import { showDialog } from "../lib/dialog";
import { showPrompt } from "../lib/prompt";
import { useDetailsBannerStoredValue } from "./core/DetailsBanner";
import { Editor } from "./core/Editor/LazyEditor";
import { Input } from "./core/Input";
import { ErrorBoundary } from "./ErrorBoundary";
import { MultipartViewer } from "./responseViewers/MultipartViewer";
import { TextViewer } from "./responseViewers/TextViewer";
import { SelectFile } from "./SelectFile";
const PdfViewer = lazy(() =>
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
);
function useClientStoredValue<T extends StoredValue>(
key: string | string[] | null,
fallback: T,
namespace: StoredValueNamespace = "global",
) {
// A null key is never written by the shared components, so any stable key will do
const { value, set } = useKeyValue<T>({ namespace, key: key ?? ["tabs", "default"], fallback });
return [value ?? fallback, set] as const;
}
function renderBodyViewer({ source, mimeType, body, complete }: HostBodyViewerArgs) {
if (!mimeType?.match(/pdf/i)) return null;
return source === "response" ? (
<PdfResponseBody body={body} complete={complete} />
) : (
<PdfRequestBody body={body} />
);
}
/** Waits for the whole response, then loads the PDF from its file */
function PdfResponseBody({ body, complete }: { body: HttpBodyLoader; complete: boolean }) {
const bodyUrl = body.useUrl(complete);
if (!complete || bodyUrl.isLoading) {
return (
<EmptyStateText>
<LoadingIcon />
</EmptyStateText>
);
}
if (bodyUrl.error) {
return <Banner color="danger">{String(bodyUrl.error)}</Banner>;
}
if (bodyUrl.data == null) {
return <div>Empty response body</div>;
}
return <PdfViewer bodyUrl={bodyUrl.data} />;
}
function PdfRequestBody({ body }: { body: HttpBodyLoader }) {
const bytes = body.useBytes();
if (bytes.data == null) return null;
return (
<Suspense fallback={<LoadingIcon />}>
<PdfViewer data={bytes.data} />
</Suspense>
);
}
const clientEditorHost: EditorHost = {
Editor,
Input,
SelectFile,
showDialog,
showPrompt,
basename: (path) => platform.files.basename(path),
useStoredValue: useClientStoredValue,
useDetailsBannerStoredValue,
ErrorBoundary,
TextViewer,
MultipartViewer,
renderBodyViewer,
openUrl: (url) => platform.openUrl(url),
copy: (text) => copyToClipboard(text, { disableToast: true }),
};
/** Gives the shared editing components the client's templated editor, dialogs and storage */
export function ClientEditorHost({ children }: { children: ReactNode }) {
return <EditorHostProvider value={clientEditorHost}>{children}</EditorHostProvider>;
}
@@ -0,0 +1,60 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { Banner, HStack, InlineCode, SizeTag } from "@yaakapp-internal/ui";
import { type ReactNode, useMemo } from "react";
import { useSaveResponse } from "../hooks/useSaveResponse";
import { useToggle } from "../hooks/useToggle";
import { isProbablyTextContentType } from "../lib/contentType";
import { getContentTypeFromHeaders } from "../lib/model_util";
import { getResponseBodyText } from "../lib/responseBody";
import { CopyButton } from "./CopyButton";
import { Button } from "./core/Button";
interface Props {
children: ReactNode;
response: HttpResponse;
}
const LARGE_BYTES = 2 * 1000 * 1000;
export function ConfirmLargeResponse({ children, response }: Props) {
const { mutate: saveResponse } = useSaveResponse(response);
const [showLargeResponse, toggleShowLargeResponse] = useToggle();
const isProbablyText = useMemo(() => {
const contentType = getContentTypeFromHeaders(response.headers);
return isProbablyTextContentType(contentType);
}, [response.headers]);
const contentLength = response.contentLength ?? 0;
const isLarge = contentLength > LARGE_BYTES;
if (!showLargeResponse && isLarge) {
return (
<Banner color="primary" className="flex flex-col gap-3">
<p>
Showing responses over{" "}
<InlineCode>
<SizeTag contentLength={LARGE_BYTES} />
</InlineCode>{" "}
may impact performance
</p>
<HStack wrap space={2}>
<Button color="primary" size="xs" onClick={toggleShowLargeResponse}>
Reveal Response
</Button>
<Button color="secondary" variant="border" size="xs" onClick={() => saveResponse()}>
Save to File
</Button>
{isProbablyText && (
<CopyButton
color="secondary"
variant="border"
size="xs"
text={() => getResponseBodyText({ response, filter: null })}
/>
)}
</HStack>
</Banner>
);
}
return <>{children}</>;
}
@@ -0,0 +1,55 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { Banner, HStack, InlineCode, SizeTag } from "@yaakapp-internal/ui";
import { type ReactNode, useMemo } from "react";
import { getRequestBodyText as getHttpResponseRequestBodyText } from "../hooks/useHttpRequestBody";
import { useToggle } from "../hooks/useToggle";
import { isProbablyTextContentType } from "../lib/contentType";
import { getContentTypeFromHeaders } from "../lib/model_util";
import { CopyButton } from "./CopyButton";
import { Button } from "./core/Button";
interface Props {
children: ReactNode;
response: HttpResponse;
}
const LARGE_BYTES = 2 * 1000 * 1000;
export function ConfirmLargeResponseRequest({ children, response }: Props) {
const [showLargeResponse, toggleShowLargeResponse] = useToggle();
const isProbablyText = useMemo(() => {
const contentType = getContentTypeFromHeaders(response.headers);
return isProbablyTextContentType(contentType);
}, [response.headers]);
const contentLength = response.requestContentLength ?? 0;
const isLarge = contentLength > LARGE_BYTES;
if (!showLargeResponse && isLarge) {
return (
<Banner color="primary" className="flex flex-col gap-3">
<p>
Showing content over{" "}
<InlineCode>
<SizeTag contentLength={LARGE_BYTES} />
</InlineCode>{" "}
may impact performance
</p>
<HStack wrap space={2}>
<Button color="primary" size="xs" onClick={toggleShowLargeResponse}>
Reveal Request Body
</Button>
{isProbablyText && (
<CopyButton
color="secondary"
variant="border"
size="xs"
text={() => getHttpResponseRequestBodyText(response).then((d) => d?.bodyText ?? "")}
/>
)}
</HStack>
</Banner>
);
}
return <>{children}</>;
}
+24 -1
View File
@@ -1 +1,24 @@
export { EmptyStateText } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { ReactNode } from "react";
interface Props {
children: ReactNode;
className?: string;
wrapperClassName?: string;
}
export function EmptyStateText({ children, className, wrapperClassName }: Props) {
return (
<div className={classNames("w-full h-full pb-2", wrapperClassName)}>
<div
className={classNames(
className,
"rounded-lg border border-dashed border-border-subtle",
"h-full py-2 text-text-subtlest flex items-center justify-center italic",
)}
>
{children}
</div>
</div>
);
}
@@ -1,42 +1,32 @@
import type { HttpRequest } from "@yaakapp-internal/models";
import { useCallback, useMemo } from "react";
import type { Pair, PairEditorProps } from "../pairs/PairEditor";
import { PairEditor } from "../pairs/PairEditor";
import type { HttpFormFieldView, HttpRequestBodyView } from "./types";
import type { Pair, PairEditorProps } from "./core/PairEditor";
import { PairEditor } from "./core/PairEditor";
type Props = {
forceUpdateKey: string;
/** The request's id, keeping each request's editor state apart */
requestId: string;
form: HttpFormFieldView[] | undefined;
onChange?: (body: HttpRequestBodyView) => void;
/** Show the fields without a way to change them */
readOnly?: boolean;
request: HttpRequest;
onChange: (body: HttpRequest["body"]) => void;
};
export function FormMultipartEditor({
requestId,
form,
forceUpdateKey,
onChange,
readOnly,
}: Props) {
export function FormMultipartEditor({ request, forceUpdateKey, onChange }: Props) {
const pairs = useMemo<Pair[]>(
() =>
(Array.isArray(form) ? form : []).map((p) => ({
(Array.isArray(request.body.form) ? request.body.form : []).map((p) => ({
enabled: p.enabled,
name: p.name,
value: (p.file ?? p.value) as string,
value: p.file ?? p.value,
contentType: p.contentType,
filename: p.filename,
isFile: !!p.file,
id: p.id,
})),
[form],
[request.body.form],
);
const handleChange = useCallback<PairEditorProps["onChange"]>(
(pairs) =>
onChange?.({
onChange({
form: pairs.map((p) => ({
enabled: p.enabled,
name: p.name,
@@ -60,9 +50,8 @@ export function FormMultipartEditor({
allowMultilineValues
pairs={pairs}
onChange={handleChange}
readOnly={readOnly}
forceUpdateKey={forceUpdateKey}
stateKey={`multipart.${requestId}`}
stateKey={`multipart.${request.id}`}
/>
);
}
@@ -0,0 +1,46 @@
import type { HttpRequest } from "@yaakapp-internal/models";
import { useCallback, useMemo } from "react";
import type { Pair, PairEditorProps } from "./core/PairEditor";
import { PairOrBulkEditor } from "./core/PairOrBulkEditor";
type Props = {
forceUpdateKey: string;
request: HttpRequest;
onChange: (headers: HttpRequest["body"]) => void;
};
export function FormUrlencodedEditor({ request, forceUpdateKey, onChange }: Props) {
const pairs = useMemo<Pair[]>(
() =>
(Array.isArray(request.body.form) ? request.body.form : []).map((p) => ({
enabled: !!p.enabled,
name: p.name || "",
value: p.value || "",
id: p.id,
})),
[request.body.form],
);
const handleChange = useCallback<PairEditorProps["onChange"]>(
(pairs) =>
onChange({ form: pairs.map((p) => ({ enabled: p.enabled, name: p.name, value: p.value })) }),
[onChange],
);
return (
<PairOrBulkEditor
allowMultilineValues
preferenceName="form_urlencoded"
valueAutocompleteFunctions
valueAutocompleteVariables
nameAutocompleteFunctions
nameAutocompleteVariables
namePlaceholder="entry_name"
valuePlaceholder="Value"
pairs={pairs}
onChange={handleChange}
forceUpdateKey={forceUpdateKey}
stateKey={`urlencoded.${request.id}`}
/>
);
}
+180 -1
View File
@@ -1 +1,180 @@
export { HeadersEditor } from "@yaakapp-internal/ui";
import type { HttpRequestHeader } from "@yaakapp-internal/models";
import { HStack, CountBadge } from "@yaakapp-internal/ui";
import { acceptLanguages } from "../lib/data/acceptLanguages";
import { cacheControlDirectives } from "../lib/data/cacheControl";
import { charsets } from "../lib/data/charsets";
import { connections } from "../lib/data/connections";
import { encodings } from "../lib/data/encodings";
import type { HeaderValuePreset } from "../lib/data/headerValuePresets";
import { headerNames } from "../lib/data/headerNames";
import { mimeTypes } from "../lib/data/mimetypes";
import { userAgents } from "../lib/data/userAgents";
import { validateHttpHeader } from "../lib/validateHttpHeader";
import { DetailsBanner } from "./core/DetailsBanner";
import type { GenericCompletion, GenericCompletionConfig } from "./core/Editor/genericCompletion";
import type { InputProps } from "./core/Input";
import type { Pair, PairEditorProps } from "./core/PairEditor";
import { PairEditorRow } from "./core/PairEditor";
import { ensurePairId } from "./core/PairEditor.util";
import { PairOrBulkEditor } from "./core/PairOrBulkEditor";
type Props = {
forceUpdateKey: string;
headers: HttpRequestHeader[];
inheritedHeaders?: HttpRequestHeader[];
inheritedHeadersLabel?: string;
stateKey: string;
onChange: (headers: HttpRequestHeader[]) => void;
label?: string;
};
export function HeadersEditor({
stateKey,
headers,
inheritedHeaders,
inheritedHeadersLabel = "Inherited",
onChange,
forceUpdateKey,
}: Props) {
// Get header names defined at current level (case-insensitive)
const currentHeaderNames = new Set(
headers.filter((h) => h.name).map((h) => h.name.toLowerCase()),
);
// Filter inherited headers: must be enabled, have content, and not be overridden by current level
const validInheritedHeaders =
inheritedHeaders?.filter(
(pair) =>
pair.enabled &&
(pair.name || pair.value) &&
!currentHeaderNames.has(pair.name.toLowerCase()),
) ?? [];
const hasInheritedHeaders = validInheritedHeaders.length > 0;
return (
<div
className={
hasInheritedHeaders
? "@container w-full h-full grid grid-rows-[auto_minmax(0,1fr)] gap-y-1.5"
: "@container w-full h-full"
}
>
{hasInheritedHeaders && (
<DetailsBanner
color="secondary"
className="text-sm"
summary={
<HStack>
{inheritedHeadersLabel} <CountBadge count={validInheritedHeaders.length} />
</HStack>
}
>
<div className="pb-2">
{validInheritedHeaders?.map((pair, i) => (
<PairEditorRow
key={`${pair.id}.${i}`}
index={i}
disabled
disableDrag
className="py-1"
pair={ensurePairId(pair)}
stateKey={null}
nameAutocompleteFunctions
nameAutocompleteVariables
valueAutocompleteFunctions
valueAutocompleteVariables
/>
))}
</div>
</DetailsBanner>
)}
<PairOrBulkEditor
forceUpdateKey={forceUpdateKey}
nameAutocomplete={nameAutocomplete}
nameAutocompleteFunctions
nameAutocompleteVariables
namePlaceholder="Header-Name"
nameValidate={validateHttpHeader}
onChange={onChange}
pairs={headers}
preferenceName="headers"
stateKey={stateKey}
valueType={valueType}
valueAutocomplete={valueAutocomplete}
valueAutocompleteFunctions
valueAutocompleteVariables
/>
</div>
);
}
const MIN_MATCH = 3;
const headerOptionsMap: Record<string, HeaderValuePreset[]> = {
"content-type": mimeTypes,
accept: ["*/*", ...mimeTypes],
"accept-encoding": encodings,
"content-encoding": encodings,
connection: connections,
"accept-charset": charsets,
"accept-language": acceptLanguages,
"cache-control": cacheControlDirectives,
"user-agent": userAgents,
pragma: ["no-cache"],
te: ["trailers", "compress", "deflate", "gzip"],
dnt: ["1", "0"],
"upgrade-insecure-requests": ["1"],
"x-requested-with": ["XMLHttpRequest"],
};
const valueType = (pair: Pair): InputProps["type"] => {
const name = pair.name.toLowerCase().trim();
if (
name.includes("authorization") ||
name.includes("api-key") ||
name.includes("access-token") ||
name.includes("auth") ||
name.includes("secret") ||
name.includes("token") ||
name === "cookie" ||
name === "set-cookie"
) {
return "password";
}
return "text";
};
const valueAutocomplete = (headerName: string): GenericCompletionConfig | undefined => {
const name = headerName.toLowerCase().trim();
const options: GenericCompletion[] =
headerOptionsMap[name]?.map((o) =>
typeof o === "string"
? {
label: o,
type: "constant",
boost: 1, // Put above other completions
}
: {
// Show the short label but insert the full value (e.g. User-Agent)
label: o.label,
apply: o.value,
type: "constant",
boost: 1,
},
) ?? [];
return { minMatch: MIN_MATCH, options };
};
const nameAutocomplete: PairEditorProps["nameAutocomplete"] = {
minMatch: MIN_MATCH,
options: headerNames.map((t) =>
typeof t === "string"
? {
label: t,
type: "constant",
boost: 1, // Put above other completions
}
: {
...t,
boost: 1, // Put above other completions
},
),
};
@@ -6,7 +6,7 @@ import type {
Workspace,
} from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models";
import { HStack, Icon, InlineCode, SegmentedControl } from "@yaakapp-internal/ui";
import { HStack, Icon, InlineCode } from "@yaakapp-internal/ui";
import { useCallback } from "react";
import { openFolderSettings } from "../commands/openFolderSettings";
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
@@ -21,6 +21,7 @@ import { IconButton } from "./core/IconButton";
import { Input, type InputProps } from "./core/Input";
import { Link } from "./core/Link";
import { RadioDropdown } from "./core/RadioDropdown";
import { SegmentedControl } from "./core/SegmentedControl";
import { DynamicForm } from "./DynamicForm";
import { EmptyStateText } from "./EmptyStateText";
+193 -164
View File
@@ -1,16 +1,9 @@
import type { HttpRequest } from "@yaakapp-internal/models";
import { getModel, patchModel, patchModelDebounced } from "@yaakapp-internal/models";
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
import {
CountBadge,
type EditablePair,
type ExtraPaneTab,
HttpRequestPaneView,
InlineCode,
languageFromContentType,
} from "@yaakapp-internal/ui";
import classNames from "classnames";
import { atom, useAtomValue } from "jotai";
import type { CSSProperties, ReactNode } from "react";
import type { CSSProperties } from "react";
import { lazy, Suspense, useCallback, useMemo, useRef, useState } from "react";
import { importCurl, looksLikeCurl } from "../commands/importCurl";
import { allRequestUrlsAtom } from "../hooks/useAllRequests";
@@ -22,8 +15,8 @@ import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
import { fireAndForget } from "../lib/fireAndForget";
import { httpRequestToView } from "../lib/httpViews";
import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
import { convertRequestBody } from "../lib/requestBodyConversion";
import {
BODY_TYPE_BINARY,
BODY_TYPE_FORM_MULTIPART,
@@ -35,22 +28,28 @@ import {
BODY_TYPE_XML,
getContentTypeFromHeaders,
} from "../lib/model_util";
import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
import { prepareImportQuerystring } from "../lib/prepareImportQuerystring";
import { convertRequestBody } from "../lib/requestBodyConversion";
import { resolvedModelName } from "../lib/resolvedModelName";
import { showToast } from "../lib/toast";
import { BinaryFileEditor } from "./BinaryFileEditor";
import { ConfirmLargeRequestBody } from "./ConfirmLargeRequestBody";
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
import { getUrlCompletionConfig } from "./core/Editor/url/completion";
import { Editor } from "./core/Editor/LazyEditor";
import { InlineCode, languageFromContentType, CountBadge } from "@yaakapp-internal/ui";
import { PlainInput } from "./core/PlainInput";
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
import { setActiveTab } from "./core/Tabs/Tabs";
import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs";
import { EmptyStateText } from "./EmptyStateText";
import { FormMultipartEditor } from "./FormMultipartEditor";
import { FormUrlencodedEditor } from "./FormUrlencodedEditor";
import { HeadersEditor } from "./HeadersEditor";
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
import { JsonBodyEditor } from "./JsonBodyEditor";
import { MarkdownEditor } from "./MarkdownEditor";
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
import { RequestMethodDropdown } from "./RequestMethodDropdown";
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
import { UrlBar } from "./UrlBar";
import { UrlParametersEditor } from "./UrlParameterEditor";
import { generateId } from "@yaakapp-internal/lib/generateId";
@@ -166,9 +165,9 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
const hasDescription = activeRequest.description.trim().length > 0;
const bodyTab = useMemo<TabItem>(
() =>
({
const tabs = useMemo<TabItem[]>(
() => [
{
value: TAB_BODY,
rightSlot: numParams > 0 ? <CountBadge count={numParams} /> : null,
options: {
@@ -245,8 +244,36 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
}
},
},
}) satisfies TabItem,
[activeRequest, contentType, handleContentTypeChange, numParams],
},
{
value: TAB_PARAMS,
rightSlot: <CountBadge count={urlParameterPairs.length} />,
label: "Params",
},
...headersTab,
...authTab,
{
value: TAB_SETTINGS,
label: "Settings",
rightSlot: <CountBadge count={numSettingsOverrides} />,
},
{
value: TAB_DESCRIPTION,
label: "Info",
rightSlot: hasDescription && <CountBadge count={true} />,
},
],
[
activeRequest,
authTab,
contentType,
handleContentTypeChange,
hasDescription,
headersTab,
numParams,
numSettingsOverrides,
urlParameterPairs.length,
],
);
const { mutate: sendRequest } = useSendAnyHttpRequest();
@@ -308,152 +335,154 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
[activeRequest],
);
const handleJsonOrBodyTextChange = useCallback(
(text: string, bodyType: string | null) => {
// JSON writes at once, as its editor always has; other text debounces
if (bodyType === BODY_TYPE_JSON) {
return patchModel(activeRequest, { body: { ...activeRequest.body, text } });
}
return handleBodyTextChange(text);
},
[activeRequest, handleBodyTextChange],
);
const handleSendJsonCommentsChange = useCallback(
(sendJsonComments: boolean) => {
const newBody = { ...activeRequest.body };
if (sendJsonComments) {
newBody.sendJsonComments = true;
} else {
delete newBody.sendJsonComments;
}
fireAndForget(patchModel(activeRequest, { body: newBody }));
},
[activeRequest],
);
const requestView = useMemo(() => httpRequestToView(activeRequest), [activeRequest]);
const extraTabs = useMemo<ExtraPaneTab[]>(
() => [
{
tab: {
value: TAB_SETTINGS,
label: "Settings",
rightSlot: <CountBadge count={numSettingsOverrides} />,
},
content: <ModelSettingsEditor model={activeRequest} />,
},
{
tab: {
value: TAB_DESCRIPTION,
label: "Info",
rightSlot: hasDescription && <CountBadge count={true} />,
},
content: (
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full">
<PlainInput
label="Request Name"
hideLabel
forceUpdateKey={updateKey}
defaultValue={activeRequest.name}
className="font-sans text-xl! px-0!"
containerClassName="border-0"
placeholder={resolvedModelName(activeRequest)}
onChange={(name) => patchModel(activeRequest, { name })}
/>
<MarkdownEditor
name="request-description"
placeholder="Request description"
defaultValue={activeRequest.description}
stateKey={`description.${activeRequest.id}`}
forceUpdateKey={updateKey}
onChange={(description) => patchModel(activeRequest, { description })}
/>
</div>
),
},
],
[activeRequest, hasDescription, numSettingsOverrides, updateKey],
);
const renderBody = useCallback(
(sharedEditor: ReactNode) => (
<ConfirmLargeRequestBody request={activeRequest}>
{activeRequest.bodyType === BODY_TYPE_GRAPHQL ? (
<Suspense>
<GraphQLEditor
forceUpdateKey={forceUpdateKey}
baseRequest={activeRequest}
request={activeRequest}
onChange={handleBodyChange}
/>
</Suspense>
) : activeRequest.bodyType === BODY_TYPE_BINARY ? (
<BinaryFileEditor
requestId={activeRequest.id}
contentType={contentType}
body={activeRequest.body}
onChange={(body) => patchModelDebounced(activeRequest, { body })}
onChangeContentType={handleContentTypeChange}
/>
) : (
sharedEditor
)}
</ConfirmLargeRequestBody>
),
[activeRequest, contentType, forceUpdateKey, handleBodyChange, handleContentTypeChange],
);
const renderUrlParameters = useCallback(
(pairs: EditablePair[]) => (
<UrlParametersEditor
stateKey={`params.${activeRequest.id}`}
forceUpdateKey={forceUpdateKey + urlParametersKey}
pairs={pairs}
onChange={(urlParameters) => patchModelDebounced(activeRequest, { urlParameters })}
/>
),
[activeRequest, forceUpdateKey, urlParametersKey],
);
return (
<HttpRequestPaneView
editable
<div
style={style}
className={className}
fullHeight={fullHeight}
request={requestView}
tabsRef={tabsRef}
tabsStorageKey={TABS_STORAGE_KEY}
forceUpdateKey={forceUpdateKey}
urlBarKey={forceUpdateKey + urlKey}
urlForceUpdateKey={updateKey}
urlAutocomplete={autocomplete}
onUrlChange={handleUrlChange}
onUrlPasteOverwrite={handlePaste}
onSend={handleSend}
onCancel={cancelResponse}
isSending={activeResponse != null && activeResponse.state !== "closed"}
sendHotkeyAction="request.send"
focusHotkeyAction="url_bar.focus"
methodSlot={<RequestMethodDropdown request={activeRequest} className="ml-0.5 h-full!" />}
bodyTab={bodyTab}
renderBody={renderBody}
onBodyChange={handleBodyChange}
onBodyTextChange={handleJsonOrBodyTextChange}
onSendJsonCommentsChange={handleSendJsonCommentsChange}
jsonNoticeStorageKey={activeRequest.workspaceId}
urlParameterPairs={urlParameterPairs}
renderUrlParameters={renderUrlParameters}
headersTabs={headersTab}
inheritedHeaders={inheritedHeaders}
headersForceUpdateKey={`${forceUpdateHeaderEditorKey}::${forceUpdateKey}`}
onHeadersChange={(headers) => patchModelDebounced(activeRequest, { headers })}
authTabs={authTab}
authContent={<HttpAuthenticationEditor model={activeRequest} />}
extraTabs={extraTabs}
/>
className={classNames(className, "h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1")}
>
{activeRequest && (
<>
<UrlBar
stateKey={`url.${activeRequest.id}`}
key={forceUpdateKey + urlKey}
url={activeRequest.url}
placeholder="https://example.com"
onPasteOverwrite={handlePaste}
autocomplete={autocomplete}
onSend={handleSend}
onCancel={cancelResponse}
onUrlChange={handleUrlChange}
leftSlot={
<div className="py-0.5">
<RequestMethodDropdown request={activeRequest} className="ml-0.5 h-full!" />
</div>
}
forceUpdateKey={updateKey}
isLoading={activeResponse != null && activeResponse.state !== "closed"}
/>
<Tabs
ref={tabsRef}
label="Request"
tabs={tabs}
tabListClassName="mt-1 -mb-1.5"
storageKey={TABS_STORAGE_KEY}
activeTabKey={activeRequestId}
>
<TabContent value={TAB_AUTH}>
<HttpAuthenticationEditor model={activeRequest} />
</TabContent>
<TabContent value={TAB_HEADERS}>
<HeadersEditor
inheritedHeaders={inheritedHeaders}
forceUpdateKey={`${forceUpdateHeaderEditorKey}::${forceUpdateKey}`}
headers={activeRequest.headers}
stateKey={`headers.${activeRequest.id}`}
onChange={(headers) => patchModelDebounced(activeRequest, { headers })}
/>
</TabContent>
<TabContent value={TAB_PARAMS}>
<UrlParametersEditor
stateKey={`params.${activeRequest.id}`}
forceUpdateKey={forceUpdateKey + urlParametersKey}
pairs={urlParameterPairs}
onChange={(urlParameters) => patchModelDebounced(activeRequest, { urlParameters })}
/>
</TabContent>
<TabContent value={TAB_SETTINGS}>
<ModelSettingsEditor model={activeRequest} />
</TabContent>
<TabContent value={TAB_BODY}>
<ConfirmLargeRequestBody request={activeRequest}>
{activeRequest.bodyType === BODY_TYPE_JSON ? (
<JsonBodyEditor
forceUpdateKey={forceUpdateKey}
heightMode={fullHeight ? "full" : "auto"}
request={activeRequest}
/>
) : activeRequest.bodyType === BODY_TYPE_XML ? (
<Editor
forceUpdateKey={forceUpdateKey}
autocompleteFunctions
autocompleteVariables
placeholder="..."
heightMode={fullHeight ? "full" : "auto"}
defaultValue={`${activeRequest.body?.text ?? ""}`}
language="xml"
onChange={handleBodyTextChange}
stateKey={`xml.${activeRequest.id}`}
/>
) : activeRequest.bodyType === BODY_TYPE_GRAPHQL ? (
<Suspense>
<GraphQLEditor
forceUpdateKey={forceUpdateKey}
baseRequest={activeRequest}
request={activeRequest}
onChange={handleBodyChange}
/>
</Suspense>
) : activeRequest.bodyType === BODY_TYPE_FORM_URLENCODED ? (
<FormUrlencodedEditor
forceUpdateKey={forceUpdateKey}
request={activeRequest}
onChange={handleBodyChange}
/>
) : activeRequest.bodyType === BODY_TYPE_FORM_MULTIPART ? (
<FormMultipartEditor
forceUpdateKey={forceUpdateKey}
request={activeRequest}
onChange={handleBodyChange}
/>
) : activeRequest.bodyType === BODY_TYPE_BINARY ? (
<BinaryFileEditor
requestId={activeRequest.id}
contentType={contentType}
body={activeRequest.body}
onChange={(body) => patchModelDebounced(activeRequest, { body })}
onChangeContentType={handleContentTypeChange}
/>
) : typeof activeRequest.bodyType === "string" ? (
<Editor
forceUpdateKey={forceUpdateKey}
autocompleteFunctions
autocompleteVariables
language={languageFromContentType(contentType)}
placeholder="..."
heightMode={fullHeight ? "full" : "auto"}
defaultValue={`${activeRequest.body?.text ?? ""}`}
onChange={handleBodyTextChange}
stateKey={`other.${activeRequest.id}`}
/>
) : (
<EmptyStateText>No Body</EmptyStateText>
)}
</ConfirmLargeRequestBody>
</TabContent>
<TabContent value={TAB_DESCRIPTION}>
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full">
<PlainInput
label="Request Name"
hideLabel
forceUpdateKey={updateKey}
defaultValue={activeRequest.name}
className="font-sans text-xl! px-0!"
containerClassName="border-0"
placeholder={resolvedModelName(activeRequest)}
onChange={(name) => patchModel(activeRequest, { name })}
/>
<MarkdownEditor
name="request-description"
placeholder="Request description"
defaultValue={activeRequest.description}
stateKey={`description.${activeRequest.id}`}
forceUpdateKey={updateKey}
onChange={(description) => patchModel(activeRequest, { description })}
/>
</div>
</TabContent>
</Tabs>
</>
)}
</div>
);
}
+354 -180
View File
@@ -1,38 +1,56 @@
import type { HttpResponse, HttpResponseEvent } from "@yaakapp-internal/models";
import {
CountBadge,
type ExtraPaneTab,
type HostBodyViewerArgs,
Banner,
HStack,
HttpResponsePaneView,
Icon,
type ResponseTextViewerExtras,
type TextFilterApi,
Tooltip,
LoadingIcon,
VStack,
CountBadge,
HttpResponseDurationTag,
HttpStatusTag,
SizeTag,
Tooltip,
AudioViewer,
CsvViewer,
ImageViewer,
SvgViewer,
VideoViewer,
} from "@yaakapp-internal/ui";
import type { CSSProperties } from "react";
import { useCallback, useMemo } from "react";
import classNames from "classnames";
import type { ComponentType, CSSProperties } from "react";
import { lazy, Suspense, useMemo } from "react";
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
import { useCopyHttpResponse } from "../hooks/useCopyHttpResponse";
import { useRequestBodyLoader, useResponseBodyLoader } from "../hooks/useHttpBodyLoaders";
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
import { useRecentFilters } from "../hooks/useRecentFilters";
import { useResponseBodyBytes, useResponseBodyText } from "../hooks/useResponseBodyText";
import { useResponseBodyUrl } from "../hooks/useResponseBodyUrl";
import { useResponseViewMode } from "../hooks/useResponseViewMode";
import { useSaveResponse } from "../hooks/useSaveResponse";
import { useTimelineViewMode } from "../hooks/useTimelineViewMode";
import { httpResponseToView } from "../lib/httpViews";
import { getCookieCounts } from "../lib/model_util";
import { CopyButton } from "./CopyButton";
import { getMimeTypeFromContentType } from "../lib/contentType";
import { getContentTypeFromHeaders, getCookieCounts } from "../lib/model_util";
import { ConfirmLargeResponse } from "./ConfirmLargeResponse";
import { ConfirmLargeResponseRequest } from "./ConfirmLargeResponseRequest";
import { Button } from "./core/Button";
import { HotkeyList } from "./core/HotkeyList";
import { IconButton } from "./core/IconButton";
import { PillButton } from "./core/PillButton";
import type { TabItem } from "./core/Tabs/Tabs";
import { TabContent, Tabs } from "./core/Tabs/Tabs";
import { EmptyStateText } from "./EmptyStateText";
import { ErrorBoundary } from "./ErrorBoundary";
import { HttpResponseTimeline } from "./HttpResponseTimeline";
import { RecentHttpResponsesDropdown } from "./RecentHttpResponsesDropdown";
import { RequestVersionDropdown } from "./RequestVersionDropdown";
import { RequestBodyViewer } from "./RequestBodyViewer";
import { ResponseCookies } from "./ResponseCookies";
import { ResponseHeaders } from "./ResponseHeaders";
import { EventStreamViewer } from "./responseViewers/EventStreamViewer";
import { RecentFiltersDropdown } from "./responseViewers/RecentFiltersDropdown";
import { HTMLOrTextViewer } from "./responseViewers/HTMLOrTextViewer";
import { MultipartViewer } from "./responseViewers/MultipartViewer";
const PdfViewer = lazy(() =>
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
);
interface Props {
style?: CSSProperties;
@@ -40,6 +58,10 @@ interface Props {
activeRequestId: string;
}
const TAB_BODY = "body";
const TAB_REQUEST = "request";
const TAB_HEADERS = "headers";
const TAB_COOKIES = "cookies";
const TAB_TIMELINE = "timeline";
export type TimelineViewMode = "timeline" | "text";
@@ -49,11 +71,12 @@ interface RedirectDropWarning {
droppedHeaders: string[];
}
/** The shared response pane, bound to the pinned response of the active request */
export function HttpResponsePane({ style, className, activeRequestId }: Props) {
const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId);
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
const [timelineViewMode, setTimelineViewMode] = useTimelineViewMode();
const contentType = getContentTypeFromHeaders(activeResponse?.headers ?? null);
const mimeType = contentType == null ? null : getMimeTypeFromContentType(contentType).essence;
const responseEvents = useHttpResponseEvents(activeResponse);
const redirectDropWarning = useMemo(
@@ -66,184 +89,280 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
const cookieCounts = useMemo(() => getCookieCounts(responseEvents.data), [responseEvents.data]);
const saveResponse = useSaveResponse(activeResponse ?? null);
const copyResponse = useCopyHttpResponse(activeResponse ?? null);
const cancel = useCancelHttpResponse(activeResponse?.id ?? null);
const body = useResponseBodyLoader(activeResponse ?? null);
const requestBody = useRequestBodyLoader(activeResponse ?? null);
const textViewer = useResponseTextViewerExtras(activeResponse ?? null);
const responseView = useMemo(
() => (activeResponse == null ? null : httpResponseToView(activeResponse, responseEvents.data)),
[activeResponse, responseEvents.data],
);
const extraTabs = useMemo<ExtraPaneTab[]>(
() =>
activeResponse == null
? []
: [
const tabs = useMemo<TabItem[]>(
() => [
{
value: TAB_BODY,
label: "Response",
options: {
value: viewMode,
onChange: setViewMode,
items: [
{ label: "Response", value: "pretty" },
...(mimeType?.startsWith("image")
? []
: [{ label: "Response (Raw)", shortLabel: "Raw", value: "raw" }]),
],
itemsAfter: [
{
tab: {
value: TAB_TIMELINE,
rightSlot: <CountBadge count={responseEvents.data?.length ?? 0} />,
options: {
value: timelineViewMode,
onChange: (v) => setTimelineViewMode((v as TimelineViewMode) ?? "timeline"),
items: [
{ label: "Timeline", value: "timeline" },
{ label: "Timeline (Text)", shortLabel: "Timeline", value: "text" },
],
},
},
content: (
<HttpResponseTimeline response={activeResponse} viewMode={timelineViewMode} />
),
label: "Save to File",
onSelect: saveResponse.mutate,
leftSlot: <Icon icon="save" />,
hidden: activeResponse == null || !!activeResponse.error,
disabled: activeResponse?.state !== "closed" && (activeResponse?.status ?? 0) >= 100,
},
{
label: "Copy Body",
onSelect: copyResponse.mutate,
leftSlot: <Icon icon="copy" />,
hidden: activeResponse == null || !!activeResponse.error,
disabled: activeResponse?.state !== "closed" && (activeResponse?.status ?? 0) >= 100,
},
],
[activeResponse, responseEvents.data?.length, setTimelineViewMode, timelineViewMode],
},
},
{
value: TAB_REQUEST,
label: "Request",
rightSlot:
(activeResponse?.requestContentLength ?? 0) > 0 ? <CountBadge count={true} /> : null,
},
{
value: TAB_HEADERS,
label: "Headers",
rightSlot: (
<CountBadge
count={activeResponse?.requestHeaders.length ?? 0}
count2={activeResponse?.headers.length ?? 0}
showZero
/>
),
},
{
value: TAB_COOKIES,
label: "Cookies",
rightSlot:
cookieCounts.sent > 0 || cookieCounts.received > 0 ? (
<CountBadge count={cookieCounts.sent} count2={cookieCounts.received} showZero />
) : null,
},
{
value: TAB_TIMELINE,
rightSlot: <CountBadge count={responseEvents.data?.length ?? 0} />,
options: {
value: timelineViewMode,
onChange: (v) => setTimelineViewMode((v as TimelineViewMode) ?? "timeline"),
items: [
{ label: "Timeline", value: "timeline" },
{ label: "Timeline (Text)", shortLabel: "Timeline", value: "text" },
],
},
},
],
[
activeResponse?.headers,
activeResponse,
activeResponse?.error,
activeResponse?.requestContentLength,
activeResponse?.requestHeaders.length,
activeResponse?.state,
activeResponse?.status,
cookieCounts.sent,
cookieCounts.received,
copyResponse.mutate,
mimeType,
responseEvents.data?.length,
saveResponse.mutate,
setViewMode,
viewMode,
timelineViewMode,
setTimelineViewMode,
],
);
const renderBodyViewer = useCallback(
({ mimeType, viewMode }: HostBodyViewerArgs) =>
activeResponse != null && mimeType?.match(/^text\/event-stream/i) && viewMode === "pretty" ? (
<EventStreamViewer response={activeResponse} />
) : null,
[activeResponse],
);
const cancel = useCancelHttpResponse(activeResponse?.id ?? null);
return (
<HttpResponsePaneView
<div
style={style}
className={className}
response={responseView}
body={body}
requestBody={requestBody}
emptyState={
className={classNames(
className,
"x-theme-responsePane",
"max-h-full h-full",
"bg-surface rounded-md border border-border-subtle overflow-hidden",
"relative",
)}
>
{activeResponse == null ? (
<HotkeyList hotkeys={["request.send", "model.create", "sidebar.focus", "url_bar.focus"]} />
}
viewMode={viewMode === "raw" ? "raw" : "pretty"}
onViewModeChange={setViewMode}
activeTabKey={activeRequestId}
onSaveBody={saveResponse.mutate}
onCopyBody={copyResponse.mutate}
onCancel={cancel.mutate}
cookieCounts={cookieCounts}
extraTabs={extraTabs}
textViewer={textViewer}
renderBodyViewer={renderBodyViewer}
renderCopyButton={renderCopyButton}
statusLineCenter={
shouldShowRedirectDropWarning ? (
<Tooltip
tabIndex={0}
className="my-auto pl-3 shrink-0 max-w-full justify-self-end overflow-hidden"
content={
<VStack alignItems="start" space={1} className="text-xs">
<span className="font-medium text-warning">Redirect changed this request</span>
{redirectDropWarning.droppedBodyCount > 0 && (
<span>
Body dropped on {redirectDropWarning.droppedBodyCount}{" "}
{redirectDropWarning.droppedBodyCount === 1 ? "redirect hop" : "redirect hops"}
</span>
)}
{redirectDropWarning.droppedHeaders.length > 0 && (
<span>
Headers dropped:{" "}
<span className="font-mono">
{redirectDropWarning.droppedHeaders.join(", ")}
</span>
</span>
)}
<span className="text-text-subtle">See Timeline for details.</span>
</VStack>
}
) : (
<div className="h-full w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1">
<HStack
className={classNames(
"text-text-subtle w-full shrink-0",
// Remove a bit of space because the tabs have lots too
"-mb-1.5",
)}
>
<span className="inline-flex min-w-0">
<PillButton
color="warning"
className="font-sans text-sm shrink! max-w-full"
innerClassName="flex items-center"
leftSlot={<Icon icon="alert_triangle" size="xs" color="warning" />}
{activeResponse && (
<div
className={classNames(
"grid grid-cols-[auto_minmax(4rem,1fr)_auto]",
"cursor-default select-none",
"whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars",
)}
>
<span className="truncate">{getRedirectWarningLabel(redirectDropWarning)}</span>
</PillButton>
</span>
</Tooltip>
) : null
}
statusLineRight={
activeResponse != null && (
<HStack space={1}>
<RequestVersionDropdown response={activeResponse} />
<RecentHttpResponsesDropdown
responses={responses}
activeResponse={activeResponse}
onPinnedResponseId={setPinnedResponseId}
/>
<HStack space={2} className="w-full shrink-0">
{activeResponse.state !== "closed" && <LoadingIcon size="sm" />}
<HttpStatusTag showReason response={activeResponse} />
<span>&bull;</span>
<HttpResponseDurationTag response={activeResponse} />
<span>&bull;</span>
<SizeTag
contentLength={activeResponse.contentLength ?? 0}
contentLengthCompressed={activeResponse.contentLengthCompressed}
/>
</HStack>
{shouldShowRedirectDropWarning ? (
<Tooltip
tabIndex={0}
className="my-auto pl-3 shrink-0 max-w-full justify-self-end overflow-hidden"
content={
<VStack alignItems="start" space={1} className="text-xs">
<span className="font-medium text-warning">
Redirect changed this request
</span>
{redirectDropWarning.droppedBodyCount > 0 && (
<span>
Body dropped on {redirectDropWarning.droppedBodyCount}{" "}
{redirectDropWarning.droppedBodyCount === 1
? "redirect hop"
: "redirect hops"}
</span>
)}
{redirectDropWarning.droppedHeaders.length > 0 && (
<span>
Headers dropped:{" "}
<span className="font-mono">
{redirectDropWarning.droppedHeaders.join(", ")}
</span>
</span>
)}
<span className="text-text-subtle">See Timeline for details.</span>
</VStack>
}
>
<span className="inline-flex min-w-0">
<PillButton
color="warning"
className="font-sans text-sm shrink! max-w-full"
innerClassName="flex items-center"
leftSlot={<Icon icon="alert_triangle" size="xs" color="warning" />}
>
<span className="truncate">
{getRedirectWarningLabel(redirectDropWarning)}
</span>
</PillButton>
</span>
</Tooltip>
) : (
<span />
)}
<div className="justify-self-end shrink-0">
<RecentHttpResponsesDropdown
responses={responses}
activeResponse={activeResponse}
onPinnedResponseId={setPinnedResponseId}
/>
</div>
</div>
)}
</HStack>
)
}
/>
<div className="overflow-hidden flex flex-col min-h-0">
{activeResponse?.error && (
<Banner color="danger" className="mx-3 mt-1 shrink-0">
{activeResponse.error}
</Banner>
)}
{/* Show tabs if we have any data (headers, body, etc.) even if there's an error */}
<Tabs
tabs={tabs}
label="Response"
className="ml-3 mr-3 mb-3 min-h-0 flex-1"
tabListClassName="mt-0.5 -mb-1.5"
storageKey="http_response_tabs"
activeTabKey={activeRequestId}
>
<TabContent value={TAB_BODY}>
<ErrorBoundary name="Http Response Viewer">
<Suspense>
<ConfirmLargeResponse response={activeResponse}>
{activeResponse.state === "initialized" ? (
<EmptyStateText>
<VStack space={3}>
<HStack space={3}>
<LoadingIcon className="text-text-subtlest" />
Sending Request
</HStack>
<Button size="sm" variant="border" onClick={() => cancel.mutate()}>
Cancel
</Button>
</VStack>
</EmptyStateText>
) : activeResponse.state === "closed" &&
(activeResponse.contentLength ?? 0) === 0 ? (
<EmptyStateText>Empty</EmptyStateText>
) : mimeType?.match(/^text\/event-stream/i) && viewMode === "pretty" ? (
<EventStreamViewer response={activeResponse} />
) : mimeType?.match(/^image\/svg/) ? (
<HttpSvgViewer response={activeResponse} />
) : mimeType?.match(/^image/i) ? (
<EnsureCompleteResponse response={activeResponse} Component={ImageViewer} />
) : mimeType?.match(/^audio/i) ? (
<EnsureCompleteResponse response={activeResponse} Component={AudioViewer} />
) : mimeType?.match(/^video/i) ? (
<EnsureCompleteResponse response={activeResponse} Component={VideoViewer} />
) : mimeType?.match(/^multipart/i) && viewMode === "pretty" ? (
<HttpMultipartViewer response={activeResponse} />
) : mimeType?.match(/pdf/i) ? (
<EnsureCompleteResponse response={activeResponse} Component={PdfViewer} />
) : mimeType?.match(/csv|tab-separated/i) && viewMode === "pretty" ? (
<HttpCsvViewer className="pb-2" response={activeResponse} />
) : (
<HTMLOrTextViewer
textViewerClassName="-mr-2 bg-surface" // Pull to the right
response={activeResponse}
pretty={viewMode === "pretty"}
/>
)}
</ConfirmLargeResponse>
</Suspense>
</ErrorBoundary>
</TabContent>
<TabContent value={TAB_REQUEST}>
<ConfirmLargeResponseRequest response={activeResponse}>
<RequestBodyViewer response={activeResponse} />
</ConfirmLargeResponseRequest>
</TabContent>
<TabContent value={TAB_HEADERS}>
<ResponseHeaders response={activeResponse} />
</TabContent>
<TabContent value={TAB_COOKIES}>
<ResponseCookies response={activeResponse} />
</TabContent>
<TabContent value={TAB_TIMELINE}>
<HttpResponseTimeline response={activeResponse} viewMode={timelineViewMode} />
</TabContent>
</Tabs>
</div>
</div>
)}
</div>
);
}
/** The client's additions to the response text viewer: filter history, save and copy */
function useResponseTextViewerExtras(
response: HttpResponse | null,
): ResponseTextViewerExtras | undefined {
const filterStateKey = `response.body.${response?.requestId ?? ""}`;
const { recentFilters, addFilter, removeFilter, togglePin, clearFilters } =
useRecentFilters(filterStateKey);
const saveResponse = useSaveResponse(response);
const copyResponse = useCopyHttpResponse(response);
const filterLeftSlot = useCallback(
(filter: TextFilterApi) => (
<div className="py-0.5 flex">
<RecentFiltersDropdown
recentFilters={recentFilters}
activeFilter={filter.appliedFilter}
onSelect={filter.replaceFilter}
onRemove={removeFilter}
onTogglePin={togglePin}
onClear={clearFilters}
/>
</div>
),
[recentFilters, removeFilter, togglePin, clearFilters],
);
if (response == null) return undefined;
const actionsDisabled = response.state !== "closed" && response.status >= 100;
return {
filterStateKey,
onFilterApplied: addFilter,
filterLeftSlot,
footerActions: [
<IconButton
key="save"
size="sm"
icon="save"
title="Save response to file"
disabled={actionsDisabled}
onClick={() => saveResponse.mutate()}
className="border !border-border-subtle"
/>,
<IconButton
key="copy"
size="sm"
icon="copy"
title="Copy response body"
disabled={actionsDisabled}
onClick={() => copyResponse.mutate()}
className="border !border-border-subtle"
/>,
],
};
}
function renderCopyButton(getText: () => Promise<string | null>) {
return <CopyButton color="secondary" variant="border" size="xs" text={getText} />;
}
function getRedirectDropWarning(
events: HttpResponseEvent[] | undefined,
): RedirectDropWarning | null {
@@ -291,3 +410,58 @@ function getRedirectWarningLabel(warning: RedirectDropWarning): string {
}
return "Dropped headers";
}
function EnsureCompleteResponse({
response,
Component,
}: {
response: HttpResponse;
Component: ComponentType<{ bodyUrl: string }>;
}) {
// Wait until the response has been fully-downloaded before asking for it
const complete = response.state === "closed";
const bodyUrl = useResponseBodyUrl(complete ? response : null);
if (!complete || bodyUrl.isPending) {
return (
<EmptyStateText>
<LoadingIcon />
</EmptyStateText>
);
}
if (bodyUrl.error) {
return <Banner color="danger">{String(bodyUrl.error)}</Banner>;
}
if (bodyUrl.data == null) {
return <div>Empty response body</div>;
}
return <Component bodyUrl={bodyUrl.data} />;
}
function HttpSvgViewer({ response }: { response: HttpResponse }) {
const body = useResponseBodyText({ response, filter: null });
if (!body.data) return null;
return <SvgViewer text={body.data} />;
}
function HttpCsvViewer({ response, className }: { response: HttpResponse; className?: string }) {
const body = useResponseBodyText({ response, filter: null });
return <CsvViewer text={body.data ?? null} className={className} />;
}
function HttpMultipartViewer({ response }: { response: HttpResponse }) {
const body = useResponseBodyBytes({ response });
if (body.data == null) return null;
const contentTypeHeader = getContentTypeFromHeaders(response.headers);
const boundary = contentTypeHeader?.split("boundary=")[1] ?? "unknown";
return <MultipartViewer data={body.data} boundary={boundary} idPrefix={response.id} />;
}
@@ -5,15 +5,7 @@ import {
type ImportSource,
type Workspace,
} from "@yaakapp-internal/models";
import {
Banner,
FormattedError,
HStack,
Icon,
VStack,
IconTooltip,
SegmentedControl,
} from "@yaakapp-internal/ui";
import { Banner, FormattedError, HStack, Icon, VStack, IconTooltip } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { type ComponentProps, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { errorMessage } from "../lib/errorMessage";
@@ -30,6 +22,7 @@ import { CheckboxTree } from "./core/CheckboxTree";
import { Chip } from "./core/Chip";
import { DialogFooter } from "./core/Dialog";
import { Select } from "./core/Select";
import { SegmentedControl } from "./core/SegmentedControl";
import { pluralize } from "@yaakapp-internal/lib/pluralize";
interface Props {
@@ -0,0 +1,121 @@
import { linter } from "@codemirror/lint";
import type { HttpRequest } from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models";
import { Banner, Icon, IconTooltip } from "@yaakapp-internal/ui";
import { useCallback, useMemo } from "react";
import { useKeyValue } from "../hooks/useKeyValue";
import { fireAndForget } from "../lib/fireAndForget";
import { textLikelyContainsJsonComments } from "../lib/jsonComments";
import type { DropdownItem } from "./core/Dropdown";
import { Dropdown } from "./core/Dropdown";
import type { EditorProps } from "./core/Editor/Editor";
import { jsonParseLinter } from "./core/Editor/json-lint";
import { Editor } from "./core/Editor/LazyEditor";
import { IconButton } from "./core/IconButton";
interface Props {
forceUpdateKey: string;
heightMode: EditorProps["heightMode"];
request: HttpRequest;
}
export function JsonBodyEditor({ forceUpdateKey, heightMode, request }: Props) {
const handleChange = useCallback(
(text: string) => patchModel(request, { body: { ...request.body, text } }),
[request],
);
const autoFix = request.body?.sendJsonComments !== true;
const lintExtension = useMemo(
() =>
linter(
jsonParseLinter(
autoFix
? { allowComments: true, allowTrailingCommas: true }
: { allowComments: false, allowTrailingCommas: false },
),
),
[autoFix],
);
const hasComments = useMemo(
() => textLikelyContainsJsonComments(request.body?.text ?? ""),
[request.body?.text],
);
const { value: bannerDismissed, set: setBannerDismissed } = useKeyValue<boolean>({
namespace: "no_sync",
key: ["json-fix-3", request.workspaceId],
fallback: false,
});
const handleToggleAutoFix = useCallback(() => {
const newBody = { ...request.body };
if (autoFix) {
newBody.sendJsonComments = true;
} else {
delete newBody.sendJsonComments;
}
fireAndForget(patchModel(request, { body: newBody }));
}, [request, autoFix]);
const handleDropdownOpen = useCallback(() => {
if (!bannerDismissed) {
fireAndForget(setBannerDismissed(true));
}
}, [bannerDismissed, setBannerDismissed]);
const showBanner = hasComments && autoFix && !bannerDismissed;
const stripMessage = "Automatically strip comments and trailing commas before sending";
const actions = useMemo<EditorProps["actions"]>(
() => [
showBanner && (
<Banner color="notice" className="opacity-100! h-sm py-0! px-2! flex items-center text-xs">
<p className="inline-flex items-center gap-1 min-w-0">
<span className="truncate">Auto-fix enabled</span>
<Icon icon="arrow_right" size="sm" className="opacity-disabled" />
</p>
</Banner>
),
<div key="settings" className="opacity-100! shadow!">
<Dropdown
onOpen={handleDropdownOpen}
items={
[
{
label: "Automatically Fix JSON",
keepOpenOnSelect: true,
onSelect: handleToggleAutoFix,
rightSlot: <IconTooltip content={stripMessage} />,
leftSlot: (
<Icon icon={autoFix ? "check_square_checked" : "check_square_unchecked"} />
),
},
] satisfies DropdownItem[]
}
>
<IconButton size="sm" variant="border" icon="settings" title="JSON Settings" />
</Dropdown>
</div>,
],
[handleDropdownOpen, handleToggleAutoFix, autoFix, showBanner],
);
return (
<Editor
forceUpdateKey={forceUpdateKey}
autocompleteFunctions
autocompleteVariables
placeholder="..."
heightMode={heightMode}
defaultValue={`${request.body?.text ?? ""}`}
language="json"
onChange={handleChange}
stateKey={`json.${request.id}`}
actions={actions}
lintExtension={lintExtension}
/>
);
}
@@ -1,8 +1,8 @@
import { SegmentedControl } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useRef, useState } from "react";
import type { EditorProps } from "./core/Editor/Editor";
import { Editor } from "./core/Editor/LazyEditor";
import { SegmentedControl } from "./core/SegmentedControl";
import { Markdown } from "./Markdown";
type ViewMode = "edit" | "preview";
@@ -0,0 +1,105 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { lazy, Suspense } from "react";
import { useHttpRequestBody } from "../hooks/useHttpRequestBody";
import { getMimeTypeFromContentType } from "../lib/contentType";
import {
LoadingIcon,
languageFromContentType,
AudioViewer,
CsvViewer,
ImageViewer,
SvgViewer,
VideoViewer,
WebPageViewer,
} from "@yaakapp-internal/ui";
import { EmptyStateText } from "./EmptyStateText";
import { MultipartViewer } from "./responseViewers/MultipartViewer";
import { TextViewer } from "./responseViewers/TextViewer";
const PdfViewer = lazy(() =>
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
);
interface Props {
response: HttpResponse;
}
export function RequestBodyViewer({ response }: Props) {
return <RequestBodyViewerInner key={response.id} response={response} />;
}
function RequestBodyViewerInner({ response }: Props) {
const { data, isLoading, error } = useHttpRequestBody(response);
if (isLoading) {
return (
<EmptyStateText>
<LoadingIcon />
</EmptyStateText>
);
}
if (error) {
return <EmptyStateText>Error loading request body: {error.message}</EmptyStateText>;
}
if (data?.bodyText == null || data.bodyText.length === 0) {
return <EmptyStateText>No request body</EmptyStateText>;
}
const { bodyText, body } = data;
// Try to detect language from content-type header that was sent
const contentTypeHeader = response.requestHeaders.find(
(h) => h.name.toLowerCase() === "content-type",
);
const contentType = contentTypeHeader?.value ?? null;
const mimeType = contentType ? getMimeTypeFromContentType(contentType).essence : null;
const language = languageFromContentType(contentType, bodyText);
// Route to appropriate viewer based on content type
if (mimeType?.match(/^multipart/i)) {
const boundary = contentType?.split("boundary=")[1] ?? "unknown";
// Create a copy because parseMultipart may detach the buffer
const bodyCopy = new Uint8Array(body);
return (
<MultipartViewer data={bodyCopy} boundary={boundary} idPrefix={`request.${response.id}`} />
);
}
if (mimeType?.match(/^image\/svg/i)) {
return <SvgViewer text={bodyText} />;
}
if (mimeType?.match(/^image/i)) {
return <ImageViewer data={body.buffer} />;
}
if (mimeType?.match(/^audio/i)) {
return <AudioViewer data={body} />;
}
if (mimeType?.match(/^video/i)) {
return <VideoViewer data={body} />;
}
if (mimeType?.match(/csv|tab-separated/i)) {
return <CsvViewer text={bodyText} />;
}
if (mimeType?.match(/^text\/html/i)) {
return <WebPageViewer html={bodyText} />;
}
if (mimeType?.match(/pdf/i)) {
return (
<Suspense fallback={<LoadingIcon />}>
<PdfViewer data={body} />
</Suspense>
);
}
return (
<TextViewer text={bodyText} language={language} stateKey={`request.body.${response.id}`} />
);
}
@@ -1,20 +1,38 @@
import type { HttpRequest } from "@yaakapp-internal/models";
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import { RequestMethodDropdown as BaseRequestMethodDropdown } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { memo, useCallback } from "react";
import { patchModel } from "@yaakapp-internal/models";
import classNames from "classnames";
import { memo, useCallback, useMemo } from "react";
import { showPrompt } from "../lib/prompt";
import { Button } from "./core/Button";
import type { DropdownItem } from "./core/Dropdown";
import { HttpMethodTag, HttpMethodTagRaw } from "./core/HttpMethodTag";
import { Icon } from "@yaakapp-internal/ui";
import type { RadioDropdownItem } from "./core/RadioDropdown";
import { RadioDropdown } from "./core/RadioDropdown";
type Props = {
request: HttpRequest;
className?: string;
};
/** The shared method menu, writing to the request and coloured by the user's setting */
const radioItems: RadioDropdownItem<string>[] = [
"GET",
"PUT",
"POST",
"PATCH",
"DELETE",
"OPTIONS",
"QUERY",
"HEAD",
].map((m) => ({
value: m,
label: <HttpMethodTagRaw method={m} />,
}));
export const RequestMethodDropdown = memo(function RequestMethodDropdown({
request,
className,
}: Props) {
const settings = useAtomValue(settingsAtom);
const handleChange = useCallback(
async (method: string) => {
await patchModel(request, { method });
@@ -22,12 +40,39 @@ export const RequestMethodDropdown = memo(function RequestMethodDropdown({
[request],
);
const itemsAfter = useMemo<DropdownItem[]>(
() => [
{
key: "custom",
label: "CUSTOM",
leftSlot: <Icon icon="sparkles" />,
onSelect: async () => {
const newMethod = await showPrompt({
id: "custom-method",
label: "Http Method",
title: "Custom Method",
confirmText: "Save",
description: "Enter a custom method name",
placeholder: "CUSTOM",
});
if (newMethod == null) return;
await handleChange(newMethod);
},
},
],
[handleChange],
);
return (
<BaseRequestMethodDropdown
method={request.method}
colored={settings.coloredMethods}
<RadioDropdown
value={request.method}
items={radioItems}
itemsAfter={itemsAfter}
onChange={handleChange}
className={className}
/>
>
<Button size="xs" className={classNames(className, "text-text-subtle hover:text")}>
<HttpMethodTag request={request} noAlias />
</Button>
</RadioDropdown>
);
});
@@ -1,79 +0,0 @@
import type { HttpResponse, RequestVersionComparison } from "@yaakapp-internal/models";
import { Icon } from "@yaakapp-internal/ui";
import { stringify } from "yaml";
import { useRequestVersion } from "../hooks/useRequestVersion";
import { showDialog } from "../lib/dialog";
import { restoreRequestVersion } from "../lib/restoreRequestVersion";
import { Button } from "./core/Button";
import { DiffViewer } from "./core/Editor/DiffViewer";
import { Dropdown } from "./core/Dropdown";
interface Props {
response: Pick<HttpResponse, "requestId" | "versionId">;
}
/**
* Offers the request a response was sent from, when that is no longer the
* request you have.
*
* Hidden while the two agree, which is the overwhelmingly common case and the
* one where there is nothing to say. Responses recorded before versioning
* existed have no version and stay quiet forever.
*/
export function RequestVersionDropdown({ response }: Props) {
const comparison = useRequestVersion(response.versionId, response.requestId);
if (comparison == null || !comparison.differs) {
return null;
}
return (
<Dropdown
items={[
{
label: "View Diff",
leftSlot: <Icon icon="git_branch" />,
onSelect: () => showRequestVersionDiff(comparison),
},
{
label: "Restore This Version",
leftSlot: <Icon icon="history" />,
onSelect: () => restoreRequestVersion(comparison.version),
},
]}
>
<Button
size="2xs"
variant="border"
color="notice"
className="font-sans"
title="This request has changed since this response was sent"
forDropdown
>
Request Changed
</Button>
</Dropdown>
);
}
function showRequestVersionDiff(comparison: RequestVersionComparison) {
showDialog({
id: "request-version-diff",
title: "Request Changes Since This Response",
size: "full",
noPadding: true,
render: () => (
<div className="h-full flex flex-col px-4 pb-4">
<DiffViewer
original={toYaml(comparison.version.document)}
modified={toYaml(comparison.currentDocument)}
className="flex-1 min-h-0"
/>
</div>
),
});
}
/** Matches how the Git dialog renders a model for diffing. */
function toYaml(document: unknown): string {
return stringify(document, { indent: 2, lineWidth: 0 });
}
@@ -1,17 +1,14 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import classNames from "classnames";
import { useMemo } from "react";
import type { JSX } from "react/jsx-runtime";
import { CountBadge } from "../CountBadge";
import { DetailsBanner } from "../DetailsBanner";
import { useEditorHost } from "../EditorHost";
import { KeyValueRows } from "../KeyValueRow";
import { KeyValueRow } from "./ResponseHeaders";
import type { HttpResponseView } from "./types";
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
import { DetailsBanner } from "./core/DetailsBanner";
import { KeyValueRow } from "./core/KeyValueRow";
import { CountBadge, KeyValueRows } from "@yaakapp-internal/ui";
interface Props {
response: HttpResponseView;
/** Only the cookies sent or only those received, for a pane that shows one side */
side?: "sent" | "received";
response: HttpResponse;
}
interface ParsedCookie {
@@ -83,41 +80,45 @@ function parseSetCookieHeader(setCookieHeader: string): ParsedCookie {
return cookie;
}
/** The cookies a response's headers sent and received, the latest value of each name winning */
export function parseCookieHeaders({ sent, received }: HttpResponseView["cookieHeaders"]) {
const sentMap = new Map<string, { name: string; value: string }>();
const receivedMap = new Map<string, ParsedCookie>();
export function ResponseCookies({ response }: Props) {
const { data: events } = useHttpResponseEvents(response);
for (const header of sent) {
for (const cookie of parseCookieHeader(header)) {
sentMap.set(cookie.name, cookie);
const { sentCookies, receivedCookies } = useMemo(() => {
if (!events) return { sentCookies: [], receivedCookies: [] };
// Use Maps to deduplicate by cookie name (latest value wins)
const sentMap = new Map<string, { name: string; value: string }>();
const receivedMap = new Map<string, ParsedCookie>();
for (const event of events) {
const e = event.event;
// Cookie headers sent (header_up with name=cookie)
if (e.type === "header_up" && e.name.toLowerCase() === "cookie") {
const cookies = parseCookieHeader(e.value);
for (const cookie of cookies) {
sentMap.set(cookie.name, cookie);
}
}
// Set-Cookie headers received (header_down with name=set-cookie)
if (e.type === "header_down" && e.name.toLowerCase() === "set-cookie") {
const cookie = parseSetCookieHeader(e.value);
receivedMap.set(cookie.name, cookie);
}
}
}
for (const header of received) {
const cookie = parseSetCookieHeader(header);
receivedMap.set(cookie.name, cookie);
}
return {
sentCookies: Array.from(sentMap.values()),
receivedCookies: Array.from(receivedMap.values()),
};
}
export function ResponseCookies({ response, side }: Props) {
const { useDetailsBannerStoredValue } = useEditorHost();
const { sentCookies, receivedCookies } = useMemo(
() => parseCookieHeaders(response.cookieHeaders),
[response.cookieHeaders],
);
return {
sentCookies: Array.from(sentMap.values()),
receivedCookies: Array.from(receivedMap.values()),
};
}, [events]);
return (
<div className="overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5">
{side !== "received" && (
<DetailsBanner
defaultOpen
useStoredValue={useDetailsBannerStoredValue}
storageKey={`${response.ownerId}.sent_cookies`}
storageKey={`${response.requestId}.sent_cookies`}
summary={
<h2 className="flex items-center">
Sent Cookies <CountBadge showZero count={sentCookies.length} />
@@ -137,13 +138,10 @@ export function ResponseCookies({ response, side }: Props) {
</KeyValueRows>
)}
</DetailsBanner>
)}
{side !== "sent" && (
<DetailsBanner
defaultOpen
useStoredValue={useDetailsBannerStoredValue}
storageKey={`${response.ownerId}.received_cookies`}
storageKey={`${response.requestId}.received_cookies`}
summary={
<h2 className="flex items-center">
Received Cookies <CountBadge showZero count={receivedCookies.length} />
@@ -218,7 +216,6 @@ export function ResponseCookies({ response, side }: Props) {
</div>
)}
</DetailsBanner>
)}
</div>
);
}
@@ -0,0 +1,63 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { platform } from "@yaakapp-internal/platform";
import { HeadersTable, KeyValueRows } from "@yaakapp-internal/ui";
import { format, formatDistanceToNowStrict } from "date-fns";
import { DetailsBanner, useDetailsBannerStoredValue } from "./core/DetailsBanner";
import { IconButton } from "./core/IconButton";
import { KeyValueRow } from "./core/KeyValueRow";
interface Props {
response: HttpResponse;
}
export function ResponseHeaders({ response }: Props) {
return (
<div className="overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5">
<DetailsBanner storageKey={`${response.requestId}.general`} summary={<h2>Info</h2>}>
<KeyValueRows>
<KeyValueRow labelColor="secondary" label="Sent">
<time
dateTime={new Date(`${response.createdAt}Z`).toISOString()}
title={formatDistanceToNowStrict(`${response.createdAt}Z`, { addSuffix: true })}
>
{format(`${response.createdAt}Z`, "MMM d, yyyy, h:mm:ss a O")}
</time>
</KeyValueRow>
<KeyValueRow labelColor="secondary" label="Request URL">
<div className="flex items-center gap-1">
<span className="select-text cursor-text">{response.url}</span>
<IconButton
iconSize="sm"
className="inline-block w-auto h-auto! opacity-50 hover:opacity-100"
icon="external_link"
onClick={() => platform.openUrl(response.url)}
title="Open in browser"
/>
</div>
</KeyValueRow>
<KeyValueRow labelColor="secondary" label="Remote Address">
{response.remoteAddr ?? <span className="text-text-subtlest">--</span>}
</KeyValueRow>
<KeyValueRow labelColor="secondary" label="Version">
{response.version ?? <span className="text-text-subtlest">--</span>}
</KeyValueRow>
</KeyValueRows>
</DetailsBanner>
<HeadersTable
title="Request Headers"
headers={response.requestHeaders}
labelColor="primary"
storageKey={`${response.requestId}.request_headers`}
useStoredValue={useDetailsBannerStoredValue}
/>
<HeadersTable
defaultOpen
title="Response Headers"
headers={response.headers}
labelColor="info"
storageKey={`${response.requestId}.response_headers`}
useStoredValue={useDetailsBannerStoredValue}
/>
</div>
);
}
+106 -64
View File
@@ -1,19 +1,13 @@
import { platform } from "@yaakapp-internal/platform";
import { useLicense } from "@yaakapp-internal/license";
import { pluginsAtom, settingsAtom } from "@yaakapp-internal/models";
import {
CountBadge,
SETTINGS_TAB_GENERAL,
SETTINGS_TAB_INTERFACE,
SETTINGS_TAB_LICENSE,
SETTINGS_TAB_THEME,
SettingsDialog,
type SettingsTabDef,
} from "@yaakapp-internal/ui";
import { HeaderSize, HStack, Icon, CountBadge } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import { useKeyPressEvent } from "react-use";
import { appInfo } from "../../lib/appInfo";
import { capitalize } from "../../lib/capitalize";
import { TabContent, type TabItem, Tabs } from "../core/Tabs/Tabs";
import { SettingsAgents } from "./SettingsAgents";
import { SettingsCertificates } from "./SettingsCertificates";
import { SettingsGeneral } from "./SettingsGeneral";
@@ -30,15 +24,15 @@ interface Props {
hide?: () => void;
}
const TAB_GENERAL = SETTINGS_TAB_GENERAL;
const TAB_INTERFACE = SETTINGS_TAB_INTERFACE;
const TAB_THEME = SETTINGS_TAB_THEME;
const TAB_GENERAL = "general";
const TAB_INTERFACE = "interface";
const TAB_THEME = "theme";
const TAB_SHORTCUTS = "shortcuts";
const TAB_PROXY = "proxy";
const TAB_CERTIFICATES = "certificates";
const TAB_PLUGINS = "plugins";
const TAB_AGENTS = "agents";
const TAB_LICENSE = SETTINGS_TAB_LICENSE;
const TAB_LICENSE = "license";
const tabs = [
TAB_GENERAL,
TAB_THEME,
@@ -66,57 +60,105 @@ export default function Settings({ tab, hide }: Props) {
if (hide == null) await platform.window.close();
});
const tabDefs: Record<SettingsTab, Omit<SettingsTabDef, "value" | "label">> = {
[TAB_GENERAL]: { icon: "settings", content: <SettingsGeneral /> },
[TAB_THEME]: { icon: "palette", content: <SettingsTheme /> },
[TAB_INTERFACE]: { icon: "columns_2", content: <SettingsInterface /> },
[TAB_SHORTCUTS]: { icon: "keyboard", content: <SettingsHotkeys /> },
[TAB_PLUGINS]: {
icon: "puzzle",
rightSlot: <CountBadge count={plugins.filter((p) => p.source !== "bundled").length} />,
content: <SettingsPlugins defaultSubtab={mainTab === TAB_PLUGINS ? subtab : undefined} />,
contentClassName: "h-full grid grid-rows-1",
},
[TAB_AGENTS]: {
icon: "sparkles",
hidden: !platform.capabilities.agents,
content: <SettingsAgents />,
},
[TAB_CERTIFICATES]: {
icon: "shield_check",
rightSlot: <CountBadge count={settings.clientCertificates.length} />,
content: <SettingsCertificates />,
},
[TAB_PROXY]: {
icon: "wifi",
rightSlot: settings.proxy?.type === "enabled" ? <CountBadge count /> : null,
content: <SettingsProxy />,
},
[TAB_LICENSE]: {
icon: "key_round",
hidden: !appInfo.featureLicense,
rightSlot:
licenseCheck.check.data?.status === "personal_use" ? (
<CountBadge count color="notice" />
) : null,
content: <SettingsLicense />,
},
};
return (
<SettingsDialog
defaultTab={mainTab}
titlebar={
hide
? null
: {
osType: platform.osType(),
hideWindowControls: settings.hideWindowControls,
useNativeTitlebar: settings.useNativeTitlebar,
interfaceScale: settings.interfaceScale,
}
}
tabs={tabs.map((value) => ({ value, label: capitalize(value), ...tabDefs[value] }))}
/>
<div className={classNames("grid grid-rows-[auto_minmax(0,1fr)] h-full")}>
{hide ? (
<span />
) : (
<HeaderSize
data-tauri-drag-region
ignoreControlsSpacing
onlyXWindowControl
size="md"
className="x-theme-appHeader bg-surface text-text-subtle flex items-center justify-center border-b border-border-subtle text-sm font-semibold"
osType={platform.osType()}
hideWindowControls={settings.hideWindowControls}
useNativeTitlebar={settings.useNativeTitlebar}
interfaceScale={settings.interfaceScale}
>
<HStack
space={2}
justifyContent="center"
className="w-full h-full grid grid-cols-[1fr_auto] pointer-events-none"
>
<div className={classNames(platform.osType() === "macos" ? "text-center" : "pl-2")}>Settings</div>
</HStack>
</HeaderSize>
)}
<Tabs
layout="horizontal"
defaultValue={mainTab}
addBorders
tabListClassName="min-w-40 bg-surface x-theme-sidebar border-r border-border pl-3"
label="Settings"
tabs={tabs.map(
(value): TabItem => ({
value,
label: capitalize(value),
hidden:
(!appInfo.featureLicense && value === TAB_LICENSE) ||
(!platform.capabilities.agents && value === TAB_AGENTS),
leftSlot:
value === TAB_GENERAL ? (
<Icon icon="settings" className="text-secondary" />
) : value === TAB_THEME ? (
<Icon icon="palette" className="text-secondary" />
) : value === TAB_INTERFACE ? (
<Icon icon="columns_2" className="text-secondary" />
) : value === TAB_SHORTCUTS ? (
<Icon icon="keyboard" className="text-secondary" />
) : value === TAB_CERTIFICATES ? (
<Icon icon="shield_check" className="text-secondary" />
) : value === TAB_PROXY ? (
<Icon icon="wifi" className="text-secondary" />
) : value === TAB_PLUGINS ? (
<Icon icon="puzzle" className="text-secondary" />
) : value === TAB_AGENTS ? (
<Icon icon="sparkles" className="text-secondary" />
) : value === TAB_LICENSE ? (
<Icon icon="key_round" className="text-secondary" />
) : null,
rightSlot:
value === TAB_CERTIFICATES ? (
<CountBadge count={settings.clientCertificates.length} />
) : value === TAB_PLUGINS ? (
<CountBadge count={plugins.filter((p) => p.source !== "bundled").length} />
) : value === TAB_PROXY && settings.proxy?.type === "enabled" ? (
<CountBadge count />
) : value === TAB_LICENSE && licenseCheck.check.data?.status === "personal_use" ? (
<CountBadge count color="notice" />
) : null,
}),
)}
>
<TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-6 py-4!">
<SettingsGeneral />
</TabContent>
<TabContent value={TAB_INTERFACE} className="overflow-y-auto h-full px-6 py-4!">
<SettingsInterface />
</TabContent>
<TabContent value={TAB_THEME} className="overflow-y-auto h-full px-6 py-4!">
<SettingsTheme />
</TabContent>
<TabContent value={TAB_SHORTCUTS} className="overflow-y-auto h-full px-6 py-4!">
<SettingsHotkeys />
</TabContent>
<TabContent value={TAB_PLUGINS} className="h-full grid grid-rows-1">
<SettingsPlugins defaultSubtab={mainTab === TAB_PLUGINS ? subtab : undefined} />
</TabContent>
<TabContent value={TAB_AGENTS} className="overflow-y-auto h-full px-6 py-4!">
<SettingsAgents />
</TabContent>
<TabContent value={TAB_PROXY} className="overflow-y-auto h-full px-6 py-4!">
<SettingsProxy />
</TabContent>
<TabContent value={TAB_CERTIFICATES} className="overflow-y-auto h-full px-6 py-4!">
<SettingsCertificates />
</TabContent>
<TabContent value={TAB_LICENSE} className="overflow-y-auto h-full px-6 py-4!">
<SettingsLicense />
</TabContent>
</Tabs>
</div>
);
}
@@ -1,7 +1,7 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { platform } from "@yaakapp-internal/platform";
import type { AgentSetupStatus, AppMetaData } from "@yaakapp-internal/rpc-schema";
import { Banner, Heading, Icon, InlineCode, SegmentedControl, VStack } from "@yaakapp-internal/ui";
import { Banner, Heading, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import { useEffect } from "react";
import { useKeyValue } from "../../hooks/useKeyValue";
import { usePluginInfos } from "../../hooks/usePluginInfo";
@@ -14,6 +14,7 @@ import {
import { appInfo } from "../../lib/appInfo";
import { rpc } from "../../lib/rpc";
import { Link } from "../core/Link";
import { SegmentedControl } from "../core/SegmentedControl";
import {
SettingCommand,
SettingSnippet,
@@ -1,17 +1,32 @@
import { settingsAtom } from "@yaakapp-internal/models";
import { SettingsGeneral as SharedSettingsGeneral } from "@yaakapp-internal/ui";
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import { Heading, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useSettingsView } from "../../hooks/useSettingsView";
import { useCheckForUpdates } from "../../hooks/useCheckForUpdates";
import { appInfo } from "../../lib/appInfo";
import { revealInFinderText } from "../../lib/reveal";
import { CargoFeature } from "../CargoFeature";
import { CommercialUseBanner } from "../CommercialUseBanner";
import { DismissibleBanner } from "../core/DismissibleBanner";
import { IconButton } from "../core/IconButton";
import { platform } from "@yaakapp-internal/platform";
import {
ModelSettingRowBoolean,
ModelSettingSelectControl,
SettingValue,
SettingRow,
SettingRowBoolean,
SettingRowSelect,
SettingsList,
SettingsSection,
} from "../core/SettingRow";
const WORKSPACE_SETTINGS_MOVED_AT = "2026-06-30";
export function SettingsGeneral() {
const settings = useAtomValue(settingsAtom);
const bound = useSettingsView();
const checkForUpdates = useCheckForUpdates();
if (settings == null || bound == null) {
if (settings == null) {
return null;
}
@@ -19,24 +34,147 @@ export function SettingsGeneral() {
settings.createdAt.slice(0, 10) < WORKSPACE_SETTINGS_MOVED_AT;
return (
<SharedSettingsGeneral
view={bound.view}
callbacks={bound.callbacks}
banner={<CommercialUseBanner source="settings-general" title="Using Yaak for work?" />}
extraSections={
showWorkspaceSettingsMovedBanner && (
<VStack space={1.5} className="mb-4">
<div>
<Heading>General</Heading>
<p className="text-text-subtle">
Configure general settings for update behavior and more.
</p>
</div>
<div className="mt-3 mb-5">
<CommercialUseBanner source="settings-general" title="Using Yaak for work?" />
</div>
<SettingsList className="space-y-8">
<CargoFeature feature="updater">
<SettingsSection title="Updates">
<SettingRow
title="Update Channel"
description="Choose whether Yaak should use stable releases or beta releases."
>
<div className="grid grid-cols-[12rem_auto] gap-1">
<ModelSettingSelectControl
model={settings}
modelKey="updateChannel"
label="Update Channel"
selectClassName="w-full!"
options={[
{ label: "Stable", value: "stable" },
{ label: "Beta", value: "beta" },
]}
/>
<IconButton
variant="border"
size="sm"
title="Check for updates"
icon="refresh"
spin={checkForUpdates.isPending}
onClick={() => checkForUpdates.mutateAsync()}
/>
</div>
</SettingRow>
<SettingRowSelect
title="Update Behavior"
description="Choose whether updates are installed automatically or manually."
name="autoupdate"
value={settings.autoupdate ? "auto" : "manual"}
onChange={(v) =>
patchModel(settings, { autoupdate: v === "auto" })
}
options={[
{ label: "Automatic", value: "auto" },
{ label: "Manual", value: "manual" },
]}
/>
<ModelSettingRowBoolean
model={settings}
modelKey="autoDownloadUpdates"
title="Automatically download updates"
description="Download Yaak updates in the background so they are ready to install."
disabled={!settings.autoupdate}
/>
<ModelSettingRowBoolean
model={settings}
modelKey="checkNotifications"
title="Check for notifications"
description="Periodically ping Yaak servers to check for relevant notifications."
/>
<SettingRowBoolean
title="Send anonymous usage statistics"
description="Yaak is local-first and does not collect analytics or usage data."
disabled
checked={false}
onChange={() => {}}
/>
</SettingsSection>
</CargoFeature>
<CargoFeature feature="license">
<SettingsSection title="Feedback">
<SettingRowBoolean
title="Prompt for feedback"
description="Show rare one-time prompts asking how new features are working."
checked={settings.promptFeedback}
onChange={(promptFeedback) => patchModel(settings, { promptFeedback })}
/>
</SettingsSection>
</CargoFeature>
{showWorkspaceSettingsMovedBanner && (
<DismissibleBanner
id="workspace-settings-moved-2026-06-30"
color="info"
className="w-full p-4 max-w-xl mr-auto"
>
<p>
Workspace specific settings have moved to <b>Workspace Settings</b>, accessible from
the workspace switcher menu.
Workspace specific settings have moved to{" "}
<b>Workspace Settings</b>, accessible from the workspace switcher
menu.
</p>
</DismissibleBanner>
)
}
/>
)}
<SettingsSection title="App Info">
<SettingRow title="Version" description="Current Yaak version.">
<SettingValue value={appInfo.version} />
</SettingRow>
<SettingRow
title="Data Directory"
description="Where Yaak stores application data."
controlClassName="min-w-0 max-w-[min(42rem,55vw)] gap-2"
>
<SettingValue
value={appInfo.appDataDir}
actions={[
{
title: revealInFinderText,
icon: "folder_open",
onClick: () => platform.revealItemInDir(appInfo.appDataDir),
},
]}
/>
</SettingRow>
<SettingRow
title="Logs Directory"
description="Where Yaak writes application logs."
controlClassName="min-w-0 max-w-[min(42rem,55vw)] gap-2"
>
<SettingValue
value={appInfo.appLogDir}
actions={[
{
title: revealInFinderText,
icon: "folder_open",
onClick: () => platform.revealItemInDir(appInfo.appLogDir),
},
]}
/>
</SettingRow>
</SettingsSection>
</SettingsList>
</VStack>
);
}
@@ -1,30 +1,59 @@
import { platform } from "@yaakapp-internal/platform";
import { useFonts } from "@yaakapp-internal/fonts";
import { useLicense } from "@yaakapp-internal/license";
import type { Settings } from "@yaakapp-internal/models";
import type { EditorKeymap, Settings } from "@yaakapp-internal/models";
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import { SettingsInterface as SharedSettingsInterface, VStack } from "@yaakapp-internal/ui";
import { clamp, Heading, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useState } from "react";
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
import { useSettingsView } from "../../hooks/useSettingsView";
import { showConfirm } from "../../lib/confirm";
import { pricingUrl } from "../../lib/pricingUrl";
import { rpc } from "../../lib/rpc";
import { CargoFeature } from "../CargoFeature";
import { Button } from "../core/Button";
import { Checkbox } from "../core/Checkbox";
import { Link } from "../core/Link";
import { SettingRowBoolean, SettingRowSelect, SettingsSection } from "../core/SettingRow";
import {
ModelSettingRowBoolean,
ModelSettingRowSelect,
SettingRow,
SettingRowBoolean,
SettingRowSelect,
SettingSelectControl,
SettingsList,
SettingsSection,
} from "../core/SettingRow";
const NULL_FONT_VALUE = "__NULL_FONT__";
const fontSizeOptions = [
8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30,
].map((n) => ({ label: `${n}`, value: `${n}` }));
const keymaps: { value: EditorKeymap; label: string }[] = [
{ value: "default", label: "Default" },
{ value: "vim", label: "Vim" },
{ value: "vscode", label: "VSCode" },
{ value: "emacs", label: "Emacs" },
];
export function SettingsInterface() {
const workspace = useAtomValue(activeWorkspaceAtom);
const settings = useAtomValue(settingsAtom);
const bound = useSettingsView();
const fonts = useFonts();
if (settings == null || workspace == null || bound == null) {
if (settings == null || workspace == null) {
return null;
}
return (
<SharedSettingsInterface
view={bound.view}
callbacks={bound.callbacks}
leadingSections={
<VStack space={1.5} className="mb-4">
<div className="mb-3">
<Heading>Interface</Heading>
<p className="text-text-subtle">Tweak settings related to the user interface.</p>
</div>
<SettingsList className="space-y-8">
<SettingsSection title="Workspaces">
<SettingRowSelect
title="Open workspace behavior"
@@ -49,13 +78,151 @@ export function SettingsInterface() {
]}
/>
</SettingsSection>
}
trailingSections={
<SettingsSection title="Fonts">
<SettingRow
title="Interface font"
description="Font used for Yaak interface controls."
controlClassName="gap-1"
>
{fonts.data && (
<SettingSelectControl
name="uiFont"
label="Interface font"
selectClassName="w-72!"
value={settings.interfaceFont ?? NULL_FONT_VALUE}
defaultValue={NULL_FONT_VALUE}
options={[
{ label: "System default", value: NULL_FONT_VALUE },
...fonts.data.uiFonts.map((f) => ({ label: f, value: f })),
...fonts.data.editorFonts.map((f) => ({ label: f, value: f })),
]}
onChange={async (v) => {
const interfaceFont = v === NULL_FONT_VALUE ? null : v;
await patchModel(settings, { interfaceFont });
}}
/>
)}
<SettingSelectControl
name="interfaceFontSize"
label="Interface Font Size"
selectClassName="w-20!"
value={`${settings.interfaceFontSize}`}
defaultValue="14"
options={fontSizeOptions}
onChange={(v) => patchModel(settings, { interfaceFontSize: Number.parseInt(v, 10) })}
/>
</SettingRow>
<SettingRow
title="Editor font"
description="Font used in request and response editors."
controlClassName="gap-1"
>
{fonts.data && (
<SettingSelectControl
name="editorFont"
label="Editor font"
selectClassName="w-72!"
value={settings.editorFont ?? NULL_FONT_VALUE}
defaultValue={NULL_FONT_VALUE}
options={[
{ label: "System default", value: NULL_FONT_VALUE },
...fonts.data.editorFonts.map((f) => ({ label: f, value: f })),
]}
onChange={async (v) => {
const editorFont = v === NULL_FONT_VALUE ? null : v;
await patchModel(settings, { editorFont });
}}
/>
)}
<SettingSelectControl
name="editorFontSize"
label="Editor Font Size"
selectClassName="w-20!"
value={`${settings.editorFontSize}`}
defaultValue="12"
options={fontSizeOptions}
onChange={(v) =>
patchModel(settings, {
editorFontSize: clamp(Number.parseInt(v, 10) || 14, 8, 30),
})
}
/>
</SettingRow>
</SettingsSection>
<SettingsSection title="Editor">
<ModelSettingRowSelect
model={settings}
modelKey="editorKeymap"
title="Editor keymap"
description="Keyboard shortcut preset used by text editors."
options={keymaps}
/>
<ModelSettingRowBoolean
model={settings}
modelKey="editorSoftWrap"
title="Wrap editor lines"
description="Wrap long lines in request and response editors."
/>
<ModelSettingRowBoolean
model={settings}
modelKey="coloredMethods"
title="Colorize request methods"
description="Use method-specific colors for HTTP request methods."
/>
</SettingsSection>
<SettingsSection title="Window">
<NativeTitlebarSetting settings={settings} />
{platform.osType() !== "macos" && (
<ModelSettingRowBoolean
model={settings}
modelKey="hideWindowControls"
title="Hide window controls"
description="Hide the close, maximize, and minimize controls on Windows or Linux."
/>
)}
</SettingsSection>
<CargoFeature feature="license">
<LicenseSettings settings={settings} />
</CargoFeature>
}
/>
</SettingsList>
</VStack>
);
}
function NativeTitlebarSetting({ settings }: { settings: Settings }) {
const [nativeTitlebar, setNativeTitlebar] = useState(settings.useNativeTitlebar);
return (
<SettingRow
title="Native title bar"
description="Use the operating system's standard title bar and window controls."
controlClassName="gap-2"
>
<Checkbox
hideLabel
size="md"
checked={nativeTitlebar}
title="Native title bar"
onChange={setNativeTitlebar}
/>
{settings.useNativeTitlebar !== nativeTitlebar && (
<Button
color="primary"
size="xs"
onClick={async () => {
await patchModel(settings, { useNativeTitlebar: nativeTitlebar });
await rpc("cmd_restart");
}}
>
Apply and Restart
</Button>
)}
</SettingRow>
);
}
@@ -1,6 +1,17 @@
import { SettingsLicense as SharedSettingsLicense } from "@yaakapp-internal/ui";
import { useSettingsView } from "../../hooks/useSettingsView";
import { useLicense } from "@yaakapp-internal/license";
import { Banner, HStack, Icon, VStack } from "@yaakapp-internal/ui";
import { differenceInDays } from "date-fns";
import { formatDate } from "date-fns/format";
import { useState } from "react";
import { useToggle } from "../../hooks/useToggle";
import { pricingUrl } from "../../lib/pricingUrl";
import { CargoFeature } from "../CargoFeature";
import { Button } from "../core/Button";
import { Link } from "../core/Link";
import { PlainInput } from "../core/PlainInput";
import { Separator } from "../core/Separator";
import { platform } from "@yaakapp-internal/platform";
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
export function SettingsLicense() {
return (
@@ -11,7 +22,166 @@ export function SettingsLicense() {
}
function SettingsLicenseCmp() {
const bound = useSettingsView();
if (bound == null) return null;
return <SharedSettingsLicense view={bound.view} callbacks={bound.callbacks} />;
const { check, activate, deactivate } = useLicense();
const [key, setKey] = useState<string>("");
const [activateFormVisible, toggleActivateFormVisible] = useToggle(false);
if (check.isPending) {
return null;
}
const renderBanner = () => {
if (!check.data) return null;
switch (check.data.status) {
case "active":
return <Banner color="success">Your license is active 🥳</Banner>;
case "trialing":
return (
<Banner color="info" className="max-w-lg">
<p className="w-full">
<strong>
{pluralizeCount("day", differenceInDays(check.data.data.end, new Date()))}
</strong>{" "}
left to evaluate Yaak for commercial use.
<br />
<span className="opacity-50">Personal use is always free, forever.</span>
<Separator className="my-2" />
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
<Link noUnderline href={pricingUrl(`app.license.learn.${check.data.status}`)}>
Learn More
</Link>
</div>
</p>
</Banner>
);
case "personal_use":
return (
<Banner color="notice" className="max-w-lg">
<p className="w-full">
Your commercial-use trial has ended.
<br />
<span className="opacity-50">
You may continue using Yaak for personal use only.
<br />A license is required for commercial use.
</span>
<Separator className="my-2" />
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
<Link noUnderline href={pricingUrl(`app.license.learn.${check.data.status}`)}>
Learn More
</Link>
</div>
</p>
</Banner>
);
case "inactive":
return (
<Banner color="danger">
Your license is invalid. Please <Link href="https://yaak.app/dashboard">Sign In</Link>{" "}
for more details
</Banner>
);
case "expired":
return (
<Banner color="notice">
Your license expired{" "}
<strong>{formatDate(check.data.data.periodEnd, "MMMM dd, yyyy")}</strong>. Please{" "}
<Link href="https://yaak.app/dashboard">Resubscribe</Link> to continue receiving
updates.
{check.data.data.changesUrl && (
<>
<br />
<Link href={check.data.data.changesUrl}>What's new in latest builds</Link>
</>
)}
</Banner>
);
case "past_due":
return (
<Banner color="danger">
<strong>Your payment method needs attention.</strong>
<br />
To re-activate your license, please{" "}
<Link href={check.data.data.billingUrl}>update your billing info</Link>.
</Banner>
);
case "error":
return (
<Banner color="danger">
License check failed: {check.data.data.message} (Code: {check.data.data.code})
</Banner>
);
}
};
return (
<div className="flex flex-col gap-6 max-w-xl">
{renderBanner()}
{check.error && <Banner color="danger">{check.error}</Banner>}
{activate.error && <Banner color="danger">{activate.error}</Banner>}
{check.data?.status === "active" ? (
<HStack space={2}>
<Button variant="border" color="secondary" size="sm" onClick={() => deactivate.mutate()}>
Deactivate License
</Button>
<Button
color="secondary"
size="sm"
onClick={() => platform.openUrl("https://yaak.app/dashboard?intent=app.license.support")}
rightSlot={<Icon icon="external_link" />}
>
Direct Support
</Button>
</HStack>
) : (
<HStack space={2}>
<Button variant="border" color="secondary" size="sm" onClick={toggleActivateFormVisible}>
Activate License
</Button>
<Button
size="sm"
color="primary"
rightSlot={<Icon icon="external_link" />}
onClick={() =>
platform.openUrl(pricingUrl(`app.license.purchase.${check.data?.status ?? "unknown"}`))
}
>
Purchase License
</Button>
</HStack>
)}
{activateFormVisible && (
<VStack
as="form"
space={3}
className="max-w-sm"
onSubmit={async (e) => {
e.preventDefault();
await activate.mutateAsync({ licenseKey: key });
toggleActivateFormVisible();
}}
>
<PlainInput
autoFocus
label="License Key"
name="key"
onChange={setKey}
placeholder="YK1-XXXXX-XXXXX-XXXXX-XXXXX"
/>
<Button type="submit" color="primary" size="sm" isLoading={activate.isPending}>
Submit
</Button>
</VStack>
)}
</div>
);
}
@@ -1,15 +1,173 @@
import { SettingsTheme as SharedSettingsTheme } from "@yaakapp-internal/ui";
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import { Heading, HStack, Icon, type IconProps, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { lazy, Suspense } from "react";
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
import { useSettingsView } from "../../hooks/useSettingsView";
import { useResolvedAppearance } from "../../hooks/useResolvedAppearance";
import { useResolvedTheme } from "../../hooks/useResolvedTheme";
import type { ButtonProps } from "../core/Button";
import { IconButton } from "../core/IconButton";
import { Link } from "../core/Link";
import type { SelectProps } from "../core/Select";
import {
ModelSettingRowSelect,
SettingRowSelect,
SettingsList,
SettingsSection,
} from "../core/SettingRow";
const Editor = lazy(() => import("../core/Editor/Editor").then((m) => ({ default: m.Editor })));
const buttonColors: ButtonProps["color"][] = [
"primary",
"info",
"success",
"notice",
"warning",
"danger",
"secondary",
"default",
];
const icons: IconProps["icon"][] = [
"info",
"box",
"update",
"alert_triangle",
"arrow_big_right_dash",
"download",
"copy",
"magic_wand",
"settings",
"trash",
"sparkles",
"pencil",
"paste",
"search",
"send_horizontal",
];
export function SettingsTheme() {
const workspace = useAtomValue(activeWorkspaceAtom);
const bound = useSettingsView();
const settings = useAtomValue(settingsAtom);
const appearance = useResolvedAppearance();
const activeTheme = useResolvedTheme();
if (workspace == null || bound == null) {
if (settings == null || workspace == null || activeTheme.data == null) {
return null;
}
return <SharedSettingsTheme view={bound.view} callbacks={bound.callbacks} />;
const lightThemes: SelectProps<string>["options"] = activeTheme.data.themes
.filter((theme) => !theme.dark)
.map((theme) => ({
label: theme.label,
value: theme.id,
}));
const darkThemes: SelectProps<string>["options"] = activeTheme.data.themes
.filter((theme) => theme.dark)
.map((theme) => ({
label: theme.label,
value: theme.id,
}));
return (
<VStack space={1.5} className="mb-4">
<div className="mb-3">
<Heading>Theme</Heading>
<p className="text-text-subtle">
Make Yaak your own by selecting a theme, or{" "}
<Link href="https://yaak.app/docs/plugin-development/plugins-quick-start">
Create Your Own
</Link>
</p>
</div>
<SettingsList className="space-y-8">
<SettingsSection title="Theme">
<ModelSettingRowSelect
model={settings}
modelKey="appearance"
title="Appearance"
description="Choose whether Yaak follows your system appearance or uses a fixed mode."
options={[
{ label: "Automatic", value: "system" },
{ label: "Light", value: "light" },
{ label: "Dark", value: "dark" },
]}
/>
{(settings.appearance === "system" || settings.appearance === "light") && (
<SettingRowSelect
name="lightTheme"
title="Light theme"
description="Theme used when Yaak is in light mode."
value={activeTheme.data.light.id}
options={lightThemes}
onChange={(themeLight) => patchModel(settings, { themeLight })}
/>
)}
{(settings.appearance === "system" || settings.appearance === "dark") && (
<SettingRowSelect
name="darkTheme"
title="Dark theme"
description="Theme used when Yaak is in dark mode."
value={activeTheme.data.dark.id}
options={darkThemes}
onChange={(themeDark) => patchModel(settings, { themeDark })}
/>
)}
</SettingsSection>
<SettingsSection title="Preview">
<VStack
space={3}
className="mt-4 w-full bg-surface p-3 border border-dashed border-border-subtle rounded-sm overflow-x-auto"
>
<HStack className="text" space={1.5}>
<Icon icon={appearance === "dark" ? "moon" : "sun"} />
<strong>{activeTheme.data.active.label}</strong>
<em>(preview)</em>
</HStack>
<HStack space={1.5} className="w-full">
{buttonColors.map((c, i) => (
<IconButton
key={c}
color={c}
size="2xs"
iconSize="xs"
icon={icons[i % icons.length] ?? "info"}
iconClassName="text"
title={`${c}`}
/>
))}
{buttonColors.map((c, i) => (
<IconButton
key={c}
color={c}
variant="border"
size="2xs"
iconSize="xs"
icon={icons[i % icons.length] ?? "info"}
iconClassName="text"
title={`${c}`}
/>
))}
</HStack>
<Suspense>
<Editor
defaultValue={[
"let foo = { // Demo code editor",
' foo: ("bar" || "baz" ?? \'qux\'),',
" baz: [1, 10.2, null, false, true],",
"};",
].join("\n")}
heightMode="auto"
language="javascript"
stateKey={null}
/>
</Suspense>
</VStack>
</SettingsSection>
</SettingsList>
</VStack>
);
}
+39 -75
View File
@@ -63,9 +63,11 @@ import { isSidebarFocused } from "../lib/scopes";
import { navigateToRequestOrFolderOrWorkspace } from "../lib/setWorkspaceSearchParams";
import type { ContextMenuProps, DropdownItem } from "./core/Dropdown";
import { ContextMenu, Dropdown } from "./core/Dropdown";
import type { Node as QueryNode, Schema } from "@yaakapp-internal/query";
import { formatField, parseQuery, QueryMatcher } from "@yaakapp-internal/query";
import { queryFilter } from "@yaakapp-internal/ui/editor";
import type { FieldDef } from "./core/Editor/filter/extension";
import { filter } from "./core/Editor/filter/extension";
import type { Ast } from "./core/Editor/filter/query";
import { evaluate, parseQuery } from "./core/Editor/filter/query";
import { formatFieldFilter } from "./core/Editor/filter/format";
import { HttpMethodTag } from "./core/HttpMethodTag";
import {
Icon,
@@ -110,7 +112,7 @@ function Sidebar({ className }: { className?: string }) {
const activeWorkspaceId = useAtomValue(activeWorkspaceAtom)?.id;
const treeId = `tree.${activeWorkspaceId ?? "unknown"}`;
const filterText = useAtomValue(sidebarFilterAtom);
const [tree, fieldValues, emptyFilterSuggestions] = useAtomValue(sidebarTreeAtom) ?? [];
const [tree, allFields, emptyFilterSuggestions] = useAtomValue(sidebarTreeAtom) ?? [];
const wrapperRef = useRef<HTMLElement>(null);
const treeRef = useRef<TreeHandle>(null);
@@ -551,18 +553,18 @@ function Sidebar({ className }: { className?: string }) {
const filterCompartmentMountExtRef = useRef<Extension | null>(null);
if (filterCompartmentMountExtRef.current == null) {
filterCompartmentMountExtRef.current = filterLanguageCompartmentRef.current.of(
queryFilter({ schema: sidebarFilterSchema, values: fieldValues }),
filter({ fields: allFields ?? [] }),
);
}
useEffect(() => {
const view = filterRef.current;
if (!view) return;
const ext = queryFilter({ schema: sidebarFilterSchema, values: fieldValues });
const ext = filter({ fields: allFields ?? [] });
view.dispatch({
effects: filterLanguageCompartmentRef.current.reconfigure(ext),
});
}, [fieldValues]);
}, [allFields]);
if (tree == null || hidden) {
return null;
@@ -850,44 +852,16 @@ function setSidebarFilterText(text: string) {
jotaiStore.set(sidebarFilterAtom, { text, key: `${Math.random()}` });
}
/**
* The fields `getItemFields` fills in, each with what it is and a term using it, as completion
* shows them. Bare words match `getItemText` by word prefix
*/
const sidebarFilterFields: [name: string, info: string, example: string][] = [
["method", "GET, POST, …", "@method:post"],
["url", "Request URL", "@url:*/users*"],
["name", "Request name", '@name:"Get user"'],
["type", "http, grpc or ws", "@type:grpc"],
["folder", "Parent folder", "@folder:auth"],
["grpc_service", "gRPC service", "@grpc_service:*Greeter"],
["grpc_method", "gRPC method", "@grpc_method:SayHello"],
];
const sidebarFilterColumns = sidebarFilterFields.map(([name]) => name);
const sidebarFilterSchema: Schema = {
fields: sidebarFilterFields.map(([name, info, example]) => ({
name,
kind: { type: "text" },
info,
example,
})),
freeText: "fuzzy",
};
function getSidebarSuggestionValue(ast: QueryNode | null, query: string) {
function getSidebarSuggestionValue(ast: Ast | null) {
if (ast == null) return null;
if (ast.type === "Term" || ast.type === "Phrase") {
const value = ast.text.trim();
const value = ast.value.trim();
return value.length > 0 ? value : null;
}
if (ast.type === "Field") {
const raw =
ast.value.type === "text"
? ast.value.text
: query.slice(ast.valueSpan.start, ast.valueSpan.end);
const value = raw.trim();
const value = ast.value.trim();
return value.length > 0 ? value : null;
}
@@ -909,7 +883,7 @@ const sidebarSuggestionFieldOrder = [
];
const sidebarTreeAtom = atom<
[TreeNode<SidebarModel>, Record<string, string[]>, SidebarFilterSuggestion[]] | null
[TreeNode<SidebarModel>, FieldDef[], SidebarFilterSuggestion[]] | null
>((get) => {
const allModels = get(memoAllPotentialChildrenAtom);
const activeWorkspace = get(activeWorkspaceAtom);
@@ -930,18 +904,15 @@ const sidebarTreeAtom = atom<
return null;
}
const suggestionValue = getSidebarSuggestionValue(parseQuery(filter.text), filter.text);
const matcher = new QueryMatcher(filter.text, sidebarFilterSchema);
// Every request goes into one flat table (its text, then a cell per column), matched in a
// single call after the tree is built
const leaves: TreeNode<SidebarModel>[] = [];
const cells: (string | null)[] = [];
const queryAst = parseQuery(filter.text);
const suggestionValue = getSidebarSuggestionValue(queryAst);
// returns true if this node OR any child matches the filter
const allFields: Record<string, Set<string>> = {};
const suggestionFields = new Set<string>();
const build = (node: TreeNode<SidebarModel>, depth: number) => {
const build = (node: TreeNode<SidebarModel>, depth: number): boolean => {
const childItems = childrenMap[node.item.id] ?? [];
let matchesSelf = true;
const fields = getItemFields(node);
const model = node.item.model;
const isLeafNode = !(model === "folder" || model === "workspace");
@@ -959,12 +930,12 @@ const sidebarTreeAtom = atom<
}
}
if (isLeafNode && !matcher.isEmpty) {
leaves.push(node);
cells.push(getItemText(node.item));
for (const column of sidebarFilterColumns) cells.push(fields[column] ?? null);
if (queryAst != null) {
matchesSelf = isLeafNode && evaluate(queryAst, { text: getItemText(node.item), fields });
}
let matchesChild = false;
// Recurse to children
node.children = !isLeafNode ? [] : undefined;
@@ -982,21 +953,19 @@ const sidebarTreeAtom = atom<
for (const item of childItems) {
const childNode = { item, parent: node, depth };
build(childNode, depth + 1);
const childMatches = build(childNode, depth + 1);
if (childMatches) {
matchesChild = true;
}
node.children.push(childNode);
}
}
};
// Hide a node IFF nothing in its subtree matches; with no query, nothing is hidden
let matched: Set<TreeNode<SidebarModel>> | null = null;
const markHidden = (node: TreeNode<SidebarModel>): boolean => {
let visible = matched == null || matched.has(node);
for (const child of node.children ?? []) {
if (markHidden(child)) visible = true;
}
node.hidden = !visible;
return visible;
// hide node IFF nothing in its subtree matches
const anyMatch = matchesSelf || matchesChild;
node.hidden = !anyMatch;
return anyMatch;
};
const root: TreeNode<SidebarModel> = {
@@ -1006,20 +975,15 @@ const sidebarTreeAtom = atom<
depth: 0,
};
// Build tree and mark visibility in one pass
build(root, 1);
if (!matcher.isEmpty) {
const rows = matcher.matchingRows(sidebarFilterColumns, cells);
matched = new Set(Array.from(rows, (i) => leaves[i]!));
}
matcher.free();
markHidden(root);
// Values offered after `@field:`. URLs and names are left out: nearly every one is unique,
// so a list of them is noise rather than a shortcut.
const fieldValues: Record<string, string[]> = {};
const fields: FieldDef[] = [];
for (const [name, values] of Object.entries(allFields)) {
if (name === "url" || name === "name") continue;
fieldValues[name] = Array.from(values);
fields.push({
name,
values: Array.from(values).filter((v) => v.length < 20),
});
}
const suggestions = Array.from(suggestionFields)
.sort((a, b) => {
@@ -1030,9 +994,9 @@ const sidebarTreeAtom = atom<
})
.map((field) => ({
field,
filterText: formatField(field, suggestionValue ?? ""),
filterText: formatFieldFilter(field, suggestionValue ?? ""),
}));
return [root, fieldValues, suggestions] as const;
return [root, fields, suggestions] as const;
});
const sidebarGitStatusByModelIdAtom = atom<Record<string, GitStatus>>((get) => {
+105 -6
View File
@@ -1,14 +1,113 @@
import { UrlBar as BaseUrlBar, type UrlBarProps } from "@yaakapp-internal/ui";
import { memo } from "react";
import type { HttpRequest } from "@yaakapp-internal/models";
import type { IconProps } from "@yaakapp-internal/ui";
import { HStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { FormEvent, ReactNode } from "react";
import { memo, useCallback, useRef, useState } from "react";
import { useHotKey } from "../hooks/useHotKey";
import { IconButton } from "./core/IconButton";
import type { InputHandle, InputProps } from "./core/Input";
import { Input } from "./core/Input";
type Props = Omit<UrlBarProps, "sendHotkeyAction" | "focusHotkeyAction" | "readOnly"> & {
type Props = Pick<HttpRequest, "url"> & {
className?: string;
placeholder: string;
onSend: () => void;
onUrlChange: (url: string) => void;
onPaste?: (v: string) => void;
onPasteOverwrite?: InputProps["onPasteOverwrite"];
onCancel: () => void;
submitIcon?: IconProps["icon"] | null;
isLoading: boolean;
forceUpdateKey: string;
rightSlot?: ReactNode;
leftSlot?: ReactNode;
autocomplete?: InputProps["autocomplete"];
stateKey: InputProps["stateKey"];
};
/** The shared URL bar, sending and focusing on the client's hotkeys */
export const UrlBar = memo(function UrlBar(props: Props) {
return <BaseUrlBar {...props} sendHotkeyAction="request.send" focusHotkeyAction="url_bar.focus" />;
export const UrlBar = memo(function UrlBar({
forceUpdateKey,
onUrlChange,
url,
placeholder,
className,
onSend,
onCancel,
onPaste,
onPasteOverwrite,
submitIcon = "send_horizontal",
autocomplete,
leftSlot,
rightSlot,
isLoading,
stateKey,
}: Props) {
const inputRef = useRef<InputHandle>(null);
const [isFocused, setIsFocused] = useState<boolean>(false);
const handleInitInputRef = useCallback((h: InputHandle | null) => {
inputRef.current = h;
}, []);
useHotKey("url_bar.focus", () => {
inputRef.current?.selectAll();
});
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (isLoading) onCancel();
else onSend();
};
return (
<form onSubmit={handleSubmit} className={classNames("x-theme-urlBar", className)}>
<Input
setRef={handleInitInputRef}
autocompleteFunctions
autocompleteVariables
stateKey={stateKey}
size="sm"
wrapLines={isFocused}
hideLabel
language="url"
className="px-1.5 py-0.5"
label="Enter URL"
name="url"
autocomplete={autocomplete}
forceUpdateKey={forceUpdateKey}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onPaste={onPaste}
onPasteOverwrite={onPasteOverwrite}
onChange={onUrlChange}
defaultValue={url}
placeholder={placeholder}
leftSlot={leftSlot}
rightSlot={
<HStack space={0.5}>
{rightSlot && <div className="py-0.5 h-full">{rightSlot}</div>}
{submitIcon !== null && (
<div className="py-0.5 h-full">
<IconButton
size="xs"
iconSize="md"
title="Send Request"
type="submit"
className="w-8 mr-0.5 h-full!"
iconColor="secondary"
icon={isLoading ? "x" : submitIcon}
hotkeyAction="request.send"
onMouseDown={(e) => {
// Prevent the button from taking focus
e.preventDefault();
}}
/>
</div>
)}
</HStack>
}
/>
</form>
);
});
@@ -1,11 +1,8 @@
import {
type EditablePair,
type PairEditorHandle,
type PairEditorProps,
UrlParametersEditor as BaseUrlParametersEditor,
} from "@yaakapp-internal/ui";
import { VStack } from "@yaakapp-internal/ui";
import { useCallback, useRef } from "react";
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
import type { EditablePair, PairEditorHandle, PairEditorProps } from "./core/PairEditor";
import { PairOrBulkEditor } from "./core/PairOrBulkEditor";
type Props = {
forceUpdateKey: string;
@@ -14,7 +11,6 @@ type Props = {
onChange: PairEditorProps["onChange"];
};
/** The shared parameters editor, wired to the request editor's focus events */
export function UrlParametersEditor({ pairs, forceUpdateKey, onChange, stateKey }: Props) {
const pairEditorRef = useRef<PairEditorHandle>(null);
const handleInitPairEditorRef = useCallback((ref: PairEditorHandle) => {
@@ -37,12 +33,22 @@ export function UrlParametersEditor({ pairs, forceUpdateKey, onChange, stateKey
);
return (
<BaseUrlParametersEditor
setRef={handleInitPairEditorRef}
forceUpdateKey={forceUpdateKey + urlParametersKey}
onChange={onChange}
pairs={pairs}
stateKey={stateKey}
/>
<VStack className="h-full">
<PairOrBulkEditor
setRef={handleInitPairEditorRef}
allowMultilineValues
forceUpdateKey={forceUpdateKey + urlParametersKey}
nameAutocompleteFunctions
nameAutocompleteVariables
namePlaceholder="param_name"
onChange={onChange}
pairs={pairs}
preferenceName="url_parameters"
stateKey={stateKey}
valueAutocompleteFunctions
valueAutocompleteVariables
valuePlaceholder="Value"
/>
</VStack>
);
}
@@ -1,14 +1,13 @@
import type { WebsocketEvent, WebsocketRequest } from "@yaakapp-internal/models";
import {
HStack,
hexdump,
HexdumpViewer,
Icon,
LoadingIcon,
VStack,
useStateWithDeps,
languageFromContentType,
} from "@yaakapp-internal/ui";
import { hexy } from "hexy";
import { useAtomValue } from "jotai";
import { useMemo, useState } from "react";
import { useFormatText } from "../hooks/useFormatText";
@@ -20,7 +19,6 @@ import {
} from "../hooks/usePinnedWebsocketConnection";
import { Button } from "./core/Button";
import { Editor } from "./core/Editor/LazyEditor";
import { renderEditor } from "./core/Editor/renderEditor";
import { type EventDetailAction, EventDetailHeader, EventViewer } from "./core/EventViewer";
import { EventViewerRow } from "./core/EventViewerRow";
import { HotkeyList } from "./core/HotkeyList";
@@ -124,10 +122,10 @@ function WebsocketEventRow({
messageType === "error"
? "alert_triangle"
: messageType === "close" || messageType === "open"
? "info"
: isServer
? "arrow_big_down_dash"
: "arrow_big_up_dash";
? "info"
: isServer
? "arrow_big_down_dash"
: "arrow_big_up_dash";
const content =
messageType === "close" ? (
@@ -174,7 +172,7 @@ function WebsocketEventDetail({
}) {
const message = useMemo(() => {
if (hexDump) {
return event.message ? hexdump(event.message) : "";
return event.message ? hexy(event.message) : "";
}
return event.message ? new TextDecoder("utf-8").decode(Uint8Array.from(event.message)) : "";
}, [event.message, hexDump]);
@@ -234,8 +232,6 @@ function WebsocketEventDetail({
</VStack>
) : event.message.length === 0 ? (
<EmptyStateText>No Content</EmptyStateText>
) : hexDump ? (
<HexdumpViewer data={event.message} renderEditor={renderEditor} />
) : (
<Editor
language={language}
+15 -3
View File
@@ -1,10 +1,10 @@
import { platform } from "@yaakapp-internal/platform";
import { settingsAtom, workspacesAtom } from "@yaakapp-internal/models";
import { Banner, HeaderSize, HeaderTint, HStack, SidebarLayout } from "@yaakapp-internal/ui";
import { Banner, HeaderSize, HStack, SidebarLayout } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import * as m from "motion/react-m";
import { useState } from "react";
import { useMemo, useState } from "react";
import {
getActiveCookieJar,
useEnsureActiveCookieJar,
@@ -61,6 +61,12 @@ export function Workspace() {
const activeEnvironment = useAtomValue(activeEnvironmentAtom);
const [floating, setFloating] = useState(false);
const environmentBgStyle = useMemo(() => {
if (activeEnvironment?.color == null) return undefined;
const background = `linear-gradient(to right, ${activeEnvironment.color} 15%, transparent 40%)`;
return { background };
}, [activeEnvironment?.color]);
if (workspaces.length === 0) {
return <RedirectToLatestWorkspace />;
}
@@ -75,7 +81,13 @@ export function Workspace() {
useNativeTitlebar={settings.useNativeTitlebar}
interfaceScale={settings.interfaceScale}
>
<HeaderTint color={activeEnvironment?.color ?? null} />
<div className="absolute inset-0 pointer-events-none">
<div style={environmentBgStyle} className="absolute inset-0 opacity-[0.07]" />
<div
style={environmentBgStyle}
className="absolute left-0 right-0 -bottom-px h-px opacity-20"
/>
</div>
<WorkspaceHeader className="pointer-events-none" floatingSidebar={floating} />
</HeaderSize>
);
@@ -1,177 +0,0 @@
// @vitest-environment happy-dom
import type { Workspace, WorkspaceMeta } from "@yaakapp-internal/models";
import { workspaceMetasAtom } from "@yaakapp-internal/models";
import { getDefaultStore, type PrimitiveAtom } from "jotai";
import { act, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, expect, test, vi } from "vite-plus/test";
import { WorkspaceSettingsDialog } from "./WorkspaceSettingsDialog";
const mocks = vi.hoisted(() => ({
rpc: vi.fn<(cmd: string, payload: unknown) => Promise<unknown>>(),
mergeModelsInStore: vi.fn(),
deleteModelWithConfirm: vi.fn<(model: unknown) => Promise<boolean>>(),
workspace: {
id: "wk_test",
model: "workspace",
name: "Broken",
settingDnsOverrides: [],
} as unknown as Workspace,
workspaceMeta: {
id: "wm_test",
model: "workspace_meta",
workspaceId: "wk_test",
settingSyncDir: "/sync/broken",
encryptionKey: null,
} as WorkspaceMeta,
}));
const { workspace, workspaceMeta } = mocks;
vi.mock("@yaakapp-internal/models", async () => {
const { atom } = await import("jotai");
return {
workspacesAtom: atom([mocks.workspace]),
// Starts empty in each test: the workspace failed to load, so the store has no meta for it
workspaceMetasAtom: atom<WorkspaceMeta[]>([]),
mergeModelsInStore: mocks.mergeModelsInStore,
patchModel: vi.fn(),
};
});
vi.mock("@yaakapp-internal/ui", () => ({
Banner: ({ children }: { children: ReactNode }) => <div role="alert">{children}</div>,
CountBadge: () => null,
HStack: ({ children }: { children: ReactNode }) => <div>{children}</div>,
InlineCode: ({ children }: { children: ReactNode }) => <code>{children}</code>,
}));
vi.mock("../hooks/useAuthTab", () => ({ useAuthTab: () => [] }));
vi.mock("../hooks/useHeadersTab", () => ({ useHeadersTab: () => [] }));
vi.mock("../hooks/useInheritedHeaders", () => ({ useInheritedHeaders: () => [] }));
vi.mock("../lib/deleteModelWithConfirm", () => ({
deleteModelWithConfirm: mocks.deleteModelWithConfirm,
}));
vi.mock("../lib/dialog", () => ({ showDialog: vi.fn() }));
vi.mock("../lib/router", () => ({ router: { navigate: vi.fn() } }));
vi.mock("../lib/rpc", () => ({ rpc: mocks.rpc }));
vi.mock("./CopyIconButton", () => ({ CopyIconButton: () => null }));
vi.mock("./core/Button", () => ({
Button: ({ children, onClick }: { children: ReactNode; onClick: () => void }) => (
<button type="button" onClick={onClick}>
{children}
</button>
),
}));
vi.mock("./core/PlainInput", () => ({
PlainInput: ({ label, defaultValue }: { label: string; defaultValue: string }) => (
<input aria-label={label} defaultValue={defaultValue} />
),
}));
vi.mock("./core/SettingRow", () => ({
SettingsList: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SettingsSection: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}));
// Render every tab at once so the test can see all of them
vi.mock("./core/Tabs/Tabs", () => ({
Tabs: ({ children }: { children: ReactNode }) => <div>{children}</div>,
TabContent: ({ children, value }: { children: ReactNode; value: string }) => (
<div data-tab={value}>{children}</div>
),
}));
vi.mock("./DnsOverridesEditor", () => ({ DnsOverridesEditor: () => null }));
vi.mock("./HeadersEditor", () => ({ HeadersEditor: () => null }));
vi.mock("./HttpAuthenticationEditor", () => ({ HttpAuthenticationEditor: () => null }));
vi.mock("./MarkdownEditor", () => ({ MarkdownEditor: () => null }));
vi.mock("./ModelSettingsEditor", () => ({ ModelSettingsEditor: () => null }));
vi.mock("./SyncToFilesystemSetting", () => ({
SyncToFilesystemSetting: ({ value }: { value: { filePath: string | null } }) => (
<div data-testid="sync-setting">{value.filePath}</div>
),
}));
vi.mock("./WorkspaceEncryptionSetting", () => ({
WorkspaceEncryptionSetting: () => <div data-testid="encryption-setting" />,
}));
let root: Root;
let host: HTMLDivElement;
beforeEach(() => {
vi.clearAllMocks();
// Behave like the real store: merged models land in the atom the dialog reads
const store = getDefaultStore();
const metasAtom = workspaceMetasAtom as unknown as PrimitiveAtom<WorkspaceMeta[]>;
store.set(metasAtom, []);
mocks.mergeModelsInStore.mockImplementation((_model: string, models: WorkspaceMeta[]) =>
store.set(metasAtom, (prev) => [...prev, ...models]),
);
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
vi.unstubAllGlobals();
});
async function render() {
await act(async () =>
root.render(<WorkspaceSettingsDialog workspaceId="wk_test" hide={() => {}} />),
);
}
function tab(value: string) {
const el = host.querySelector(`[data-tab="${value}"]`);
if (el == null) throw new Error(`No ${value} tab`);
return el;
}
function deleteButton() {
return Array.from(host.querySelectorAll("button")).find(
(b) => b.textContent === "Delete Workspace",
);
}
test("a workspace with no meta in the store still offers Delete", async () => {
mocks.rpc.mockReturnValue(new Promise(() => {}));
mocks.deleteModelWithConfirm.mockResolvedValue(false);
await render();
expect(host.textContent).not.toContain("not found");
const button = deleteButton();
expect(button).toBeDefined();
await act(async () => button?.click());
expect(mocks.deleteModelWithConfirm).toHaveBeenCalledWith(workspace, { confirmName: "Broken" });
});
test("a missing meta is fetched, put in the store, and its settings rendered", async () => {
mocks.rpc.mockResolvedValue(workspaceMeta);
await render();
expect(mocks.rpc).toHaveBeenCalledWith("cmd_get_workspace_meta", { workspaceId: "wk_test" });
expect(mocks.mergeModelsInStore).toHaveBeenCalledWith("workspace_meta", [workspaceMeta]);
const settings = tab("settings");
expect(settings.querySelector('[data-testid="sync-setting"]')?.textContent).toBe("/sync/broken");
expect(settings.querySelector('[data-testid="encryption-setting"]')).not.toBeNull();
expect(host.querySelector('[role="alert"]')).toBeNull();
});
test("a meta that cannot be loaded shows the error only where sync and encryption go", async () => {
mocks.rpc.mockRejectedValue("database is locked");
await render();
// The error is the one banner, inside the Settings tab, in place of sync and encryption
const banners = host.querySelectorAll('[role="alert"]');
expect(banners).toHaveLength(1);
expect(tab("settings").contains(banners[0] ?? null)).toBe(true);
expect(banners[0]?.textContent).toContain("database is locked");
expect(host.querySelector('[data-testid="sync-setting"]')).toBeNull();
expect(host.querySelector('[data-testid="encryption-setting"]')).toBeNull();
expect(mocks.mergeModelsInStore).not.toHaveBeenCalled();
// The workspace's own settings and Delete still render
const general = tab("general");
expect(general.querySelector<HTMLInputElement>('input[aria-label="Name"]')?.value).toBe("Broken");
expect(general.contains(deleteButton() ?? null)).toBe(true);
});
@@ -1,20 +1,12 @@
import type { WorkspaceMeta } from "@yaakapp-internal/models";
import {
mergeModelsInStore,
patchModel,
workspaceMetasAtom,
workspacesAtom,
} from "@yaakapp-internal/models";
import { patchModel, workspaceMetasAtom, workspacesAtom } from "@yaakapp-internal/models";
import { Banner, HStack, InlineCode, CountBadge } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useEffect, useState } from "react";
import { useAuthTab } from "../hooks/useAuthTab";
import { useHeadersTab } from "../hooks/useHeadersTab";
import { useInheritedHeaders } from "../hooks/useInheritedHeaders";
import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
import { showDialog } from "../lib/dialog";
import { router } from "../lib/router";
import { rpc } from "../lib/rpc";
import { CopyIconButton } from "./CopyIconButton";
import { Button } from "./core/Button";
import { PlainInput } from "./core/PlainInput";
@@ -55,10 +47,6 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
const authTab = useAuthTab(TAB_AUTH, workspace ?? null);
const headersTab = useHeadersTab(TAB_HEADERS, workspace ?? null);
const inheritedHeaders = useInheritedHeaders(workspace ?? null);
const workspaceMetaError = useEnsureWorkspaceMeta(
workspaceId,
workspace != null && workspaceMeta == null,
);
if (workspace == null) {
return (
@@ -68,6 +56,13 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
);
}
if (workspaceMeta == null)
return (
<Banner color="danger">
<InlineCode>WorkspaceMeta</InlineCode> not found for workspace
</Banner>
);
return (
<Tabs
defaultValue={tab ?? DEFAULT_TAB}
@@ -110,26 +105,15 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
<TabContent value={TAB_SETTINGS} className="overflow-y-auto h-full px-4">
<SettingsList className="space-y-8 pb-3">
<SettingsSection title={null}>
{workspaceMeta != null ? (
<>
<SyncToFilesystemSetting
layout="settings"
value={{ filePath: workspaceMeta.settingSyncDir }}
onCreateNewWorkspace={hide}
onChange={({ filePath }) =>
patchModel(workspaceMeta, { settingSyncDir: filePath })
}
/>
<div className="mt-4">
<WorkspaceEncryptionSetting layout="settings" size="xs" />
</div>
</>
) : workspaceMetaError != null ? (
<Banner color="danger">
Sync and encryption settings are unavailable because{" "}
<InlineCode>WorkspaceMeta</InlineCode> could not be loaded: {workspaceMetaError}
</Banner>
) : null}
<SyncToFilesystemSetting
layout="settings"
value={{ filePath: workspaceMeta.settingSyncDir }}
onCreateNewWorkspace={hide}
onChange={({ filePath }) => patchModel(workspaceMeta, { settingSyncDir: filePath })}
/>
<div className="mt-4">
<WorkspaceEncryptionSetting layout="settings" size="xs" />
</div>
</SettingsSection>
<ModelSettingsEditor model={workspace} showSectionTitles />
</SettingsList>
@@ -193,34 +177,6 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
);
}
/**
* The meta row normally reaches the store when the workspace loads, which also creates it if it
* is missing. If that load failed (for example on a corrupt row elsewhere in the workspace), the
* store never gets it, so ask for it directly. Returns the error if that fails too.
*/
function useEnsureWorkspaceMeta(workspaceId: string, missing: boolean): string | null {
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!missing) return;
let cancelled = false;
setError(null);
rpc<WorkspaceMeta>("cmd_get_workspace_meta", { workspaceId }).then(
(meta) => {
if (!cancelled) mergeModelsInStore("workspace_meta", [meta]);
},
(err) => {
if (!cancelled) setError(String(err));
},
);
return () => {
cancelled = true;
};
}, [workspaceId, missing]);
return error;
}
WorkspaceSettingsDialog.show = (workspaceId: string, tab?: WorkspaceSettingsTab) => {
showDialog({
id: "workspace-settings",
@@ -1,9 +1,7 @@
import { useVirtualizer, type Virtualizer } from "@tanstack/react-virtual";
import classNames from "classnames";
import type { ReactElement, ReactNode, UIEvent } from "react";
import { useCallback, useLayoutEffect, useRef, useState } from "react";
import { Icon } from "../Icon";
import { IconButton } from "../IconButton";
import { IconButton } from "./IconButton";
interface Props<T> {
data: T[];
@@ -13,14 +11,6 @@ interface Props<T> {
focusable?: boolean;
/** Callback to expose the virtualizer for keyboard navigation */
onVirtualizerReady?: (virtualizer: Virtualizer<HTMLDivElement, Element>) => void;
/**
* Holds and catches up as the proxy's flow list does: once scrolled away from the end (or
* `holdSignal` changes), new items wait under a pill counting them, which scrolls back down.
* Without it, a button locks the scroll to the bottom again.
*/
catchUp?: { label: (count: number) => string };
/** Holds the scroll where it is whenever this changes, as selecting an item does */
holdSignal?: unknown;
}
export function AutoScroller<T>({
@@ -29,15 +19,9 @@ export function AutoScroller<T>({
header,
focusable = false,
onVirtualizerReady,
catchUp,
holdSignal,
}: Props<T>) {
const containerRef = useRef<HTMLDivElement>(null);
const [autoScroll, setAutoScroll] = useState<boolean>(true);
// How many items there were when the scroll was held, to count the new ones
const [heldAt, setHeldAt] = useState<number>(0);
const latestLength = useRef(data.length);
latestLength.current = data.length;
// The virtualizer
const rowVirtualizer = useVirtualizer({
@@ -51,11 +35,6 @@ export function AutoScroller<T>({
onVirtualizerReady?.(rowVirtualizer);
}, [rowVirtualizer, onVirtualizerReady]);
const hold = useCallback(() => {
setAutoScroll(false);
setHeldAt(latestLength.current);
}, []);
// Scroll to new items
const handleScroll = useCallback(
(e: UIEvent<HTMLDivElement>) => {
@@ -65,21 +44,12 @@ export function AutoScroller<T>({
const pixelsFromBottom = el.scrollHeight - (el.scrollTop + el.clientHeight);
const newAutoScroll = pixelsFromBottom <= 0;
if (newAutoScroll !== autoScroll) {
if (newAutoScroll) setAutoScroll(true);
else hold();
setAutoScroll(newAutoScroll);
}
},
[autoScroll, hold],
[autoScroll],
);
// Selecting an item holds the list where it is
const lastHoldSignal = useRef(holdSignal);
useLayoutEffect(() => {
if (Object.is(lastHoldSignal.current, holdSignal)) return;
lastHoldSignal.current = holdSignal;
if (catchUp != null && autoScroll) hold();
}, [autoScroll, catchUp, hold, holdSignal]);
// Scroll to bottom on count change
useLayoutEffect(() => {
if (!autoScroll) return;
@@ -92,11 +62,9 @@ export function AutoScroller<T>({
el.scrollTop = el.scrollHeight;
}, [autoScroll, data.length]);
const newCount = data.length - heldAt;
return (
<div className="h-full w-full relative grid grid-rows-[auto_minmax(0,1fr)]">
{!autoScroll && catchUp == null && (
{!autoScroll && (
<div className="absolute bottom-0 right-0 m-2">
<IconButton
title="Lock scroll to bottom"
@@ -109,21 +77,6 @@ export function AutoScroller<T>({
/>
</div>
)}
{!autoScroll && catchUp != null && newCount > 0 && (
<button
type="button"
onClick={() => setAutoScroll(true)}
className={classNames(
"absolute bottom-3 left-1/2 -translate-x-1/2 z-10",
"flex items-center gap-1.5 h-6 px-3 rounded-full",
"bg-surface border border-border shadow-lg text-text text-xs tabular-nums",
"hover:bg-surface-highlight",
)}
>
<Icon icon="arrow_down_to_line" size="sm" color="primary" />
{catchUp.label(newCount)}
</button>
)}
{header ?? <span aria-hidden />}
<div
ref={containerRef}
@@ -1,7 +1,7 @@
import { generateId } from "@yaakapp-internal/lib/generateId";
import { useCallback, useMemo } from "react";
import { useEditorHost } from "../EditorHost";
import { Editor } from "./Editor/LazyEditor";
import type { Pair, PairEditorProps, PairWithId } from "./PairEditor";
import { generateId } from "@yaakapp-internal/lib/generateId";
type Props = PairEditorProps;
@@ -12,10 +12,8 @@ export function BulkPairEditor({
valuePlaceholder,
forceUpdateKey,
forcedEnvironmentId,
readOnly,
stateKey,
}: Props) {
const { Editor } = useEditorHost();
const pairsText = useMemo(() => formatBulkPairs(pairs), [pairs]);
const handleChange = useCallback(
@@ -35,7 +33,6 @@ export function BulkPairEditor({
placeholder={`${namePlaceholder ?? "name"}: ${valuePlaceholder ?? "value"}`}
defaultValue={pairsText}
language="pairs"
readOnly={readOnly}
onChange={handleChange}
/>
);
@@ -1,5 +1,8 @@
import type { Completion, CompletionContext, CompletionResult } from "@codemirror/autocomplete";
import { autocompletion, startCompletion } from "@codemirror/autocomplete";
import { LanguageSupport, LRLanguage, syntaxTree } from "@codemirror/language";
import type { SyntaxNode } from "@lezer/common";
import { parser } from "./filter";
export interface FieldDef {
name: string;
@@ -10,18 +13,6 @@ export interface FieldDef {
export interface FilterOptions {
fields: FieldDef[] | null; // e.g., ['method','status','path'] or [{name:'tag', values:()=>cachedTags}]
/** Written before a field name, `@` by default: `@method:`. Empty for `host:` */
fieldPrefix?: string;
/**
* A prefix that negates a term, such as `!`: offered where a field name is, and a term may
* start with it before its field name
*/
negation?: { prefix: string; info?: string };
/**
* Value suggestions replace only what follows the field's colon, so `host:api` offers hosts
* matching `api`. Off, they replace the whole term
*/
replaceValueOnly?: boolean;
}
const FIELD_IDENT = /[A-Za-z0-9_/]+$/;
@@ -53,38 +44,36 @@ function wordBefore(
return { from, to: pos, text: m[0] };
}
function fieldCompletionFrom(
doc: string,
pos: number,
fieldPrefix = "@",
negation?: string,
): { from: number; includeAt: boolean; negated: boolean } | null {
function fieldCompletionFrom(doc: string, pos: number): { from: number; includeAt: boolean } | null {
const w = wordBefore(doc, pos, FIELD_IDENT);
let from = w?.from ?? pos;
const atTermStart = (at: number) => {
const before = doc[at - 1];
return at === 0 || (before != null && /\s/.test(before));
};
let negated = false;
if (negation != null && doc.slice(0, from).endsWith(negation)) {
from -= negation.length;
negated = true;
const from = w?.from ?? pos;
const beforeToken = doc[from - 1];
if (from === 0 || (beforeToken != null && /\s/.test(beforeToken))) {
return { from, includeAt: true };
}
if (atTermStart(from)) {
return { from, includeAt: true, negated };
}
if (fieldPrefix !== "" && doc.slice(0, from).endsWith(fieldPrefix)) {
if (atTermStart(from - fieldPrefix.length)) {
return { from, includeAt: false, negated };
if (beforeToken === "@") {
const beforeAt = doc[from - 2];
if (from === 1 || (beforeAt != null && /\s/.test(beforeAt))) {
return { from, includeAt: false };
}
}
return null;
}
/** Inside a quote, opened and not yet closed */
function inPhrase(ctx: CompletionContext): boolean {
// Lezer node names from your grammar: Phrase is the quoted token
let n: SyntaxNode | null = syntaxTree(ctx.state).resolveInner(ctx.pos, -1);
while (n) {
if (n.name === "Phrase") return true;
n = n.parent;
}
return false;
}
// While typing an incomplete quote, there's no Phrase token yet.
function inUnclosedQuote(doc: string, pos: number): boolean {
let quotes = 0;
for (let i = 0; i < pos; i++) {
@@ -128,20 +117,13 @@ function contextInfo(stateDoc: string, pos: number) {
}
/** Build a completion list for field names */
function fieldNameCompletions(
fieldNames: string[],
includeAt: boolean,
fieldPrefix = "@",
fieldMap: Record<string, { info?: string }> = {},
negated = "",
): Completion[] {
function fieldNameCompletions(fieldNames: string[], includeAt: boolean): Completion[] {
return fieldNames.map((name) => ({
label: `${negated}${name}`,
label: name,
type: "property",
info: fieldMap[name]?.info,
apply: (view, _completion, from, to) => {
// Leave cursor right after the field filter colon.
const insert = `${negated}${includeAt ? fieldPrefix : ""}${name}:`;
const insert = `${includeAt ? "@" : ""}${name}:`;
view.dispatch({
changes: { from, to, insert },
selection: { anchor: from + insert.length },
@@ -164,39 +146,23 @@ function fieldValueCompletions(
}));
}
/** Offers the negation prefix itself, reopening the list for a field name after it */
function negationCompletion(negation: { prefix: string; info?: string }): Completion {
return {
label: negation.prefix,
type: "keyword",
info: negation.info,
apply: (view, _completion, from, to) => {
view.dispatch({
changes: { from, to, insert: negation.prefix },
selection: { anchor: from + negation.prefix.length },
});
startCompletion(view);
},
};
}
/** The main completion source */
export function makeCompletionSource(opts: FilterOptions) {
function makeCompletionSource(opts: FilterOptions) {
const { fieldNames, fieldMap } = normalizeFields(opts.fields ?? []);
return (ctx: CompletionContext): CompletionResult | null => {
const { state, pos } = ctx;
const doc = state.doc.toString();
if (inUnclosedQuote(doc, pos)) {
if (inPhrase(ctx) || inUnclosedQuote(doc, pos)) {
return null;
}
const { inValue, fieldName, lastColon, emptyAfterColon } = contextInfo(doc, pos);
const { inValue, fieldName, emptyAfterColon } = contextInfo(doc, pos);
// In field value position
if (inValue && fieldName) {
const w = wordBefore(doc, pos, VALUE_IDENT);
const from = opts.replaceValueOnly ? lastColon + 1 : (w?.from ?? pos);
const from = w?.from ?? pos;
const to = pos;
const valDefs = fieldMap[fieldName];
const vals = fieldValueCompletions(valDefs);
@@ -220,27 +186,26 @@ export function makeCompletionSource(opts: FilterOptions) {
}
// Not in a value: suggest field names (and maybe boolean ops)
const completion = fieldCompletionFrom(doc, pos, opts.fieldPrefix, opts.negation?.prefix);
const completion = fieldCompletionFrom(doc, pos);
if (completion == null) return null;
const { from, includeAt, negated } = completion;
const { from, includeAt } = completion;
const to = pos;
const options: Completion[] = fieldNameCompletions(
fieldNames,
includeAt,
opts.fieldPrefix,
fieldMap,
negated ? (opts.negation?.prefix ?? "") : "",
);
if (opts.negation != null && !negated) options.push(negationCompletion(opts.negation));
const options: Completion[] = fieldNameCompletions(fieldNames, includeAt);
return { from, to, options, filter: true };
};
}
/**
* Field and value suggestions, as the sidebar's filter offers them, for a field of another
* language: a plain input of `host:` and `app:` entries, say
*/
export function filterAutocomplete(options: FilterOptions) {
return autocompletion({ override: [makeCompletionSource(options)] });
const language = LRLanguage.define({
name: "filter",
parser,
languageData: {
autocompletion: {},
},
});
/** Public extension */
export function filter(options: FilterOptions) {
const source = makeCompletionSource(options);
return new LanguageSupport(language, [autocompletion({ override: [source] })]);
}
@@ -0,0 +1,78 @@
@top Query { Expr }
@skip { space+ }
@tokens {
space { $[ \t\r\n]+ }
LParen { "(" }
RParen { ")" }
At { "@" }
Colon { ":" }
Not { "-" | "NOT" }
// Keywords (case-insensitive)
And { "AND" }
Or { "OR" }
// "quoted phrase" with simple escapes: \" and \\
Phrase { '"' (!["\\] | "\\" _)* '"' }
// Bare words run until filter syntax or whitespace. Leading '-' remains unary
// negation, but '-' may appear after the first character.
Word { ![ \t\r\n():"@-] ![ \t\r\n():"@]* }
FieldValueWord { ![ \t\r\n"] ![ \t\r\n]* }
@precedence { Not, And, Or, Word }
}
@detectDelim
// Precedence: NOT (highest) > AND > OR (lowest)
// We also allow implicit AND in your parser/evaluator, but for highlighting,
// this grammar parses explicit AND/OR/NOT + adjacency as a sequence (Seq).
Expr {
OrExpr
}
OrExpr {
AndExpr (Or AndExpr)*
}
AndExpr {
Unary (And Unary | Unary)* // allow implicit AND by adjacency: Unary Unary
}
Unary {
Not Unary
| Primary
}
Primary {
Group
| Field
| Phrase
| Term
}
Group {
LParen Expr RParen
}
Field {
FieldName Colon FieldValue
}
FieldName {
At? Word
}
FieldValue {
Phrase
| FieldValueWord
}
Term {
Word
}
@external propSource highlight from "./highlight"
@@ -0,0 +1,42 @@
import { describe, expect, test } from "vite-plus/test";
import { parser } from "./filter";
function getNodeNames(input: string): string[] {
const tree = parser.parse(input);
const nodes: string[] = [];
const cursor = tree.cursor();
do {
if (cursor.name !== "Query") {
nodes.push(cursor.name);
}
} while (cursor.next());
return nodes;
}
describe("filter grammar", () => {
test("parses URL-like field values as one value", () => {
const nodes = getNodeNames("@url:yaak.app/foo-bar");
expect(nodes).not.toContain("⚠");
expect(nodes).toContain("FieldValue");
expect(nodes).toContain("FieldValueWord");
});
test("parses punctuation-heavy field values as one value", () => {
const nodes = getNodeNames("@url:yaa$&#*@tsrna(*)");
expect(nodes).not.toContain("⚠");
expect(nodes).toContain("FieldValue");
expect(nodes).toContain("FieldValueWord");
});
test("parses operator-looking field values as one value", () => {
const negativeValueNodes = getNodeNames("@url:-foo");
const operatorWordNodes = getNodeNames("@url:AND");
expect(negativeValueNodes).not.toContain("⚠");
expect(negativeValueNodes).toContain("FieldValueWord");
expect(operatorWordNodes).not.toContain("⚠");
expect(operatorWordNodes).toContain("FieldValueWord");
});
});
@@ -0,0 +1,22 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
import {LRParser} from "@lezer/lr"
import {highlight} from "./highlight"
export const parser = LRParser.deserialize({
version: 14,
states: "%WOVQPOOPhOPOOOVQPO'#CfOmQPO'#ChO!_QPO'#ChO!dQPO'#CgOOQO'#Cc'#CcOVQPO'#CaOOQO'#Ca'#CaO!iQPO'#C`O!yQPO'#C_OOQO'#C^'#C^QOQPOOPOOO'#Cr'#CrP#UOPO)C>lO#]QPO,59QOOQO,59S,59SO#bQQO,59ROOQO,58{,58{OVQPO'#CsOOQO'#Cs'#CsO#jQPO,58zOVQPO'#CtO#zQPO,58yPOOO-E6p-E6pOOQO1G.l1G.lOOQO'#Cl'#ClOOQO1G.m1G.mOOQO,59_,59_OOQO-E6q-E6qOOQO,59`,59`OOQO-E6r-E6r",
stateData: "$]~OkPQ~OUVOXQO]SO^ROaUO~Ok]O~OUcXXcX]cX^cX_[XacXdcXecXicXWcX~O^`O~O_aO~OdcOeSXiSXWSX~PVOefOiRXWRX~Ok]O~Qj]WiO~OajObjO~OdcOeSaiSaWSa~PVOefOiRaWRa~OUde^e~",
goto: "#^iPPjpt{P![PP!e!e!nPPP!wPP!ePP!z#Q#WQ[OR_QTZOQSYOQRnfUXOQfQbVSdXeRlc_WOQVXcef_UOQVXcef_TOQVXcefRkaQ^PRh^QeXRmeQgYRog",
nodeNames: "⚠ Query Expr OrExpr AndExpr Unary Not Primary RParen LParen Group Field FieldName At Word Colon FieldValue Phrase FieldValueWord Term And Or",
maxTerm: 27,
nodeProps: [
["openedBy", 8,"LParen"],
["closedBy", 9,"RParen"]
],
propSources: [highlight],
skippedNodes: [0,22],
repeatNodeCount: 3,
tokenData: "2h~RiOX!pXY$hYZ$hZ]!p]^$h^p!ppq$hqr!prs$ysx!pxy&gyz'Qz}!p}!O'k!O![!p![!](U!]!b!p!b!c(o!c!d)Y!d!p!p!p!q,q!q!r0Y!r;'S!p;'S;=`$b<%lO!pR!w^bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!pQ#xUbQOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sQ$_P;=`<%l#sR$eP;=`<%l!p~$mSk~XY$hYZ$h]^$hpq$h~$|VOr$yrs%cs#O$y#O#P%h#P;'S$y;'S;=`&a<%lO$y~%hOa~~%kRO;'S$y;'S;=`%t;=`O$y~%wWOr$yrs%cs#O$y#O#P%h#P;'S$y;'S;=`&a;=`<%l$y<%lO$y~&dP;=`<%l$yR&nUbQXPOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR'XUbQWPOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR'rUbQUPOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR(]U_PbQOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR(vU]PbQOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR)a`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!p!p!p!q*c!q;'S!p;'S;=`$b<%lO!pR*j`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!f!p!f!g+l!g;'S!p;'S;=`$b<%lO!pR+u^bQdP^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!pR,x`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!q!p!q!r-z!r;'S!p;'S;=`$b<%lO!pR.R`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!v!p!v!w/T!w;'S!p;'S;=`$b<%lO!pR/^^bQUP^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!pR0a`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!t!p!t!u1c!u;'S!p;'S;=`$b<%lO!pR1l^bQeP^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!p",
tokenizers: [0, 1],
topRules: {"Query":[0,1]},
tokenPrec: 145
})
@@ -0,0 +1,43 @@
import { describe, expect, test } from "vite-plus/test";
import { formatFieldFilter } from "./format";
import { evaluate, parseQuery } from "./query";
function matchesFormattedUrl(value: string) {
return evaluate(parseQuery(formatFieldFilter("url", value)), {
fields: { url: value },
});
}
describe("formatFieldFilter", () => {
test("keeps URL-like values bare", () => {
expect(formatFieldFilter("url", "yaak.app/foo-bar")).toBe("@url:yaak.app/foo-bar");
expect(matchesFormattedUrl("yaak.app/foo-bar")).toBe(true);
});
test("keeps non-syntax punctuation bare", () => {
expect(formatFieldFilter("url", "yaa$&#*@tsrna(*)")).toBe("@url:yaa$&#*@tsrna(*)");
expect(matchesFormattedUrl("yaa$&#*@tsrna(*)")).toBe(true);
});
test("keeps values that start with an operator token bare", () => {
expect(formatFieldFilter("url", "-foo")).toBe("@url:-foo");
expect(matchesFormattedUrl("-foo")).toBe(true);
});
test("keeps boolean operator words bare", () => {
expect(formatFieldFilter("url", "AND")).toBe("@url:AND");
expect(formatFieldFilter("url", "or")).toBe("@url:or");
expect(formatFieldFilter("url", "Not")).toBe("@url:Not");
expect(matchesFormattedUrl("AND")).toBe(true);
});
test("escapes quoted values", () => {
expect(formatFieldFilter("url", 'say "hi"')).toBe('@url:"say \\"hi\\""');
expect(matchesFormattedUrl('say "hi"')).toBe(true);
});
test("quotes values that start with a quote", () => {
expect(formatFieldFilter("url", '"hi"')).toBe('@url:"\\"hi\\""');
expect(matchesFormattedUrl('"hi"')).toBe(true);
});
});
@@ -0,0 +1,7 @@
const bareFieldValue = /^[^\s"]\S*$/;
export function formatFieldFilter(field: string, value: string) {
const escapedValue = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
const filterValue = bareFieldValue.test(value) ? value : `"${escapedValue}"`;
return `@${field}:${filterValue}`;
}
@@ -0,0 +1,22 @@
import { styleTags, tags as t } from "@lezer/highlight";
export const highlight = styleTags({
// Boolean operators
And: t.operatorKeyword,
Or: t.operatorKeyword,
Not: t.operatorKeyword,
// Structural punctuation
LParen: t.paren,
RParen: t.paren,
Colon: t.punctuation,
Minus: t.operator,
// Literals
Phrase: t.string, // "quoted string"
// Fields
"FieldName/At": t.attributeName,
"FieldName/Word": t.attributeName,
"FieldValue/FieldValueWord": t.attributeValue,
});
@@ -0,0 +1,308 @@
// query.ts
// A tiny query language parser with NOT/AND/OR, parentheses, phrases, negation, and field:value.
import { fuzzyMatch } from "fuzzbunny";
/////////////////////////
// AST
/////////////////////////
export type Ast =
| { type: "Term"; value: string } // foo
| { type: "Phrase"; value: string } // "hi there"
| { type: "Field"; field: string; value: string } // method:POST or title:"exact phrase"
| { type: "Not"; node: Ast } // -foo or NOT foo
| { type: "And"; left: Ast; right: Ast } // a AND b
| { type: "Or"; left: Ast; right: Ast }; // a OR b
/////////////////////////
// Tokenizer
/////////////////////////
type Tok =
| { kind: "LPAREN" }
| { kind: "RPAREN" }
| { kind: "AND" }
| { kind: "OR" }
| { kind: "NOT" } // explicit NOT
| { kind: "MINUS" } // unary minus before term/phrase/paren group
| { kind: "COLON" }
| { kind: "WORD"; text: string } // bareword (unquoted)
| { kind: "PHRASE"; text: string } // "quoted phrase"
| { kind: "EOF" };
const isSpace = (c: string) => /\s/.test(c);
const isWordStart = (c: string) => c !== "" && !isSpace(c) && !/[():"@-]/.test(c);
const isWordChar = (c: string) => c !== "" && !isSpace(c) && !/[():"@]/.test(c);
export function tokenize(input: string): Tok[] {
const toks: Tok[] = [];
let i = 0;
const n = input.length;
const peek = () => input[i] ?? "";
const advance = () => input[i++];
const readWord = () => {
let s = "";
while (i < n && isWordChar(peek())) s += advance();
return s;
};
const readFieldValue = () => {
let s = "";
while (i < n && !isSpace(peek())) s += advance();
return s;
};
const readPhrase = () => {
// assumes current char is opening quote
advance(); // consume opening "
let s = "";
while (i < n) {
const c = advance();
if (c === `"`) break;
if (c === "\\" && i < n) {
// escape \" and \\ (simple)
const next = advance();
s += next;
} else {
s += c;
}
}
return s;
};
while (i < n) {
const c = peek();
if (isSpace(c)) {
i++;
continue;
}
if (c === "(") {
toks.push({ kind: "LPAREN" });
i++;
continue;
}
if (c === ")") {
toks.push({ kind: "RPAREN" });
i++;
continue;
}
if (c === ":") {
toks.push({ kind: "COLON" });
i++;
if (peek() && !isSpace(peek()) && peek() !== `"`) {
toks.push({ kind: "WORD", text: readFieldValue() });
}
continue;
}
if (c === `"`) {
const text = readPhrase();
toks.push({ kind: "PHRASE", text });
continue;
}
if (c === "-") {
toks.push({ kind: "MINUS" });
i++;
continue;
}
// WORD / AND / OR / NOT
if (isWordStart(c)) {
const w = readWord();
const upper = w.toUpperCase();
if (upper === "AND") toks.push({ kind: "AND" });
else if (upper === "OR") toks.push({ kind: "OR" });
else if (upper === "NOT") toks.push({ kind: "NOT" });
else toks.push({ kind: "WORD", text: w });
continue;
}
// Unknown char—skip to be forgiving
i++;
}
toks.push({ kind: "EOF" });
return toks;
}
class Parser {
private i = 0;
constructor(private toks: Tok[]) {}
private peek(): Tok {
return this.toks[this.i] ?? { kind: "EOF" };
}
private advance(): Tok {
return this.toks[this.i++] ?? { kind: "EOF" };
}
private at(kind: Tok["kind"]) {
return this.peek().kind === kind;
}
// Top-level: parse OR-precedence chain, allowing implicit AND.
parse(): Ast | null {
if (this.at("EOF")) return null;
const expr = this.parseOr();
if (!this.at("EOF")) {
// Optionally, consume remaining tokens or throw
}
return expr;
}
// Precedence: NOT (highest), AND, OR (lowest)
private parseOr(): Ast {
let node = this.parseAnd();
while (this.at("OR")) {
this.advance();
const rhs = this.parseAnd();
node = { type: "Or", left: node, right: rhs };
}
return node;
}
private parseAnd(): Ast {
let node = this.parseUnary();
// Implicit AND: if next token starts a primary, treat as AND.
while (this.at("AND") || this.startsPrimary()) {
if (this.at("AND")) this.advance();
const rhs = this.parseUnary();
node = { type: "And", left: node, right: rhs };
}
return node;
}
private parseUnary(): Ast {
if (this.at("NOT") || this.at("MINUS")) {
this.advance();
const node = this.parseUnary();
return { type: "Not", node };
}
return this.parsePrimaryOrField();
}
private startsPrimary(): boolean {
const k = this.peek().kind;
return k === "WORD" || k === "PHRASE" || k === "LPAREN" || k === "MINUS" || k === "NOT";
}
private parsePrimaryOrField(): Ast {
// Parenthesized group
if (this.at("LPAREN")) {
this.advance();
const inside = this.parseOr();
// if (!this.at('RPAREN')) throw new Error("Missing closing ')'");
this.advance();
return inside;
}
// Phrase
if (this.at("PHRASE")) {
const t = this.advance() as Extract<Tok, { kind: "PHRASE" }>;
return { type: "Phrase", value: t.text };
}
// Field or bare word
if (this.at("WORD")) {
const wordTok = this.advance() as Extract<Tok, { kind: "WORD" }>;
if (this.at("COLON")) {
// field:value or field:"phrase"
this.advance(); // :
let value: string;
if (this.at("PHRASE")) {
const p = this.advance() as Extract<Tok, { kind: "PHRASE" }>;
value = p.text;
} else if (this.at("WORD")) {
const w = this.advance() as Extract<Tok, { kind: "WORD" }>;
value = w.text;
} else {
// Anything else after colon is treated literally as a single Term token.
const t = this.advance();
value = tokText(t);
}
return { type: "Field", field: wordTok.text, value };
}
// plain term
return { type: "Term", value: wordTok.text };
}
const w = this.advance() as Extract<Tok, { kind: "WORD" }>;
return { type: "Phrase", value: "text" in w ? w.text : "" };
}
}
function tokText(t: Tok): string {
if ("text" in t) return t.text;
switch (t.kind) {
case "COLON":
return ":";
case "LPAREN":
return "(";
case "RPAREN":
return ")";
default:
return "";
}
}
export function parseQuery(q: string): Ast | null {
if (q.trim() === "") return null;
const toks = tokenize(q);
const parser = new Parser(toks);
return parser.parse();
}
export type Doc = {
text?: string;
fields?: Record<string, unknown>;
};
type Technique = "substring" | "fuzzy" | "strict";
function includes(hay: string | undefined, needle: string, technique: Technique): boolean {
if (!hay || !needle) return false;
if (technique === "strict") return hay === needle;
if (technique === "fuzzy") return !!fuzzyMatch(hay, needle);
return hay.indexOf(needle) !== -1;
}
export function evaluate(ast: Ast | null, doc: Doc): boolean {
if (!ast) return true; // Match everything if no query is provided
const text = (doc.text ?? "").toLowerCase();
const fieldsNorm: Record<string, string[]> = {};
for (const [k, v] of Object.entries(doc.fields ?? {})) {
if (!(typeof v === "string" || Array.isArray(v))) continue;
fieldsNorm[k.toLowerCase()] = Array.isArray(v)
? v.filter((v) => typeof v === "string").map((s) => s.toLowerCase())
: [String(v ?? "").toLowerCase()];
}
const evalNode = (node: Ast): boolean => {
switch (node.type) {
case "Term":
return includes(text, node.value.toLowerCase(), "fuzzy");
case "Phrase":
// Quoted phrases match exactly
return includes(text, node.value.toLowerCase(), "substring");
case "Field": {
const vals = fieldsNorm[node.field.toLowerCase()] ?? [];
if (vals.length === 0) return false;
return vals.some((v) => includes(v, node.value.toLowerCase(), "substring"));
}
case "Not":
return !evalNode(node.node);
case "And":
return evalNode(node.left) && evalNode(node.right);
case "Or":
return evalNode(node.left) || evalNode(node.right);
}
};
return evalNode(ast);
}
@@ -1,8 +1,15 @@
import type { CompletionContext } from "@codemirror/autocomplete";
import type { GenericCompletionConfig } from "@yaakapp-internal/ui";
import type { Completion, CompletionContext } from "@codemirror/autocomplete";
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
import { defaultBoost } from "./twig/completion";
export type { GenericCompletion, GenericCompletionConfig } from "@yaakapp-internal/ui";
export type GenericCompletion = GenericCompletionOption & {
apply?: Completion["apply"];
};
export interface GenericCompletionConfig {
minMatch?: number;
options: GenericCompletion[];
}
/**
* Complete options, always matching until the start of the line
@@ -1,2 +1,312 @@
export type { EventDetailAction } from "@yaakapp-internal/ui";
export { EventDetailHeader, EventViewer } from "@yaakapp-internal/ui";
import type { Virtualizer } from "@tanstack/react-virtual";
import { Banner, HStack, SplitLayout } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { format } from "date-fns";
import type { ReactNode } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { useEventViewerKeyboard } from "../../hooks/useEventViewerKeyboard";
import { CopyIconButton } from "../CopyIconButton";
import { AutoScroller } from "./AutoScroller";
import { Button } from "./Button";
import { IconButton } from "./IconButton";
import type { SelectProps } from "./Select";
import { Select } from "./Select";
import { Separator } from "./Separator";
interface EventViewerProps<T> {
/** Array of events to display */
events: T[];
/** Get unique key for each event */
getEventKey: (event: T, index: number) => string;
/** Render the event row - receives event, index, isActive, and onClick */
renderRow: (props: {
event: T;
index: number;
isActive: boolean;
onClick: () => void;
}) => ReactNode;
/** Render the detail pane for the selected event */
renderDetail?: (props: { event: T; index: number; onClose: () => void }) => ReactNode;
/** Optional header above the event list (e.g., connection status) */
header?: ReactNode;
/** Error message to display as a banner */
error?: string | null;
/** Key for SplitLayout state persistence */
splitLayoutStorageKey: string;
/** Default ratio for the split (0.0 - 1.0) */
defaultRatio?: number;
/** Enable keyboard navigation (arrow keys) */
enableKeyboardNav?: boolean;
/** Loading state */
isLoading?: boolean;
/** Message to show while loading */
loadingMessage?: string;
/** Message to show when no events */
emptyMessage?: string;
/** Callback when active index changes (for controlled state in parent) */
onActiveIndexChange?: (index: number | null) => void;
}
export function EventViewer<T>({
events,
getEventKey,
renderRow,
renderDetail,
header,
error,
splitLayoutStorageKey,
defaultRatio = 0.4,
enableKeyboardNav = true,
isLoading = false,
loadingMessage = "Loading events...",
emptyMessage = "No events recorded",
onActiveIndexChange,
}: EventViewerProps<T>) {
const [activeIndex, setActiveIndexInternal] = useState<number | null>(null);
const [isPanelOpen, setIsPanelOpen] = useState(false);
// Wrap setActiveIndex to notify parent
const setActiveIndex = useCallback(
(indexOrUpdater: number | null | ((prev: number | null) => number | null)) => {
setActiveIndexInternal((prev) => {
const newIndex =
typeof indexOrUpdater === "function" ? indexOrUpdater(prev) : indexOrUpdater;
onActiveIndexChange?.(newIndex);
return newIndex;
});
},
[onActiveIndexChange],
);
const containerRef = useRef<HTMLDivElement>(null);
const virtualizerRef = useRef<Virtualizer<HTMLDivElement, Element> | null>(null);
const activeEvent = useMemo(
() => (activeIndex != null ? events[activeIndex] : null),
[activeIndex, events],
);
// Check if the event list container is focused
const isContainerFocused = useCallback(() => {
return containerRef.current?.contains(document.activeElement) ?? false;
}, []);
// Keyboard navigation
useEventViewerKeyboard({
totalCount: events.length,
activeIndex,
setActiveIndex,
virtualizer: virtualizerRef.current,
isContainerFocused,
enabled: enableKeyboardNav,
closePanel: () => setIsPanelOpen(false),
openPanel: () => setIsPanelOpen(true),
});
// Handle virtualizer ready callback
const handleVirtualizerReady = useCallback(
(virtualizer: Virtualizer<HTMLDivElement, Element>) => {
virtualizerRef.current = virtualizer;
},
[],
);
// Handle row click - select and open panel, scroll into view
const handleRowClick = useCallback(
(index: number) => {
setActiveIndex(index);
setIsPanelOpen(true);
// Scroll to ensure selected item is visible after panel opens
requestAnimationFrame(() => {
virtualizerRef.current?.scrollToIndex(index, { align: "auto" });
});
},
[setActiveIndex],
);
const handleClose = useCallback(() => {
setIsPanelOpen(false);
}, []);
if (isLoading) {
return <div className="p-3 text-text-subtlest italic">{loadingMessage}</div>;
}
if (events.length === 0 && !error) {
return <div className="p-3 text-text-subtlest italic">{emptyMessage}</div>;
}
return (
<div ref={containerRef} className="h-full">
<SplitLayout
layout="vertical"
storageKey={splitLayoutStorageKey}
defaultRatio={defaultRatio}
minHeightPx={72}
firstSlot={({ style }) => (
<div style={style} className="w-full h-full grid grid-rows-[auto_minmax(0,1fr)]">
{header ?? <span aria-hidden />}
<AutoScroller
data={events}
focusable={enableKeyboardNav}
onVirtualizerReady={handleVirtualizerReady}
header={
error && (
<Banner color="danger" className="m-3">
{error}
</Banner>
)
}
render={(event, index) => (
<div key={getEventKey(event, index)}>
{renderRow({
event,
index,
isActive: index === activeIndex,
onClick: () => handleRowClick(index),
})}
</div>
)}
/>
</div>
)}
secondSlot={
activeEvent != null && renderDetail && isPanelOpen
? ({ style }) => (
<div style={style} className="grid grid-rows-[auto_minmax(0,1fr)] bg-surface">
<div className="pb-3 px-2">
<Separator />
</div>
<div className="mx-2 overflow-y-auto">
{renderDetail({
event: activeEvent,
index: activeIndex ?? 0,
onClose: handleClose,
})}
</div>
</div>
)
: null
}
/>
</div>
);
}
export type EventDetailAction =
| {
type?: "button";
/** Unique key for React */
key: string;
/** Button label */
label: string;
/** Optional icon */
icon?: ReactNode;
/** Click handler */
onClick: () => void;
}
| {
type: "select";
/** Unique key for React */
key: string;
/** Select label */
label: string;
/** Selected value */
value: string;
/** Select options */
options: SelectProps<string>["options"];
/** Change handler */
onChange: (value: string) => void;
};
interface EventDetailHeaderProps {
title: string;
prefix?: ReactNode;
timestamp?: string;
actions?: EventDetailAction[];
copyText?: string | (() => Promise<string | null>);
onClose?: () => void;
}
export function EventDetailHeader({
title,
prefix,
timestamp,
actions,
copyText,
onClose,
}: EventDetailHeaderProps) {
const formattedTime = timestamp ? format(new Date(`${timestamp}Z`), "HH:mm:ss.SSS") : null;
return (
<div className="flex items-center justify-between gap-2 mb-2 h-xs">
<HStack space={2} className="items-center min-w-0">
{prefix}
<h3 className="font-semibold select-auto cursor-auto truncate">{title}</h3>
</HStack>
<HStack space={2} className="items-center">
{actions?.map((action) =>
action.type === "select" ? (
<div key={action.key} className="w-32">
<Select
name={action.key}
label={action.label}
hideLabel
size="xs"
value={action.value}
options={action.options}
onChange={action.onChange}
/>
</div>
) : (
<Button
key={action.key}
type="button"
variant="border"
size="xs"
onClick={action.onClick}
>
{action.icon}
{action.label}
</Button>
),
)}
{copyText != null && (
<CopyIconButton text={copyText} size="xs" title="Copy" variant="border" iconSize="sm" />
)}
{formattedTime && (
<span className="text-text-subtlest font-mono text-editor ml-2">{formattedTime}</span>
)}
{onClose != null && (
<div
className={classNames(
copyText != null ||
formattedTime ||
((actions ?? []).length > 0 && "border-l border-l-surface-highlight ml-2 pl-3"),
)}
>
<IconButton
color="custom"
className="text-text-subtle -mr-3"
size="xs"
icon="x"
title="Close event panel"
onClick={onClose}
/>
</div>
)}
</HStack>
</div>
);
}
@@ -1 +1,38 @@
export { EventViewerRow } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { format } from "date-fns";
import type { ReactNode } from "react";
interface EventViewerRowProps {
isActive: boolean;
onClick: () => void;
icon: ReactNode;
content: ReactNode;
timestamp?: string;
}
export function EventViewerRow({
isActive,
onClick,
icon,
content,
timestamp,
}: EventViewerRowProps) {
return (
<div className="px-1">
<button
type="button"
onClick={onClick}
className={classNames(
"w-full grid grid-cols-[auto_minmax(0,1fr)_auto] gap-2 items-center text-left",
"px-1.5 h-xs font-mono text-editor cursor-default group focus:outline-hidden focus:text-text rounded-sm",
isActive && "bg-surface-active text-text!",
"text-text-subtle hover:text",
)}
>
{icon}
<div className="w-full truncate">{content}</div>
{timestamp && <div className="opacity-50">{format(`${timestamp}Z`, "HH:mm:ss.SSS")}</div>}
</button>
</div>
);
}
@@ -1,6 +1,6 @@
import classNames from "classnames";
import type { HotkeyAction } from "../hooks/useHotKey";
import { useHotkeyLabel } from "../hooks/useHotKey";
import type { HotkeyAction } from "../../hooks/useHotKey";
import { useHotkeyLabel } from "../../hooks/useHotKey";
interface Props {
action: HotkeyAction;
@@ -1,6 +1,6 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, test, vi } from "vite-plus/test";
import type { HotkeyAction } from "../hooks/useHotKey";
import type { HotkeyAction } from "../../hooks/useHotKey";
import { HotkeyList } from "./HotkeyList";
vi.mock("./Hotkey", () => ({
@@ -1 +1,31 @@
export { HotkeyList } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { ReactNode } from "react";
import { Fragment } from "react";
import type { HotkeyAction } from "../../hooks/useHotKey";
import { Hotkey } from "./Hotkey";
import { HotkeyLabel } from "./HotkeyLabel";
interface Props {
hotkeys: HotkeyAction[];
bottomSlot?: ReactNode;
className?: string;
}
export const HotkeyList = ({ hotkeys, bottomSlot, className }: Props) => {
return (
<div className={classNames(className, "h-full flex items-center justify-center")}>
<div className="grid gap-2 grid-cols-[auto_auto]">
{hotkeys.map((hotkey) => (
<Fragment key={hotkey}>
<HotkeyLabel className="truncate" 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}
</div>
</div>
);
};
+32 -28
View File
@@ -3,43 +3,16 @@ import { platform } from "@yaakapp-internal/platform";
import classNames from "classnames";
import type { HTMLAttributes } from "react";
import { appInfo } from "../../lib/appInfo";
import { ExternalLink, isYaakUrl } from "@yaakapp-internal/ui";
import { Icon } from "@yaakapp-internal/ui";
interface Props extends HTMLAttributes<HTMLAnchorElement> {
href: string;
noUnderline?: boolean;
}
/** Tags a link to yaak.app with which app it came from */
export function withYaakRef(href: string): string {
if (!isYaakUrl(href)) return href;
const url = new URL(href);
url.searchParams.set("ref", appInfo.identifier);
return url.toString();
}
/** Opens a link outside the app, tagged as `Link` tags it */
export function openExternalUrl(href: string) {
void platform.openUrl(withYaakRef(href));
}
export function Link({ href, children, noUnderline, className, ...other }: Props) {
const isExternal = href.match(/^https?:\/\//);
if (isExternal) {
return (
<ExternalLink
href={withYaakRef(href)}
noUnderline={noUnderline}
onOpen={openExternalUrl}
className={className}
{...other}
>
{children}
</ExternalLink>
);
}
className = classNames(
className,
"relative",
@@ -47,6 +20,37 @@ export function Link({ href, children, noUnderline, className, ...other }: Props
!noUnderline && "underline",
);
if (isExternal) {
const isYaakLink = href.startsWith("https://yaak.app");
let finalHref = href;
if (isYaakLink) {
const url = new URL(href);
url.searchParams.set("ref", appInfo.identifier);
finalHref = url.toString();
}
return (
// eslint-disable-next-line react/jsx-no-target-blank
<a
href={finalHref}
target="_blank"
rel={isYaakLink ? undefined : "noopener noreferrer"}
onClick={(e) => {
e.preventDefault();
void platform.openUrl(finalHref);
}}
className={className}
{...other}
>
<span className="pr-5">{children}</span>
<Icon
className="inline absolute right-0.5 top-[0.3em] opacity-70 group-hover:opacity-100"
size="xs"
icon="external_link"
/>
</a>
);
}
return (
<RouterLink to={href} className={className} {...other}>
{children}
+969 -7
View File
@@ -1,8 +1,970 @@
export type {
EditablePair,
Pair,
PairEditorHandle,
import type { DragEndEvent, DragMoveEvent, DragStartEvent } from "@dnd-kit/core";
import {
DndContext,
DragOverlay,
PointerSensor,
pointerWithin,
useDraggable,
useDroppable,
useSensor,
useSensors,
} from "@dnd-kit/core";
import classNames from "classnames";
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { WrappedEnvironmentVariable } from "../../hooks/useEnvironmentVariables";
import { useRandomKey } from "../../hooks/useRandomKey";
import { useToggle } from "../../hooks/useToggle";
import { showDialog } from "../../lib/dialog";
import { computeSideForDragMove, DropMarker, languageFromContentType } from "@yaakapp-internal/ui";
import { showPrompt } from "../../lib/prompt";
import { SelectFile } from "../SelectFile";
import { Button } from "./Button";
import { Checkbox } from "./Checkbox";
import type { DropdownItem } from "./Dropdown";
import { Dropdown } from "./Dropdown";
import type { EditorProps } from "./Editor/Editor";
import type { GenericCompletionConfig } from "./Editor/genericCompletion";
import { Editor } from "./Editor/LazyEditor";
import { Icon } from "@yaakapp-internal/ui";
import { IconButton } from "./IconButton";
import type { InputHandle, InputProps } from "./Input";
import { Input } from "./Input";
import { ensurePairId } from "./PairEditor.util";
import type { RadioDropdownItem } from "./RadioDropdown";
import { RadioDropdown } from "./RadioDropdown";
import { platform } from "@yaakapp-internal/platform";
export interface PairEditorHandle {
/**
* Focus a row's name field once it's able to take focus. Focus can't land immediately when the
* row isn't mounted yet or the editor is hidden — eg. sitting in a tab that's still becoming
* active — so this retries for up to ~1s. A newer focus request cancels a pending one.
*/
focusName(id: string): void;
/** Focus a row's value field. See {@link PairEditorHandle.focusName} for timing. */
focusValue(id: string): void;
}
/** ~1s at 60fps, plenty for a tab switch to land without spinning forever if it never does */
const MAX_FOCUS_ATTEMPTS = 60;
export type PairEditorProps = {
allowFileValues?: boolean;
allowMultilineValues?: boolean;
className?: string;
forcedEnvironmentId?: string;
forceUpdateKey?: string;
nameAutocomplete?: GenericCompletionConfig;
nameAutocompleteFunctions?: boolean;
nameAutocompleteVariables?: boolean;
namePlaceholder?: string;
nameValidate?: InputProps["validate"];
noScroll?: boolean;
onChange: (pairs: PairWithId[]) => void;
pairs: EditablePair[];
stateKey: InputProps["stateKey"];
setRef?: (n: PairEditorHandle) => void;
valueAutocomplete?: (name: string) => GenericCompletionConfig | undefined;
valueAutocompleteFunctions?: boolean;
valueAutocompleteVariables?: boolean | "environment";
valuePlaceholder?: string;
valueType?: InputProps["type"] | ((pair: Pair) => InputProps["type"]);
valueValidate?: InputProps["validate"];
};
export type Pair = {
id?: string;
enabled?: boolean;
name: string;
value: string;
contentType?: string;
filename?: string;
isFile?: boolean;
};
export type PairWithId = Pair & {
id: string;
};
/**
* A pair as handed to the editor. Adds behaviour that only the editor cares about, so the plain
* `Pair` stays the shape that gets written to models.
*/
export type EditablePair = Pair & {
/**
* When set, name edits are held until the field blurs and then committed through this, instead
* of calling `onChange` on every keystroke. Return false to reject the new name, which reverts
* the field. For names that can't be written directly, like a URL path placeholder that lives
* in the URL itself.
*/
commitName?: (name: string) => boolean;
};
type EditablePairWithId = EditablePair & {
id: string;
};
/** Strip the editor-only fields, so they can never reach a model write */
function toPairData({ commitName: _commitName, ...pair }: EditablePairWithId): PairWithId {
return pair;
}
/** Max number of pairs to show before prompting the user to reveal the rest */
const MAX_INITIAL_PAIRS = 30;
// Keyed on `stateKey` so no state survives a change of owner. Row ids alone can't tell owners
// apart — two pair sets can share ids (eg. one duplicated from the other), and the same-rows
// fast path below would swap in the new data without rebuilding the row editors, leaving any
// still-focused input showing the old owner's text.
export function PairEditor(props: PairEditorProps) {
return <PairEditorInner key={props.stateKey} {...props} />;
}
function PairEditorInner({
allowFileValues,
allowMultilineValues,
className,
forcedEnvironmentId,
forceUpdateKey,
nameAutocomplete,
nameAutocompleteFunctions,
nameAutocompleteVariables,
namePlaceholder,
nameValidate,
noScroll,
onChange,
pairs: originalPairs,
stateKey,
valueAutocomplete,
valueAutocompleteFunctions,
valueAutocompleteVariables,
valuePlaceholder,
valueType,
valueValidate,
setRef,
}: PairEditorProps) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const [isDragging, setIsDragging] = useState<EditablePairWithId | null>(null);
const [pairs, setPairs] = useState<EditablePairWithId[]>([]);
const [showAll, toggleShowAll] = useToggle(false);
// NOTE: Use local force update key because we trigger an effect on forceUpdateKey change. If
// we simply pass forceUpdateKey to the editor, the data set by useEffect will be stale.
const [localForceUpdateKey, regenerateLocalForceUpdateKey] = useRandomKey();
const rowsRef = useRef<Record<string, RowHandle | null>>({});
const pendingFocusFrame = useRef<number | null>(null);
useEffect(
() => () => {
if (pendingFocusFrame.current != null) cancelAnimationFrame(pendingFocusFrame.current);
},
[],
);
const focusWhenReady = useCallback((id: string, field: "name" | "value") => {
if (pendingFocusFrame.current != null) cancelAnimationFrame(pendingFocusFrame.current);
let attemptsLeft = MAX_FOCUS_ATTEMPTS;
const attempt = () => {
pendingFocusFrame.current = null;
const row = rowsRef.current[id];
const landed = field === "name" ? row?.focusName() : row?.focusValue();
if (landed || --attemptsLeft <= 0) return;
pendingFocusFrame.current = requestAnimationFrame(attempt);
};
attempt();
}, []);
const handle = useMemo<PairEditorHandle>(
() => ({
focusName(id: string) {
focusWhenReady(id, "name");
},
focusValue(id: string) {
focusWhenReady(id, "value");
},
}),
[focusWhenReady],
);
const initPairEditorRow = useCallback(
(id: string, n: RowHandle | null) => {
const isLast = id === pairs[pairs.length - 1]?.id;
if (isLast) return; // Never add the last pair
rowsRef.current[id] = n;
const validHandles = Object.values(rowsRef.current).filter((v) => v != null);
// Use >= because more might be added if an ID of one changes (eg. editing placeholder in URL regenerates fresh pairs every keystroke)
const ready = validHandles.length >= pairs.length - 1;
if (ready) {
setRef?.(handle);
}
},
[handle, pairs, setRef],
);
// oxlint-disable-next-line react-hooks/exhaustive-deps -- Only care about forceUpdateKey
useEffect(() => {
// Remove empty headers on initial render and ensure they all have valid ids (pairs didn't use to have IDs)
const newPairs: EditablePairWithId[] = [];
for (let i = 0; i < originalPairs.length; i++) {
const p = originalPairs[i];
if (!p) continue; // Make TS happy
if (isPairEmpty(p)) continue;
newPairs.push(ensurePairId(p));
}
// When the reset holds the exact same rows (eg. renaming a URL path placeholder, which keeps
// every row id), swap the data in without rebuilding the row editors. Unfocused inputs re-seed
// themselves when `defaultValue` changes, and a rebuild would drop the user's focus and
// selection — like tabbing from a placeholder's name into its value.
const trailingPair = pairs[pairs.length - 1];
const sameRows =
trailingPair != null &&
isPairEmpty(trailingPair) &&
pairs.length === newPairs.length + 1 &&
newPairs.every((p, i) => p.id === pairs[i]?.id);
if (sameRows) {
setPairs([...newPairs, trailingPair]);
return;
}
// Add empty last pair if there is none
const lastPair = newPairs[newPairs.length - 1];
if (lastPair == null || !isPairEmpty(lastPair)) {
newPairs.push(emptyPair());
}
setPairs(newPairs);
regenerateLocalForceUpdateKey();
}, [forceUpdateKey]);
const setPairsAndSave = useCallback(
(fn: (pairs: EditablePairWithId[]) => EditablePairWithId[]) => {
setPairs((oldPairs) => {
const pairs = fn(oldPairs);
onChange(pairs.map(toPairData));
return pairs;
});
},
[onChange],
);
const handleChange = useCallback(
(pair: EditablePairWithId) =>
setPairsAndSave((pairs) => pairs.map((p) => (pair.id !== p.id ? p : pair))),
[setPairsAndSave],
);
const handleDelete = useCallback(
(pair: Pair, focusPrevious: boolean) => {
if (focusPrevious) {
const index = pairs.findIndex((p) => p.id === pair.id);
const id = pairs[index - 1]?.id ?? null;
rowsRef.current[id ?? "n/a"]?.focusName();
}
return setPairsAndSave((oldPairs) => oldPairs.filter((p) => p.id !== pair.id));
},
[setPairsAndSave, pairs],
);
const handleFocusName = useCallback(
(pair: Pair) => {
const isLast = pair.id === pairs[pairs.length - 1]?.id;
if (isLast) setPairs([...pairs, emptyPair()]);
},
[pairs],
);
const handleFocusValue = useCallback(
(pair: Pair) => {
const isLast = pair.id === pairs[pairs.length - 1]?.id;
if (isLast) setPairs([...pairs, emptyPair()]);
},
[pairs],
);
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
// dnd-kit: show the “between rows” marker while hovering
const onDragMove = useCallback(
(e: DragMoveEvent) => {
const overId = e.over?.id as string | undefined;
if (!overId) return setHoveredIndex(null);
const overPair = pairs.find((p) => p.id === overId);
if (overPair == null) return setHoveredIndex(null);
const side = computeSideForDragMove(overPair.id, e);
const overIndex = pairs.findIndex((p) => p.id === overId);
const hoveredIndex = overIndex + (side === "before" ? 0 : 1);
setHoveredIndex(hoveredIndex);
},
[pairs],
);
const onDragStart = useCallback(
(e: DragStartEvent) => {
const pair = pairs.find((p) => p.id === e.active.id);
setIsDragging(pair ?? null);
},
[pairs],
);
const onDragCancel = useCallback(() => setIsDragging(null), []);
const onDragEnd = useCallback(
(e: DragEndEvent) => {
setIsDragging(null);
setHoveredIndex(null);
const activeId = e.active.id as string | undefined;
const overId = e.over?.id as string | undefined;
if (!activeId || !overId) return;
const from = pairs.findIndex((p) => p.id === activeId);
const baseTo = pairs.findIndex((p) => p.id === overId);
const to = hoveredIndex ?? (baseTo === -1 ? from : baseTo);
if (from !== -1 && to !== -1 && from !== to) {
setPairsAndSave((ps) => {
const next = [...ps];
const [moved] = next.splice(from, 1);
if (moved === undefined) return ps; // Make TS happy
next.splice(to > from ? to - 1 : to, 0, moved);
return next;
});
}
},
[pairs, hoveredIndex, setPairsAndSave],
);
return (
<div
className={classNames(
className,
"@container relative",
"pb-2 mb-auto h-full",
!noScroll && "overflow-y-auto max-h-full",
// Move over the width of the drag handle
"-mr-2 pr-2",
// Pad to make room for the drag divider
"pt-0.5",
"grid grid-rows-[auto_1fr]",
)}
>
<div>
<DndContext
autoScroll
sensors={sensors}
onDragMove={onDragMove}
onDragEnd={onDragEnd}
onDragStart={onDragStart}
onDragCancel={onDragCancel}
collisionDetection={pointerWithin}
>
{pairs.map((p, i) => {
if (!showAll && i > MAX_INITIAL_PAIRS) return null;
const isLast = i === pairs.length - 1;
return (
<Fragment key={p.id}>
{hoveredIndex === i && <DropMarker />}
<PairEditorRow
setRef={initPairEditorRow}
allowFileValues={allowFileValues}
allowMultilineValues={allowMultilineValues}
className="py-1"
forcedEnvironmentId={forcedEnvironmentId}
forceUpdateKey={localForceUpdateKey}
index={i}
isLast={isLast}
isDraggingGlobal={!!isDragging}
nameAutocomplete={nameAutocomplete}
nameAutocompleteFunctions={nameAutocompleteFunctions}
nameAutocompleteVariables={nameAutocompleteVariables}
namePlaceholder={namePlaceholder}
nameValidate={nameValidate}
onChange={handleChange}
onDelete={handleDelete}
onFocusName={handleFocusName}
onFocusValue={handleFocusValue}
pair={p}
stateKey={stateKey}
valueAutocomplete={valueAutocomplete}
valueAutocompleteFunctions={valueAutocompleteFunctions}
valueAutocompleteVariables={valueAutocompleteVariables}
valuePlaceholder={valuePlaceholder}
valueType={valueType}
valueValidate={valueValidate}
/>
</Fragment>
);
})}
{!showAll && pairs.length > MAX_INITIAL_PAIRS && (
<Button onClick={toggleShowAll} variant="border" className="m-2" size="xs">
Show {pairs.length - MAX_INITIAL_PAIRS} More
</Button>
)}
<DragOverlay dropAnimation={null}>
{isDragging && (
<PairEditorRow
namePlaceholder={namePlaceholder}
valuePlaceholder={valuePlaceholder}
className="opacity-80"
pair={isDragging}
index={0}
stateKey={null}
/>
)}
</DragOverlay>
</DndContext>
</div>
<div
// There's a weird bug where clicking below one of the above Codemirror inputs will cause
// it to focus. Putting this element here prevents that
aria-hidden
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
/>
</div>
);
}
type PairEditorRowProps = {
className?: string;
pair: EditablePairWithId;
forceFocusNamePairId?: string | null;
forceFocusValuePairId?: string | null;
onChange?: (pair: EditablePairWithId) => void;
onDelete?: (pair: EditablePairWithId, focusPrevious: boolean) => void;
onFocusName?: (pair: EditablePairWithId) => void;
onFocusValue?: (pair: EditablePairWithId) => void;
onSubmit?: (pair: EditablePairWithId) => void;
isLast?: boolean;
disabled?: boolean;
disableDrag?: boolean;
index: number;
isDraggingGlobal?: boolean;
setRef?: (id: string, n: RowHandle | null) => void;
} & Pick<
PairEditorProps,
PairWithId,
} from "@yaakapp-internal/ui";
export { PairEditor, PairEditorRow } from "@yaakapp-internal/ui";
| "allowFileValues"
| "allowMultilineValues"
| "forcedEnvironmentId"
| "forceUpdateKey"
| "nameAutocomplete"
| "nameAutocompleteVariables"
| "namePlaceholder"
| "nameValidate"
| "nameAutocompleteFunctions"
| "stateKey"
| "valueAutocomplete"
| "valueAutocompleteFunctions"
| "valueAutocompleteVariables"
| "valuePlaceholder"
| "valueType"
| "valueValidate"
>;
interface RowHandle {
focusName(): boolean;
focusValue(): boolean;
}
export function PairEditorRow({
allowFileValues,
allowMultilineValues,
className,
disableDrag,
disabled,
forceUpdateKey,
forcedEnvironmentId,
index,
isLast,
nameAutocomplete,
nameAutocompleteFunctions,
nameAutocompleteVariables,
namePlaceholder,
nameValidate,
isDraggingGlobal,
onChange,
onDelete,
onFocusName,
onFocusValue,
pair,
stateKey,
valueAutocomplete,
valueAutocompleteFunctions,
valueAutocompleteVariables,
valuePlaceholder,
valueType,
valueValidate,
setRef,
}: PairEditorRowProps) {
const nameInputRef = useRef<InputHandle>(null);
const valueInputRef = useRef<InputHandle>(null);
const handle = useRef<RowHandle>({
focusName() {
nameInputRef.current?.focus();
return nameInputRef.current?.isFocused() ?? false;
},
focusValue() {
valueInputRef.current?.focus();
return valueInputRef.current?.isFocused() ?? false;
},
});
const initNameInputRef = useCallback(
(n: InputHandle | null) => {
nameInputRef.current = n;
if (nameInputRef.current && valueInputRef.current) {
setRef?.(pair.id, handle.current);
}
},
[pair.id, setRef],
);
const initValueInputRef = useCallback(
(n: InputHandle | null) => {
valueInputRef.current = n;
if (nameInputRef.current && valueInputRef.current) {
setRef?.(pair.id, handle.current);
}
},
[pair.id, setRef],
);
const handleFocusName = useCallback(() => onFocusName?.(pair), [onFocusName, pair]);
const handleFocusValue = useCallback(() => onFocusValue?.(pair), [onFocusValue, pair]);
const handleDelete = useCallback(() => onDelete?.(pair, false), [onDelete, pair]);
const handleChangeEnabled = useMemo(
() => (enabled: boolean) => onChange?.({ ...pair, enabled }),
[onChange, pair],
);
// The name being typed into a deferred-commit field, before it's committed or reverted
const pendingName = useRef<string | null>(null);
const handleChangeName = useMemo(
() => (name: string) => {
// Keep the edit local until commit. Writing on every keystroke would reset the editor from
// beneath the cursor, since the pairs are derived from the name being edited.
if (pair.commitName != null) pendingName.current = name;
else onChange?.({ ...pair, name });
},
[onChange, pair],
);
const revertName = useCallback(() => {
const nameInput = nameInputRef.current;
if (nameInput != null) {
const changes = { from: 0, to: nameInput.value().length, insert: pair.name };
nameInput.dispatch({ changes });
}
pendingName.current = null;
}, [pair.name]);
const handleBlurName = useCallback(() => {
if (pair.commitName == null) return;
const name = pendingName.current;
pendingName.current = null;
if (name == null || name === pair.name) return;
if (!pair.commitName(name)) revertName();
}, [pair, revertName]);
const handleChangeValueText = useMemo(
() => (value: string) => onChange?.({ ...pair, value, isFile: false }),
[onChange, pair],
);
const handleChangeValueFile = useMemo(
() =>
({ filePath }: { filePath: string | null }) =>
onChange?.({ ...pair, value: filePath ?? "", isFile: true }),
[onChange, pair],
);
const handleChangeValueContentType = useMemo(
() => (contentType: string) => onChange?.({ ...pair, contentType }),
[onChange, pair],
);
const handleChangeValueFilename = useMemo(
() => (filename: string) => onChange?.({ ...pair, filename }),
[onChange, pair],
);
const handleEditMultiLineValue = useCallback(
() =>
showDialog({
id: "pair-edit-multiline",
size: "dynamic",
title: <>Edit {pair.name}</>,
render: ({ hide }) => (
<MultilineEditDialog
hide={hide}
onChange={handleChangeValueText}
defaultValue={pair.value}
contentType={pair.contentType ?? null}
/>
),
}),
[handleChangeValueText, pair.contentType, pair.name, pair.value],
);
const defaultItems = useMemo(
(): DropdownItem[] => [
{
label: "Edit Multi-line",
onSelect: handleEditMultiLineValue,
hidden: !allowMultilineValues,
},
{
label: "Delete",
onSelect: handleDelete,
color: "danger",
},
],
[allowMultilineValues, handleDelete, handleEditMultiLineValue],
);
const { attributes, listeners, setNodeRef: setDraggableRef } = useDraggable({ id: pair.id });
const { setNodeRef: setDroppableRef } = useDroppable({ id: pair.id });
// Filter out the current pair name
const valueAutocompleteVariablesFiltered = useMemo<EditorProps["autocompleteVariables"]>(() => {
if (valueAutocompleteVariables === "environment") {
return (v: WrappedEnvironmentVariable): boolean => v.variable.name !== pair.name;
}
return valueAutocompleteVariables;
}, [pair.name, valueAutocompleteVariables]);
const handleSetRef = useCallback(
(n: HTMLDivElement | null) => {
setDraggableRef(n);
setDroppableRef(n);
},
[setDraggableRef, setDroppableRef],
);
// Skip the trailing placeholder row. It exists to start a new pair, so a picker there would
// imply it's already a real row. `Input` handles the rest, including having no options to show.
const showOptionsPicker = !isLast;
return (
<div
ref={handleSetRef}
className={classNames(
className,
"group/pair-row grid grid-cols-[auto_auto_minmax(0,1fr)_auto]",
"grid-rows-1 items-center",
!pair.enabled && "opacity-60",
)}
>
<Checkbox
hideLabel
title={pair.enabled ? "Disable item" : "Enable item"}
disabled={isLast || disabled}
checked={isLast ? false : !!pair.enabled}
className={classNames(isLast && "opacity-disabled!")}
onChange={handleChangeEnabled}
/>
{!isLast && !disableDrag ? (
<div
{...attributes}
{...listeners}
className={classNames(
"py-2 h-7 w-4 flex items-center",
"justify-center opacity-0 group-hover/pair-row:opacity-70",
)}
>
<Icon size="sm" icon="grip_vertical" className="pointer-events-none" />
</div>
) : (
<span className="w-4" />
)}
<div
className={classNames(
"grid items-center",
"@xs:gap-2 @xs:grid-rows-1! @xs:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]!",
"gap-0.5 grid-cols-1 grid-rows-2",
)}
>
<Input
setRef={initNameInputRef}
hideLabel
stateKey={`name.${pair.id}.${stateKey}`}
disabled={disabled}
wrapLines={false}
readOnly={isDraggingGlobal}
size="sm"
required={!isLast && !!pair.enabled && !!pair.value}
validate={nameValidate}
forcedEnvironmentId={forcedEnvironmentId}
forceUpdateKey={forceUpdateKey}
containerClassName={classNames("bg-surface", isLast && "border-dashed")}
defaultValue={pair.name}
label="Name"
name={`name[${index}]`}
onBlur={handleBlurName}
onChange={handleChangeName}
onFocus={handleFocusName}
placeholder={namePlaceholder ?? "name"}
autocomplete={nameAutocomplete}
autocompleteVariables={nameAutocompleteVariables}
autocompleteFunctions={nameAutocompleteFunctions}
showOptionsPicker={showOptionsPicker}
/>
<div className="w-full grid grid-cols-[minmax(0,1fr)_auto] gap-1 items-center">
{pair.isFile ? (
<SelectFile
disabled={disabled}
inline
size="xs"
filePath={pair.value}
nameOverride={pair.filename || null}
onChange={handleChangeValueFile}
/>
) : pair.value.includes("\n") ? (
<Button
color="secondary"
size="sm"
onClick={handleEditMultiLineValue}
title={pair.value}
className="text-xs font-mono"
>
{pair.value.split("\n").join(" ")}
</Button>
) : (
<Input
setRef={initValueInputRef}
hideLabel
stateKey={`value.${pair.id}.${stateKey}`}
wrapLines={false}
size="sm"
disabled={disabled}
readOnly={isDraggingGlobal}
containerClassName={classNames("bg-surface", isLast && "border-dashed")}
validate={valueValidate}
forcedEnvironmentId={forcedEnvironmentId}
forceUpdateKey={forceUpdateKey}
defaultValue={pair.value}
label="Value"
name={`value[${index}]`}
onChange={handleChangeValueText}
onFocus={handleFocusValue}
type={isLast ? "text" : typeof valueType === "function" ? valueType(pair) : valueType}
placeholder={valuePlaceholder ?? "value"}
autocomplete={valueAutocomplete?.(pair.name)}
autocompleteFunctions={valueAutocompleteFunctions}
autocompleteVariables={valueAutocompleteVariablesFiltered}
showOptionsPicker={showOptionsPicker}
/>
)}
</div>
</div>
{allowFileValues ? (
<FileActionsDropdown
pair={pair}
onChangeFile={handleChangeValueFile}
onChangeText={handleChangeValueText}
onChangeContentType={handleChangeValueContentType}
onChangeFilename={handleChangeValueFilename}
onDelete={handleDelete}
editMultiLine={handleEditMultiLineValue}
/>
) : (
<Dropdown items={defaultItems}>
<IconButton
iconSize="sm"
size="xs"
// Ellipsis rather than a chevron: the name and value fields now carry chevrons for
// picking a suggestion, and this menu acts on the row instead of filling in a field.
icon={isLast || disabled ? "empty" : "ellipsis_vertical"}
title="More actions"
className="text-text-subtlest"
/>
</Dropdown>
)}
</div>
);
}
const fileItems: RadioDropdownItem<string>[] = [
{ label: "Text", value: "text" },
{ label: "File", value: "file" },
];
function FileActionsDropdown({
pair,
onChangeFile,
onChangeText,
onChangeContentType,
onChangeFilename,
onDelete,
editMultiLine,
}: {
pair: Pair;
onChangeFile: ({ filePath }: { filePath: string | null }) => void;
onChangeText: (text: string) => void;
onChangeContentType: (contentType: string) => void;
onChangeFilename: (filename: string) => void;
onDelete: () => void;
editMultiLine: () => void;
}) {
const onChange = useCallback(
(v: string) => {
if (v === "file") onChangeFile({ filePath: "" });
else onChangeText("");
},
[onChangeFile, onChangeText],
);
const itemsAfter = useMemo<DropdownItem[]>(
() => [
{
label: "Edit Multi-Line",
leftSlot: <Icon icon="file_code" />,
hidden: pair.isFile,
onSelect: editMultiLine,
},
{
label: "Set Content-Type",
leftSlot: <Icon icon="pencil" />,
onSelect: async () => {
const contentType = await showPrompt({
id: "content-type",
title: "Override Content-Type",
label: "Content-Type",
required: false,
placeholder: "text/plain",
defaultValue: pair.contentType ?? "",
confirmText: "Set",
description: "Leave blank to auto-detect",
});
if (contentType == null) return;
onChangeContentType(contentType);
},
},
{
label: "Set File Name",
leftSlot: <Icon icon="file_code" />,
onSelect: async () => {
console.log("PAIR", pair);
const defaultFilename = await platform.files.basename(pair.value ?? "");
const filename = await showPrompt({
id: "filename",
title: "Override Filename",
label: "Filename",
required: false,
placeholder: defaultFilename ?? "myfile.png",
defaultValue: pair.filename,
confirmText: "Set",
description: "Leave blank to use the name of the selected file",
});
if (filename == null) return;
onChangeFilename(filename);
},
},
{
label: "Unset File",
leftSlot: <Icon icon="x" />,
hidden: pair.isFile,
onSelect: async () => {
onChangeFile({ filePath: null });
},
},
{
label: "Delete",
onSelect: onDelete,
variant: "danger",
leftSlot: <Icon icon="trash" />,
color: "danger",
},
],
[
editMultiLine,
onChangeContentType,
onChangeFile,
onDelete,
pair.contentType,
pair.isFile,
onChangeFilename,
pair.filename,
pair,
],
);
return (
<RadioDropdown
value={pair.isFile ? "file" : "text"}
onChange={onChange}
items={fileItems}
itemsAfter={itemsAfter}
>
<IconButton
iconSize="sm"
size="xs"
// Matches the plain row menu, so the actions column reads the same for file and text rows
icon="ellipsis_vertical"
title="Select form data type"
className="text-text-subtlest"
/>
</RadioDropdown>
);
}
function emptyPair(): EditablePairWithId {
return ensurePairId({ enabled: true, name: "", value: "" });
}
function isPairEmpty(pair: Pair): boolean {
return !pair.name && !pair.value;
}
function MultilineEditDialog({
defaultValue,
contentType,
onChange,
hide,
}: {
defaultValue: string;
contentType: string | null;
onChange: (value: string) => void;
hide: () => void;
}) {
const [value, setValue] = useState<string>(defaultValue);
const language = languageFromContentType(contentType, value);
return (
<div className="w-screen max-w-160 h-[50vh] max-h-full grid grid-rows-[minmax(0,1fr)_auto]">
<Editor
heightMode="auto"
defaultValue={defaultValue}
language={language}
onChange={setValue}
stateKey={null}
autocompleteFunctions
autocompleteVariables
/>
<div>
<Button
color="primary"
className="ml-auto my-2"
onClick={() => {
onChange(value);
hide();
}}
>
Done
</Button>
</div>
</div>
);
}
@@ -1 +1,10 @@
export { ensurePairId } from "@yaakapp-internal/ui";
import type { Pair, PairWithId } from "./PairEditor";
import { generateId } from "@yaakapp-internal/lib/generateId";
// NOTE: Generic so callers keep whatever they passed in (eg. an EditablePair stays editable)
export function ensurePairId<T extends Pair>(p: T): T & PairWithId {
if (typeof p.id === "string") {
return p as T & PairWithId;
}
return { ...p, id: p.id ?? generateId() };
}
@@ -1 +1,38 @@
export { PairOrBulkEditor } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useKeyValue } from "../../hooks/useKeyValue";
import { BulkPairEditor } from "./BulkPairEditor";
import { IconButton } from "./IconButton";
import type { PairEditorProps } from "./PairEditor";
import { PairEditor } from "./PairEditor";
interface Props extends PairEditorProps {
preferenceName: string;
forcedEnvironmentId?: string;
}
export function PairOrBulkEditor({ preferenceName, ...props }: Props) {
const { value: useBulk, set: setUseBulk } = useKeyValue<boolean>({
namespace: "global",
key: ["bulk_edit", preferenceName],
fallback: false,
});
return (
<div className="relative h-full w-full group/wrapper">
{useBulk ? <BulkPairEditor {...props} /> : <PairEditor {...props} />}
<div className="absolute right-0 bottom-0">
<IconButton
size="sm"
variant="border"
title={useBulk ? "Enable form edit" : "Enable bulk edit"}
className={classNames(
"transition-opacity opacity-0 group-hover:opacity-80 hover:opacity-100! shadow",
"bg-surface hover:text group-hover/wrapper:opacity-100",
)}
onClick={() => setUseBulk((b) => !b)}
icon={useBulk ? "table" : "file_code"}
/>
</div>
</div>
);
}
@@ -1 +1,14 @@
export { PillButton } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { ButtonProps } from "./Button";
import { Button } from "./Button";
export function PillButton({ className, ...props }: ButtonProps) {
return (
<Button
size="2xs"
variant="border"
className={classNames(className, "rounded-full! mx-1 px-3!")}
{...props}
/>
);
}
@@ -1,2 +1,69 @@
export type { RadioDropdownItem, RadioDropdownProps } from "@yaakapp-internal/ui";
export { RadioDropdown } from "@yaakapp-internal/ui";
import type { ReactNode } from "react";
import { useMemo } from "react";
import type { DropdownItem, DropdownItemSeparator, DropdownProps } from "./Dropdown";
import { Dropdown } from "./Dropdown";
import { Icon } from "@yaakapp-internal/ui";
export type RadioDropdownItem<T = string | null> =
| {
type?: "default";
label: ReactNode;
shortLabel?: ReactNode;
value: T;
rightSlot?: ReactNode;
}
| DropdownItemSeparator;
export interface RadioDropdownProps<T = string | null> {
value: T;
onChange: (value: T) => void;
itemsBefore?: DropdownItem[];
items: RadioDropdownItem<T>[];
itemsAfter?: DropdownItem[];
children: DropdownProps["children"];
}
export function RadioDropdown<T = string | null>({
value,
items,
itemsAfter,
itemsBefore,
onChange,
children,
}: RadioDropdownProps<T>) {
const dropdownItems = useMemo(
() => [
...((itemsBefore
? [
...itemsBefore,
{
type: "separator",
hidden: itemsBefore[itemsBefore.length - 1]?.type === "separator",
},
]
: []) as DropdownItem[]),
...items.map((item) => {
if (item.type === "separator") {
return item;
}
return {
key: item.value,
label: item.label,
rightSlot: item.rightSlot,
onSelect: () => onChange(item.value),
leftSlot: <Icon icon={value === item.value ? "check" : "empty"} />,
} as DropdownItem;
}),
...((itemsAfter
? [{ type: "separator", hidden: itemsAfter[0]?.type === "separator" }, ...itemsAfter]
: []) as DropdownItem[]),
],
[itemsBefore, items, itemsAfter, value, onChange],
);
return (
<Dropdown fullWidth items={dropdownItems}>
{children}
</Dropdown>
);
}
@@ -1,33 +1,19 @@
import { generateId } from "@yaakapp-internal/lib/generateId";
import type { IconProps } from "@yaakapp-internal/ui";
import { HStack, useStateWithDeps, IconTooltip, Label } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { type ReactNode, useRef } from "react";
import { useStateWithDeps } from "../hooks/useStateWithDeps";
import { Button, CONTROL_TOOLTIP_DELAY } from "./Button";
import type { IconProps } from "./Icon";
import { Button } from "./Button";
import { IconButton, type IconButtonProps } from "./IconButton";
import { IconTooltip } from "./IconTooltip";
import { Label } from "./Label";
import { HStack } from "./Stacks";
import { Tooltip } from "./Tooltip";
import { generateId } from "@yaakapp-internal/lib/generateId";
export interface SegmentedControlOption<T extends string> {
value: T;
label: string;
icon?: IconProps["icon"];
/** Shown on hover over a text option, leaving its label as its name */
tooltip?: string;
}
export interface SegmentedControlProps<T extends string> {
options: SegmentedControlOption<T>[];
interface Props<T extends string> {
options: { value: T; label: string; icon?: IconProps["icon"] }[];
onChange: (value: T) => void;
value: T;
name: string;
size?: IconButtonProps["size"];
label: string;
className?: string;
/** Replaces the full width the control takes by default */
containerClassName?: string;
hideLabel?: boolean;
labelClassName?: string;
help?: ReactNode;
@@ -43,8 +29,7 @@ export function SegmentedControl<T extends string>({
labelClassName,
help,
className,
containerClassName = "w-full",
}: SegmentedControlProps<T>) {
}: Props<T>) {
const [selectedValue, setSelectedValue] = useStateWithDeps<T>(value, [value]);
const containerRef = useRef<HTMLDivElement>(null);
const id = useRef(`input-${generateId()}`);
@@ -53,13 +38,8 @@ export function SegmentedControl<T extends string>({
const inlineHelp =
hideLabel && help ? <IconTooltip tabIndex={-1} content={help} iconSize="xs" /> : null;
const focusOption = (index: number) => {
const option = containerRef.current?.querySelectorAll<HTMLElement>('[role="radio"]')[index];
option?.focus();
};
return (
<div className={classNames(containerClassName, "grid")}>
<div className="w-full grid">
<Label
htmlFor={id.current}
help={hideLabel ? undefined : help}
@@ -87,14 +67,16 @@ export function SegmentedControl<T extends string>({
if (options[newIndex]) {
setSelectedValue(options[newIndex].value);
}
focusOption(newIndex);
const child = containerRef.current?.children[newIndex] as HTMLButtonElement;
child.focus();
} else if (e.key === "ArrowLeft") {
e.preventDefault();
const newIndex = Math.abs((selectedIndex - 1) % options.length);
if (options[newIndex]) {
setSelectedValue(options[newIndex].value);
}
focusOption(newIndex);
const child = containerRef.current?.children[newIndex] as HTMLButtonElement;
child.focus();
}
}}
>
@@ -103,7 +85,7 @@ export function SegmentedControl<T extends string>({
const isActive = value === o.value;
const rightSlot = i === options.length - 1 ? inlineHelp : null;
if (o.icon == null) {
const button = (
return (
<Button
key={o.label}
aria-checked={isActive}
@@ -122,13 +104,6 @@ export function SegmentedControl<T extends string>({
{o.label}
</Button>
);
return o.tooltip == null ? (
button
) : (
<Tooltip key={o.label} wrap content={o.tooltip} delay={CONTROL_TOOLTIP_DELAY}>
{button}
</Tooltip>
);
} else {
return (
<IconButton
+148 -2
View File
@@ -1,2 +1,148 @@
export type { SelectProps } from "@yaakapp-internal/ui";
export { Select } from "@yaakapp-internal/ui";
import { platform } from "@yaakapp-internal/platform";
import { HStack, Label } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { CSSProperties, ReactNode } from "react";
import { useState } from "react";
import type { ButtonProps } from "./Button";
import { Button } from "./Button";
import type { RadioDropdownItem } from "./RadioDropdown";
import { RadioDropdown } from "./RadioDropdown";
export interface SelectProps<T extends string> {
name: string;
label: string;
labelPosition?: "top" | "left";
labelClassName?: string;
hideLabel?: boolean;
value: T;
help?: ReactNode;
leftSlot?: ReactNode;
options: RadioDropdownItem<T>[];
onChange: (value: T) => void;
defaultValue?: T;
size?: ButtonProps["size"];
className?: string;
disabled?: boolean;
filterable?: boolean;
}
export function Select<T extends string>({
labelPosition = "top",
name,
help,
labelClassName,
disabled,
hideLabel,
label,
value,
options,
leftSlot,
onChange,
className,
defaultValue,
filterable,
size = "md",
}: SelectProps<T>) {
const [focused, setFocused] = useState<boolean>(false);
const id = `input-${name}`;
const isInvalidSelection = options.find((o) => "value" in o && o.value === value) == null;
const handleChange = (value: T) => {
onChange?.(value);
};
return (
<div
className={classNames(
className,
"x-theme-input",
"w-full",
"pointer-events-auto", // Just in case we're placing in disabled parent
labelPosition === "left" && "grid grid-cols-[auto_1fr] items-center gap-2",
labelPosition === "top" && "flex-row gap-0.5",
)}
>
<Label htmlFor={id} visuallyHidden={hideLabel} className={labelClassName} help={help}>
{label}
</Label>
{platform.osType() === "macos" && !filterable ? (
<HStack
space={2}
className={classNames(
"w-full rounded-md text text-sm font-mono",
"pl-2",
"border",
focused && !disabled ? "border-border-focus" : "border-border",
disabled && "border-dotted",
isInvalidSelection && "border-danger",
size === "xs" && "h-xs",
size === "sm" && "h-sm",
size === "md" && "h-md",
)}
>
{leftSlot && <div>{leftSlot}</div>}
<select
value={value}
style={selectBackgroundStyles}
onChange={(e) => handleChange(e.target.value as T)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
disabled={disabled}
className={classNames(
"pr-7 w-full outline-hidden bg-transparent disabled:opacity-disabled",
"leading-none rounded-none", // Center the text better vertically
)}
>
{isInvalidSelection && <option value={"__NONE__"}>-- Select an Option --</option>}
{options.map((o) => {
if (o.type === "separator") return null;
return (
<option key={o.value} value={o.value}>
{o.label}
{o.value === defaultValue && " (default)"}
</option>
);
})}
</select>
</HStack>
) : (
// Use custom "select" component until Tauri can be configured to have select menus not always appear in
// light mode
<RadioDropdown
value={value}
onChange={handleChange}
items={options.map((o) =>
o.type === "separator" || o.value !== defaultValue
? o
: { ...o, label: <>{o.label} (default)</> },
)}
>
<Button
className={classNames(
"w-full text-sm font-mono",
disabled && "border-dotted",
isInvalidSelection && "border-danger",
)}
justify="start"
variant="input"
size={size}
leftSlot={leftSlot}
disabled={disabled}
forDropdown
>
{options.find((o) => o.type !== "separator" && o.value === value)?.label ?? "--"}
</Button>
</RadioDropdown>
)}
</div>
);
}
const selectBackgroundStyles: CSSProperties = {
backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e")`,
backgroundPosition: "right 0.3rem center",
backgroundRepeat: "no-repeat",
backgroundSize: "1.5em 1.5em",
appearance: "none",
printColorAdjust: "exact",
};
+450 -25
View File
@@ -1,36 +1,260 @@
import type { AnyModel } from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models";
import {
SettingRow,
type SettingRowBaseProps,
SettingRowBoolean,
SettingRowNumber,
SettingRowSelect,
SettingRowText,
SettingSelectControl,
} from "@yaakapp-internal/ui";
import { Icon } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { ReactNode } from "react";
import { CopyIconButton } from "../CopyIconButton";
import { Checkbox } from "./Checkbox";
import { IconButton, type IconButtonProps } from "./IconButton";
import { PlainInput } from "./PlainInput";
import type { RadioDropdownItem } from "./RadioDropdown";
import { Select } from "./Select";
import { SelectFile } from "../SelectFile";
export type { SettingRowBaseProps, SettingSnippetLanguage } from "@yaakapp-internal/ui";
export {
SettingCommand,
SettingOverrideRow,
SettingRow,
SettingRowBoolean,
SettingRowNumber,
SettingRowSelect,
SettingRowText,
SettingSelectControl,
SettingSnippet,
SettingsList,
SettingsSection,
SettingValue,
} from "@yaakapp-internal/ui";
type ModelKeyOfValue<T, V> = {
[K in keyof T]-?: T[K] extends V ? K : never;
}[keyof T];
type SettingRowBaseProps = {
className?: string;
controlClassName?: string;
description?: ReactNode;
disabled?: boolean;
title: ReactNode;
};
export function SettingsList({ children, className }: { children: ReactNode; className?: string }) {
return <div className={classNames("w-full", className)}>{children}</div>;
}
export function SettingsSection({
children,
className,
description,
title,
}: {
children: ReactNode;
className?: string;
description?: ReactNode;
title: ReactNode | null;
}) {
const showHeader = title != null || description != null;
return (
<section className={classNames(className, "w-full")}>
{showHeader && (
<div className="border-b border-border-subtle pb-2">
{title != null && <div className="text-text-subtle">{title}</div>}
{description != null && <p className="mt-1 text-sm text-text-subtlest">{description}</p>}
</div>
)}
<div className="[&>*:last-child]:border-b-0">{children}</div>
</section>
);
}
export function SettingRow({
children,
className,
controlClassName,
description,
disabled,
title,
}: {
children: ReactNode;
} & SettingRowBaseProps) {
return (
<div
aria-disabled={disabled || undefined}
className={classNames(
className,
"@container border-b border-border-subtle py-4",
disabled && "opacity-disabled",
)}
>
<div
className={classNames(
"grid grid-cols-1 gap-2",
"@[30rem]:grid-cols-[minmax(0,1fr)_auto] items-center",
)}
>
<div className="min-w-0">
<div className="text text-text">{title}</div>
{description != null && (
<div className="mt-1 max-w-2xl text-sm text-text-subtle">{description}</div>
)}
</div>
<div
className={classNames(
"flex min-w-0 items-center justify-start @[40rem]:justify-end",
controlClassName,
)}
>
{children}
</div>
</div>
</div>
);
}
export function SettingValue({
actions,
className,
copyText,
enableCopy = true,
value,
}: {
actions?: SettingValueAction[];
className?: string;
copyText?: string;
enableCopy?: boolean;
value: ReactNode;
}) {
const textValue = typeof value === "string" || typeof value === "number" ? `${value}` : null;
const textToCopy = copyText ?? textValue;
return (
<>
<span
className={classNames(
className,
"cursor-text select-text truncate font-mono text-editor text-text-subtle pr-1.5",
)}
>
{value}
</span>
{actions?.map((action) => (
<IconButton
key={action.title}
icon={action.icon}
title={action.title}
size="2xs"
iconSize="sm"
onClick={action.onClick}
/>
))}
{enableCopy && textToCopy != null && (
<CopyIconButton size="2xs" text={textToCopy} title="Copy value" />
)}
</>
);
}
/**
* A command or config to copy, set as a block: its title, an optional line
* saying which file it goes in, then the snippet at full width with the copy
* button on it. One-line shell commands and multi-line JSON get the same block.
*/
export function SettingCommand({
className,
description,
disabled,
done,
language,
snippet,
title,
}: {
description?: ReactNode;
disabled?: boolean;
/** A short word shown with a check beside the title once the step is detected as done */
done?: ReactNode;
language: SettingSnippetLanguage;
snippet: string;
title: ReactNode;
} & Pick<SettingRowBaseProps, "className">) {
return (
<div
aria-disabled={disabled || undefined}
className={classNames(
className,
"border-b border-border-subtle py-4",
disabled && "opacity-disabled",
)}
>
<div className="flex min-w-0 items-center gap-2 text text-text">
<span>{title}</span>
{done != null && done !== false && (
<span className="inline-flex items-center gap-1 text-sm text-success">
<Icon icon="check" size="sm" />
{done}
</span>
)}
</div>
{description != null && (
<div className="mt-1 max-w-2xl text-sm text-text-subtle">{description}</div>
)}
<SettingSnippet className="mt-3" language={language} snippet={snippet} />
</div>
);
}
export type SettingSnippetLanguage = "shell" | "json" | "text";
/** The snippet half of a `SettingCommand`, for places with their own title, like a banner. */
export function SettingSnippet({
className,
language,
snippet,
}: {
className?: string;
language: SettingSnippetLanguage;
snippet: string;
}) {
const prompt = language === "shell";
return (
<div
className={classNames(
className,
"flex items-start gap-2 rounded-md border border-border-subtle bg-surface-highlight/40 py-1.5 pr-1.5 pl-3",
)}
>
<div className="flex min-h-sm min-w-0 flex-1 items-center font-mono text-editor text-text">
<span className="cursor-text select-text whitespace-pre-wrap [overflow-wrap:anywhere]">
{prompt && (
<span aria-hidden className="mr-2 select-none text-text-subtlest">
$
</span>
)}
{snippet}
</span>
</div>
<CopyIconButton size="sm" variant="border" title="Copy" text={snippet} className="shrink-0" />
</div>
);
}
type SettingValueAction = {
icon: IconButtonProps["icon"];
onClick: () => void;
title: string;
};
export function SettingRowBoolean({
checked,
checkboxSize = "md",
onChange,
title,
...props
}: {
checked: boolean;
checkboxSize?: "sm" | "md";
onChange: (checked: boolean) => void;
} & SettingRowBaseProps) {
return (
<SettingRow title={title} {...props}>
<Checkbox
hideLabel
size={checkboxSize}
checked={checked}
disabled={props.disabled}
title={title}
onChange={onChange}
/>
</SettingRow>
);
}
export function ModelSettingRowBoolean<M extends AnyModel, K extends ModelKeyOfValue<M, boolean>>({
model,
modelKey,
@@ -48,6 +272,50 @@ export function ModelSettingRowBoolean<M extends AnyModel, K extends ModelKeyOfV
);
}
export function SettingRowNumber({
inputClassName,
inputWidthClassName = "w-48!",
name,
onChange,
placeholder,
required,
title,
type = "number",
validate,
value,
...props
}: {
inputClassName?: string;
inputWidthClassName?: string;
name: string;
onChange: (value: number) => void;
placeholder?: string;
required?: boolean;
type?: "number";
validate?: (value: string) => boolean;
value: number;
} & SettingRowBaseProps) {
return (
<SettingRow title={title} {...props}>
<PlainInput
required={required}
hideLabel
size="sm"
name={name}
label={typeof title === "string" ? title : name}
placeholder={placeholder}
defaultValue={`${value}`}
validate={validate}
onChange={(value) => onChange(Number.parseInt(value, 10) || 0)}
type={type}
className={inputClassName}
containerClassName={inputWidthClassName}
disabled={props.disabled}
/>
</SettingRow>
);
}
export function ModelSettingRowNumber<M extends AnyModel, K extends ModelKeyOfValue<M, number>>({
model,
modelKey,
@@ -66,6 +334,47 @@ export function ModelSettingRowNumber<M extends AnyModel, K extends ModelKeyOfVa
);
}
export function SettingRowText({
inputClassName,
inputWidthClassName = "w-80!",
name,
onChange,
placeholder,
required,
title,
type = "text",
value,
...props
}: {
inputClassName?: string;
inputWidthClassName?: string;
name: string;
onChange: (value: string) => void;
placeholder?: string;
required?: boolean;
type?: "text" | "password";
value: string;
} & SettingRowBaseProps) {
return (
<SettingRow title={title} {...props}>
<PlainInput
required={required}
hideLabel
size="sm"
name={name}
label={typeof title === "string" ? title : name}
placeholder={placeholder}
defaultValue={value}
onChange={onChange}
type={type}
className={inputClassName}
containerClassName={inputWidthClassName}
disabled={props.disabled}
/>
</SettingRow>
);
}
export function ModelSettingRowText<M extends AnyModel, K extends ModelKeyOfValue<M, string>>({
model,
modelKey,
@@ -129,6 +438,74 @@ export function SettingRowDirectory({
return <SettingRowFile directory noun={noun} {...props} />;
}
export function SettingRowSelect<T extends string>({
defaultValue,
name,
onChange,
options,
selectClassName = "w-48!",
title,
value,
...props
}: {
defaultValue?: T;
name: string;
onChange: (value: T) => void;
options: RadioDropdownItem<T>[];
selectClassName?: string;
value: T;
} & SettingRowBaseProps) {
return (
<SettingRow title={title} {...props}>
<SettingSelectControl
name={name}
label={typeof title === "string" ? title : name}
value={value}
defaultValue={defaultValue}
selectClassName={selectClassName}
disabled={props.disabled}
onChange={onChange}
options={options}
/>
</SettingRow>
);
}
export function SettingSelectControl<T extends string>({
defaultValue,
disabled,
label,
name,
onChange,
options,
selectClassName = "w-48!",
value,
}: {
defaultValue?: T;
disabled?: boolean;
label: string;
name: string;
onChange: (value: T) => void;
options: RadioDropdownItem<T>[];
selectClassName?: string;
value: T;
}) {
return (
<Select
hideLabel
name={name}
value={value}
defaultValue={defaultValue}
label={label}
size="sm"
className={selectClassName}
disabled={disabled}
onChange={onChange}
options={options}
/>
);
}
export function ModelSettingSelectControl<
M extends AnyModel,
K extends ModelKeyOfValue<M, string>,
@@ -172,3 +549,51 @@ export function ModelSettingRowSelect<
/>
);
}
export function SettingOverrideRow({
children,
className,
controlClassName,
description,
disabled,
onResetOverride,
overridden,
resetTitle = "Reset override",
title,
}: {
children: ReactNode;
className?: string;
controlClassName?: string;
description?: ReactNode;
disabled?: boolean;
onResetOverride: () => void;
overridden: boolean;
resetTitle?: string;
title: ReactNode;
}) {
return (
<SettingRow
className={className}
controlClassName={controlClassName}
description={description}
disabled={disabled}
title={
<span className="inline-flex items-center gap-1.5">
{title}
{overridden && (
<IconButton
icon="undo_2"
size="2xs"
iconSize="sm"
title={resetTitle}
className="text-text-subtle"
onClick={onResetOverride}
/>
)}
</span>
}
>
{children}
</SettingRow>
);
}
+81 -10
View File
@@ -1,28 +1,99 @@
import {
Icon,
type StoredValue,
TabContent as BaseTabContent,
type TabContentProps,
type TabItem,
type TabItem as BaseTabItem,
Tabs as BaseTabs,
type TabsProps,
type TabsProps as BaseTabsProps,
type TabsRef,
type TabsStorage,
} from "@yaakapp-internal/ui";
import { forwardRef, memo } from "react";
import classNames from "classnames";
import type { ReactNode } from "react";
import { forwardRef, memo, useMemo } from "react";
import { useKeyValue } from "../../../hooks/useKeyValue";
import { ErrorBoundary } from "../../ErrorBoundary";
import { Button } from "../Button";
import type { RadioDropdownProps } from "../RadioDropdown";
import { RadioDropdown } from "../RadioDropdown";
export type { TabItem, TabsRef };
export type { TabsRef };
// The client's EditorHostProvider keeps tab order and the active tab in its key-value store
export const Tabs = forwardRef<TabsRef, Omit<TabsProps, "useStoredValue">>(
function Tabs(props, ref) {
return <BaseTabs ref={ref} {...props} />;
},
);
export type TabItem =
| Extract<BaseTabItem, { label: string }>
| {
value: string;
options: Omit<RadioDropdownProps, "children">;
leftSlot?: ReactNode;
rightSlot?: ReactNode;
};
type Props = Omit<BaseTabsProps, "tabs" | "useStoredValue"> & { tabs: TabItem[] };
export const Tabs = forwardRef<TabsRef, Props>(function Tabs({ tabs, ...props }, ref) {
const baseTabs = useMemo(() => tabs.map(toBaseTab), [tabs]);
return <BaseTabs ref={ref} tabs={baseTabs} useStoredValue={useTabsStoredValue} {...props} />;
});
export const TabContent = memo(function TabContent(props: Omit<TabContentProps, "ErrorBoundary">) {
return <BaseTabContent {...props} ErrorBoundary={ErrorBoundary} />;
});
function toBaseTab(tab: TabItem): BaseTabItem {
if (!("options" in tab)) return tab;
const { options } = tab;
return {
value: tab.value,
leftSlot: tab.leftSlot,
rightSlot: tab.rightSlot,
renderButton: ({ buttonProps, isActive }) => {
const option = options.items.find((i) => "value" in i && i.value === options.value);
return (
<RadioDropdown
key={tab.value}
items={options.items}
itemsAfter={options.itemsAfter}
itemsBefore={options.itemsBefore}
value={options.value}
onChange={options.onChange}
>
<Button
leftSlot={tab.leftSlot}
rightSlot={
<div className="flex items-center">
{tab.rightSlot}
<Icon
size="sm"
icon="chevron_down"
className={classNames(
"ml-1",
isActive ? "text-text-subtle" : "text-text-subtlest",
)}
/>
</div>
}
{...buttonProps}
>
{option && "shortLabel" in option && option.shortLabel
? option.shortLabel
: (option?.label ?? "Unknown")}
</Button>
</RadioDropdown>
);
},
};
}
function useTabsStoredValue<T extends StoredValue>(key: string | string[] | null, fallback: T) {
const { value, set } = useKeyValue<T>({
namespace: "no_sync",
key: key ?? ["tabs", "default"],
fallback,
});
return [value ?? fallback, set] as const;
}
/**
* Programmatically set the active tab for a Tabs component that uses storageKey + activeTabKey.
* This is useful when you need to change the tab from outside the component (e.g., in response to an event).
@@ -1,15 +1,6 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { extractSseValueAtPath, type ServerSentEvent } from "@yaakapp-internal/sse";
import {
EventStreamViewer as SharedEventStreamViewer,
HStack,
Icon,
InlineCode,
SplitLayout,
type StreamEvent,
VStack,
IconTooltip,
} from "@yaakapp-internal/ui";
import { HStack, Icon, InlineCode, SplitLayout, VStack, IconTooltip } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { CSSProperties, ReactNode } from "react";
import { Fragment, useMemo, useState } from "react";
@@ -29,7 +20,8 @@ import type { DropdownItem } from "../core/Dropdown";
import { Dropdown } from "../core/Dropdown";
import type { EditorProps } from "../core/Editor/Editor";
import { Editor } from "../core/Editor/LazyEditor";
import { EventDetailHeader } from "../core/EventViewer";
import { EventDetailHeader, EventViewer } from "../core/EventViewer";
import { EventViewerRow } from "../core/EventViewerRow";
import { IconButton } from "../core/IconButton";
import { Input } from "../core/Input";
import { Select } from "../core/Select";
@@ -70,7 +62,6 @@ function ActualEventStreamViewer({ response }: Props) {
});
const summarySettings = useSseSummaryResultKeyPath({ response });
const events = useResponseBodyEventSource(response);
const streamEvents = useMemo(() => (events.data ?? []).map(toStreamEvent), [events.data]);
const summary = useResponseBodySseSummary(response, summarySettings.resultKeyPath);
const showExtractedText = summarySettings.resultKeyPath != null;
const showResultKeyPathWarning =
@@ -114,7 +105,10 @@ function ActualEventStreamViewer({ response }: Props) {
),
},
],
[applyToDetailsSetting, filterEventPreviewsSetting],
[
applyToDetailsSetting,
filterEventPreviewsSetting,
],
);
return (
@@ -181,21 +175,30 @@ function ActualEventStreamViewer({ response }: Props) {
resizeHandleClassName="hover:bg-surface-highlight active:bg-surface-highlight"
firstSlot={({ style }) => (
<div style={style} className="min-h-0">
<SharedEventStreamViewer
events={streamEvents}
<EventViewer
events={events.data ?? []}
getEventKey={(_, index) => String(index)}
error={events.error ? String(events.error) : null}
splitLayoutStorageKey="sse_events"
defaultRatio={0.4}
getPreview={(event) =>
getEventPreview(
sseEventOf(events.data, event),
summarySettings.resultKeyPath,
filterEventPreviews,
)
}
renderRow={({ event, index, isActive, onClick }) => (
<EventViewerRow
isActive={isActive}
onClick={onClick}
icon={<Icon color="info" title="Server Message" icon="arrow_big_down_dash" />}
content={
<HStack space={2} className="items-center">
<EventLabels event={event} index={index} isActive={isActive} />
<span className="truncate text-xs">
{getEventPreview(event, summarySettings.resultKeyPath, filterEventPreviews)}
</span>
</HStack>
}
/>
)}
renderDetail={({ event, index, onClose }) => (
<EventDetail
event={sseEventOf(events.data, event)}
event={event}
index={index}
applyJsonPath={applyToDetails}
resultKeyPath={summarySettings.resultKeyPath}
@@ -309,23 +312,6 @@ function SseSummaryFooter({
);
}
/** An SSE event as the shared viewer lists it: received, with its id (or place) and type */
function toStreamEvent(event: ServerSentEvent, index: number): StreamEvent {
return {
key: String(index),
direction: "received",
kind: event.eventType || null,
labels: [String(event.id ?? index), ...(event.eventType ? [event.eventType] : [])],
timestamp: null,
size: null,
preview: event.data,
};
}
function sseEventOf(events: ServerSentEvent[] | undefined, event: StreamEvent): ServerSentEvent {
return events?.[Number(event.key)] ?? { data: "", eventType: "", id: null, retry: null };
}
function getEventPreview(
event: ServerSentEvent,
resultKeyPath: string | null,
@@ -0,0 +1,118 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { useQueryClient } from "@tanstack/react-query";
import { languageFromContentType, type TextFilterApi, WebPageViewer } from "@yaakapp-internal/ui";
import type { CodeEditorProps } from "@yaakapp-internal/ui/editor";
import { useCallback } from "react";
import { useCopyHttpResponse } from "../../hooks/useCopyHttpResponse";
import { useRecentFilters } from "../../hooks/useRecentFilters";
import { responseBodyTextQuery, useResponseBodyText } from "../../hooks/useResponseBodyText";
import { useSaveResponse } from "../../hooks/useSaveResponse";
import { getContentTypeFromHeaders } from "../../lib/model_util";
import { IconButton } from "../core/IconButton";
import { EmptyStateText } from "../EmptyStateText";
import { RecentFiltersDropdown } from "./RecentFiltersDropdown";
import { TextViewer } from "./TextViewer";
interface Props {
response: HttpResponse;
pretty: boolean;
textViewerClassName?: string;
}
export function HTMLOrTextViewer({ response, pretty, textViewerClassName }: Props) {
const rawTextBody = useResponseBodyText({ response, filter: null });
const contentType = getContentTypeFromHeaders(response.headers);
const language = languageFromContentType(contentType, rawTextBody.data ?? "");
if (rawTextBody.isLoading || response.state === "initialized") {
return null;
}
if (language === "html" && pretty) {
return <WebPageViewer html={rawTextBody.data ?? ""} baseUrl={response.url} />;
}
if (rawTextBody.data == null) {
return <EmptyStateText>Empty response</EmptyStateText>;
}
return (
<HttpTextViewer
response={response}
text={rawTextBody.data}
language={language}
pretty={pretty}
className={textViewerClassName}
/>
);
}
interface HttpTextViewerProps {
response: HttpResponse;
text: string;
language: CodeEditorProps["language"];
pretty: boolean;
className?: string;
}
function HttpTextViewer({ response, text, language, pretty, className }: HttpTextViewerProps) {
const queryClient = useQueryClient();
const filterStateKey = `response.body.${response.requestId}`;
// Shares the response body query's cache, so applying and showing a filter cost one RPC
const filterText = useCallback(
(f: string) => queryClient.fetchQuery(responseBodyTextQuery({ response, filter: f })),
[queryClient, response],
);
const { recentFilters, addFilter, removeFilter, togglePin, clearFilters } =
useRecentFilters(filterStateKey);
const filterLeftSlot = useCallback(
(filter: TextFilterApi) => (
<div className="py-0.5 flex">
<RecentFiltersDropdown
recentFilters={recentFilters}
activeFilter={filter.appliedFilter}
onSelect={filter.replaceFilter}
onRemove={removeFilter}
onTogglePin={togglePin}
onClear={clearFilters}
/>
</div>
),
[recentFilters, removeFilter, togglePin, clearFilters],
);
const saveResponse = useSaveResponse(response);
const copyResponse = useCopyHttpResponse(response);
const actionsDisabled = response.state !== "closed" && response.status >= 100;
return (
<TextViewer
text={text}
language={language}
stateKey={`response.body.${response.id}`}
pretty={pretty}
className={className}
footerActions={[
<IconButton
key="save"
size="sm"
icon="save"
title="Save response to file"
disabled={actionsDisabled}
onClick={() => saveResponse.mutate()}
className="border !border-border-subtle"
/>,
<IconButton
key="copy"
size="sm"
icon="copy"
title="Copy response body"
disabled={actionsDisabled}
onClick={() => copyResponse.mutate()}
className="border !border-border-subtle"
/>,
]}
filterText={filterText}
filterStateKey={filterStateKey}
onFilterApplied={addFilter}
filterLeftSlot={filterLeftSlot}
/>
);
}
-1
View File
@@ -243,7 +243,6 @@ export function useHotkeyContextValue(): HotkeyContextValue {
() => ({
os: platform.osType(),
bindingFor: (action: HotkeyAction) => hotkeys[action] ?? null,
labelFor: (action: HotkeyAction) => hotkeyLabels[action],
register: registerHotkey,
}),
[hotkeys],
@@ -1,64 +0,0 @@
import { useQueryClient } from "@tanstack/react-query";
import type { HttpResponse } from "@yaakapp-internal/models";
import type { HttpBodyLoader, HttpBodyResult } from "@yaakapp-internal/ui";
import { useMemo } from "react";
import { getResponseBodyText } from "../lib/responseBody";
import { getRequestBodyText, useHttpRequestBody } from "./useHttpRequestBody";
import {
responseBodyTextQuery,
useResponseBodyBytes,
useResponseBodyText,
} from "./useResponseBodyText";
import { useResponseBodyUrl } from "./useResponseBodyUrl";
interface QueryLike<T> {
data: T | undefined;
isLoading: boolean;
error: Error | null;
}
function result<T>({ data, isLoading, error }: QueryLike<T>): HttpBodyResult<T> {
return { data, isLoading, error };
}
const noUrl: HttpBodyResult<string | null> = { data: null, isLoading: false, error: null };
/** Reads a response's stored body the ways the shared viewers ask for it */
export function useResponseBodyLoader(response: HttpResponse | null): HttpBodyLoader | null {
const queryClient = useQueryClient();
return useMemo(() => {
if (response == null) return null;
return {
useText: () => result(useResponseBodyText({ response, filter: null })),
useBytes: () => result(useResponseBodyBytes({ response })),
useUrl: (enabled: boolean) => {
// Waiting on a URL that isn't asked for yet still counts as loading
const url = useResponseBodyUrl(enabled ? response : null);
return { data: url.data, isLoading: url.isPending, error: url.error };
},
// Shares the response body query's cache, so applying and showing a filter cost one RPC
filterText: (filter: string) =>
queryClient.fetchQuery(responseBodyTextQuery({ response, filter })),
getText: () => getResponseBodyText({ response, filter: null }),
};
}, [queryClient, response]);
}
/** Reads the body a response's request was sent with */
export function useRequestBodyLoader(response: HttpResponse | null): HttpBodyLoader | null {
return useMemo(() => {
if (response == null) return null;
return {
useText: () => {
const body = useHttpRequestBody(response);
return { ...result(body), data: body.data?.bodyText ?? null };
},
useBytes: () => {
const body = useHttpRequestBody(response);
return { ...result(body), data: body.data?.body ?? null };
},
useUrl: () => noUrl,
getText: () => getRequestBodyText(response).then((d) => d?.bodyText ?? ""),
};
}, [response]);
}
+1 -3
View File
@@ -1,7 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import type { HttpResponse } from "@yaakapp-internal/models";
import { decodeBodyText } from "@yaakapp-internal/lib/decodeBodyText";
import { getContentTypeFromHeaders } from "../lib/model_util";
import { rpc } from "../lib/rpc";
export function useHttpRequestBody(response: HttpResponse | null) {
@@ -29,6 +27,6 @@ export async function getRequestBodyText(response: HttpResponse | null) {
}
const body = new Uint8Array(data);
const bodyText = decodeBodyText(body, getContentTypeFromHeaders(response.requestHeaders));
const bodyText = new TextDecoder("utf-8", { fatal: false }).decode(body);
return { body, bodyText };
}
@@ -1,133 +0,0 @@
// @vitest-environment happy-dom
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { RequestVersionComparison } from "@yaakapp-internal/models";
import { atom, getDefaultStore } from "jotai";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, test, vi } from "vite-plus/test";
const mocks = vi.hoisted(() => ({
rpc: vi.fn<(cmd: string, args: { versionId: string }) => Promise<unknown>>(),
}));
vi.mock("../lib/rpc", () => ({ rpc: mocks.rpc }));
vi.mock("./useAllRequests", async () => {
const { atom } = await import("jotai");
return { allRequestsAtom: atom([{ id: "rq_1", updatedAt: "t1" }]) };
});
function comparison(versionId: string): RequestVersionComparison {
return {
version: { id: versionId, modelId: "rq_1" },
currentDocument: {},
differs: true,
} as unknown as RequestVersionComparison;
}
/** Each version's comparison resolves only when the test says so. */
function deferredRpc() {
const pending = new Map<string, () => void>();
mocks.rpc.mockImplementation(
(_cmd, { versionId }) =>
new Promise((resolve) => pending.set(versionId, () => resolve(comparison(versionId)))),
);
return async (versionId: string) => {
await vi.waitFor(() => expect(pending.has(versionId)).toBe(true));
await act(async () => pending.get(versionId)?.());
};
}
describe("useRequestVersion", () => {
let container: HTMLDivElement;
let root: Root;
let seen: (RequestVersionComparison | null)[];
let useRequestVersion: typeof import("./useRequestVersion").useRequestVersion;
let allRequestsAtom: ReturnType<typeof atom<{ id: string; updatedAt: string }[]>>;
const queryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false } } });
let client: QueryClient;
function Probe({ versionId }: { versionId: string | null }) {
seen.push(useRequestVersion(versionId, "rq_1"));
return null;
}
async function show(versionId: string | null) {
await act(async () =>
root.render(
<QueryClientProvider client={client}>
<Probe versionId={versionId} />
</QueryClientProvider>,
),
);
}
const current = () => seen[seen.length - 1];
/** Wait for the comparison for a version to reach the component. */
async function answered(versionId: string) {
await vi.waitFor(() => expect(current()?.version.id).toBe(versionId));
}
beforeEach(async () => {
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
({ useRequestVersion } = await import("./useRequestVersion"));
({ allRequestsAtom } = (await import("./useAllRequests")) as unknown as {
allRequestsAtom: typeof allRequestsAtom;
});
getDefaultStore().set(allRequestsAtom, [{ id: "rq_1", updatedAt: "t1" }]);
client = queryClient();
seen = [];
container = document.createElement("div");
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
client.clear();
mocks.rpc.mockReset();
});
test("a response with no version has no comparison, even right after one that had", async () => {
const resolve = deferredRpc();
await show("mv_a");
await resolve("mv_a");
await answered("mv_a");
seen = [];
await show(null);
expect(seen.length).toBeGreaterThan(0);
expect(seen.every((c) => c == null)).toBe(true);
});
test("switching versions never shows the previous version's comparison", async () => {
const resolve = deferredRpc();
await show("mv_a");
await resolve("mv_a");
await answered("mv_a");
seen = [];
await show("mv_b");
// Still loading: nothing, rather than mv_a's answer
expect(seen.length).toBeGreaterThan(0);
expect(seen.every((c) => c == null)).toBe(true);
await resolve("mv_b");
await answered("mv_b");
});
test("an edit to the live request keeps the answer while it refetches", async () => {
const resolve = deferredRpc();
await show("mv_a");
await resolve("mv_a");
await answered("mv_a");
seen = [];
await act(async () =>
getDefaultStore().set(allRequestsAtom, [{ id: "rq_1", updatedAt: "t2" }]),
);
// The refetch is pending, but the dropdown does not flicker away meanwhile
expect(seen.length).toBeGreaterThan(0);
expect(seen.every((c) => c?.version.id === "mv_a")).toBe(true);
expect(mocks.rpc).toHaveBeenCalledTimes(2);
});
});
@@ -1,37 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import type { RequestVersionComparison } from "@yaakapp-internal/models";
import { useAtomValue } from "jotai";
import { allRequestsAtom } from "./useAllRequests";
import { rpc } from "../lib/rpc";
/**
* The request version a response was sent from, alongside the request as it
* stands now.
*
* Refetches when the live request is written, which is what keeps the "has this
* changed?" answer honest while someone edits. The comparison itself is the
* backend's — the frontend never hashes anything.
*
* Only ever answers for the version asked about: a response with no version
* gets nothing, and switching responses never shows the last one's comparison
* while the new one loads.
*/
export function useRequestVersion(
versionId: string | null | undefined,
requestId: string | null,
): RequestVersionComparison | null {
const requests = useAtomValue(allRequestsAtom);
const liveUpdatedAt = requests.find((r) => r.id === requestId)?.updatedAt;
const { data } = useQuery({
// Keep the last answer across edits to the live request, but never across
// versions, or a response would borrow another response's comparison
placeholderData: (prev, prevQuery) => (prevQuery?.queryKey[1] === versionId ? prev : undefined),
queryKey: ["request_version", versionId, liveUpdatedAt],
enabled: versionId != null,
queryFn: () =>
rpc<RequestVersionComparison>("models_request_version", { versionId: versionId! }),
});
return versionId != null && data?.version.id === versionId ? data : null;
}
@@ -1,9 +1,4 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vite-plus/test";
// Loaded once as the file is collected (the mocks below are hoisted above it), so the first
// transform of its module graph, the shared UI package among it, is not timed against whichever
// test imports it first. Each test still gets the module fresh after `vi.resetModules()`, which
// reuses that transform.
import "./useSendAnyHttpRequest";
const mocks = vi.hoisted(() => ({
platform: { httpProxyUrl: "https://web.yaak.test" as string | null },
-76
View File
@@ -1,76 +0,0 @@
import { useFonts } from "@yaakapp-internal/fonts";
import { useLicense } from "@yaakapp-internal/license";
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import { platform } from "@yaakapp-internal/platform";
import type { SettingsCallbacks, SettingsView } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { openExternalUrl } from "../components/core/Link";
import { appInfo } from "../lib/appInfo";
import { pricingUrl } from "../lib/pricingUrl";
import { revealInFinderText } from "../lib/reveal";
import { rpc } from "../lib/rpc";
import {
licenseStatusFromCheck,
settingsValuesFromModel,
themesViewFromResolved,
} from "../lib/settingsView";
import { useCheckForUpdates } from "./useCheckForUpdates";
import { useResolvedAppearance } from "./useResolvedAppearance";
import { useResolvedTheme } from "./useResolvedTheme";
/** Binds the shared settings tabs to the client's settings model, themes, fonts and license */
export function useSettingsView(): { view: SettingsView; callbacks: SettingsCallbacks } | null {
const settings = useAtomValue(settingsAtom);
const appearance = useResolvedAppearance();
const activeTheme = useResolvedTheme();
const fonts = useFonts();
const { check, activate, deactivate } = useLicense();
const checkForUpdates = useCheckForUpdates();
if (settings == null) {
return null;
}
const view: SettingsView = {
app: {
name: "Yaak",
version: appInfo.version,
dataDir: appInfo.appDataDir,
logDir: appInfo.appLogDir,
revealPathLabel: revealInFinderText,
},
values: settingsValuesFromModel(settings),
themes: themesViewFromResolved(activeTheme.data, appearance),
fonts: fonts.data ?? null,
windowControlsSetting: platform.osType() !== "macos",
updates: appInfo.featureUpdater ? { checking: checkForUpdates.isPending } : null,
feedbackPrompts: appInfo.featureLicense,
license: appInfo.featureLicense
? {
pending: check.isPending,
status: licenseStatusFromCheck(check.data),
checkError: check.error ?? null,
activateError: activate.error ?? null,
activating: activate.isPending,
}
: null,
};
const callbacks: SettingsCallbacks = {
onChange: async (patch) => {
await patchModel(settings, patch);
},
onApplyNativeTitlebar: async (useNativeTitlebar) => {
await patchModel(settings, { useNativeTitlebar });
await rpc("cmd_restart");
},
onCheckForUpdates: () => checkForUpdates.mutate(),
onActivateLicense: (licenseKey) => activate.mutateAsync({ licenseKey }),
onDeactivateLicense: () => deactivate.mutate(),
onOpenUrl: openExternalUrl,
onRevealPath: (path) => platform.revealItemInDir(path),
pricingUrl,
};
return { view, callbacks };
}
+7 -2
View File
@@ -1,2 +1,7 @@
// From its own entry rather than the package index, so tests that mock the index keep it
export { useToggle } from "@yaakapp-internal/ui/useToggle";
import { useCallback, useState } from "react";
export function useToggle(initialValue = false) {
const [value, setValue] = useState<boolean>(initialValue);
const toggle = useCallback(() => setValue((v) => !v), []);
return [value, toggle, setValue] as const;
}
@@ -1,11 +1,6 @@
import { renderToStaticMarkup } from "react-dom/server";
import { afterEach, beforeEach, describe, expect, test, vi } from "vite-plus/test";
import type { DialogInstance } from "../components/Dialogs";
// Loaded once as the file is collected (the mocks below are hoisted above it), so the first
// transform of its module graph, the shared UI package among it, is not timed against whichever
// test imports it first. Each test still gets the module fresh after `vi.resetModules()`, which
// reuses that transform.
import "./confirmWebProxy";
const mocks = vi.hoisted(() => ({
platform: { httpProxyUrl: "https://web.yaak.app" },
+44 -1
View File
@@ -1 +1,44 @@
export { getMimeTypeFromContentType, isJSON, isProbablyTextContentType } from "@yaakapp-internal/ui/mimeType";
import { MIMEType } from "whatwg-mimetype";
export function isJSON(content: string | null | undefined): boolean {
if (typeof content !== "string") return false;
try {
JSON.parse(content);
return true;
} catch {
return false;
}
}
export function isProbablyTextContentType(contentType: string | null): boolean {
if (contentType == null) return false;
const mimeType = getMimeTypeFromContentType(contentType).essence;
const normalized = mimeType.toLowerCase();
// Check if it starts with "text/"
if (normalized.startsWith("text/")) {
return true;
}
// Common text mimetypes and suffixes
return [
"application/json",
"application/xml",
"application/javascript",
"application/yaml",
"+json",
"+xml",
"+yaml",
"+text",
].some((textType) => normalized === textType || normalized.endsWith(textType));
}
export function getMimeTypeFromContentType(contentType: string): MIMEType {
try {
return new MIMEType(contentType);
} catch {
return new MIMEType("text/plain");
}
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,122 +0,0 @@
import type { ModelVersionReason } from "@yaakapp-internal/models";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { EditSessionTracker } from "./editSessionTracker";
type Capture = [requestId: string, reason: ModelVersionReason];
function tracker(idleMs = 1000) {
const captured: Capture[] = [];
return {
captured,
tracker: new EditSessionTracker((id, reason) => captured.push([id, reason]), idleMs),
};
}
describe("EditSessionTracker", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
test("captures a request once it has been left alone", () => {
const { tracker: t, captured } = tracker();
t.noteEdit("rq_1");
vi.advanceTimersByTime(999);
expect(captured).toEqual([]);
vi.advanceTimersByTime(1);
expect(captured).toEqual([["rq_1", "idle"]]);
});
test("a burst of edits is one capture, not one per keystroke", () => {
const { tracker: t, captured } = tracker();
for (let i = 0; i < 10; i++) {
t.noteEdit("rq_1");
vi.advanceTimersByTime(500);
}
expect(captured).toEqual([]);
vi.advanceTimersByTime(1000);
expect(captured).toEqual([["rq_1", "idle"]]);
});
test("each request keeps its own countdown when two are edited in turn", () => {
const { tracker: t, captured } = tracker();
t.noteEdit("rq_a");
vi.advanceTimersByTime(600);
// An edit to B, from this window, another one, or sync, must not touch A's countdown
t.noteEdit("rq_b");
vi.advanceTimersByTime(400);
expect(captured).toEqual([["rq_a", "idle"]]);
// B keeps writing; A is done and stays done
t.noteEdit("rq_b");
vi.advanceTimersByTime(999);
expect(captured).toEqual([["rq_a", "idle"]]);
vi.advanceTimersByTime(1);
expect(captured).toEqual([
["rq_a", "idle"],
["rq_b", "idle"],
]);
});
test("a boundary capture settles that request's countdown, and only that one", () => {
const { tracker: t, captured } = tracker();
t.noteActiveRequest("rq_a");
t.noteEdit("rq_a");
t.noteEdit("rq_b");
t.noteActiveRequest("rq_c");
expect(captured).toEqual([["rq_a", "switch"]]);
vi.advanceTimersByTime(1000);
// A was already captured when it was left; B's countdown still ran
expect(captured).toEqual([
["rq_a", "switch"],
["rq_b", "idle"],
]);
});
test("a close can bring its own snapshot", () => {
const { tracker: t, captured } = tracker();
const onClose: Capture[] = [];
t.noteActiveRequest("rq_1");
t.noteEdit("rq_1");
t.noteBoundary((id, reason) => onClose.push([id, reason]));
vi.advanceTimersByTime(1000);
expect(onClose).toEqual([["rq_1", "switch"]]);
expect(captured).toEqual([]);
});
test("captures the request being left, not the one being opened", () => {
const { tracker: t, captured } = tracker();
t.noteActiveRequest("rq_1");
expect(captured).toEqual([]);
t.noteActiveRequest("rq_2");
expect(captured).toEqual([["rq_1", "switch"]]);
});
test("re-selecting the same request is not a boundary", () => {
const { tracker: t, captured } = tracker();
t.noteActiveRequest("rq_1");
t.noteActiveRequest("rq_1");
expect(captured).toEqual([]);
});
test("blur and close capture the request still on screen", () => {
const { tracker: t, captured } = tracker();
t.noteActiveRequest("rq_1");
t.noteBoundary();
t.noteBoundary();
expect(captured).toEqual([
["rq_1", "switch"],
["rq_1", "switch"],
]);
});
test("nothing is captured before a request is open", () => {
const { tracker: t, captured } = tracker();
t.noteBoundary();
t.noteActiveRequest(null);
expect(captured).toEqual([]);
});
});
@@ -1,78 +0,0 @@
import type { ModelVersionReason } from "@yaakapp-internal/models";
/**
* How long a request has to sit untouched before its edits become a version.
* Long enough that typing a URL is one version rather than forty, short enough
* that walking away from a half-finished edit still records it.
*/
export const IDLE_MS = 60_000;
type Snapshot = (requestId: string, reason: ModelVersionReason) => void;
/**
* When a request's editing session ends.
*
* The backend versions a request on every send, which covers "what produced
* this response". This covers the rest: an edit someone made and then walked
* away from, which no send would ever have captured.
*
* It deliberately knows nothing about *what* changed. Versions are
* content-addressed, so a boundary that turns out to have nothing behind it
* costs one query and creates nothing — which is what lets this stay a few
* timers instead of a change-tracking system.
*/
export class EditSessionTracker {
/**
* One idle countdown per request. Writes arrive for any request — from other
* windows and sync too — and an edit to one must never restart or cancel
* another's countdown.
*/
private timers = new Map<string, ReturnType<typeof setTimeout>>();
private activeRequestId: string | null = null;
constructor(
private readonly snapshot: Snapshot,
private readonly idleMs: number = IDLE_MS,
) {}
/** A request was written. Restarts that request's idle countdown. */
noteEdit(requestId: string) {
this.clearTimer(requestId);
this.timers.set(
requestId,
setTimeout(() => {
this.timers.delete(requestId);
this.snapshot(requestId, "idle");
}, this.idleMs),
);
}
/** The user moved to a different request, so the one they left is finished. */
noteActiveRequest(requestId: string | null) {
if (requestId === this.activeRequestId) return;
const left = this.activeRequestId;
this.activeRequestId = requestId;
if (left != null) this.capture(left, this.snapshot);
}
/**
* The window lost focus or is closing. A close can't wait for anything, so it
* passes its own snapshot function.
*/
noteBoundary(snapshot: Snapshot = this.snapshot) {
if (this.activeRequestId != null) this.capture(this.activeRequestId, snapshot);
}
/** Capture at a boundary, which also settles any idle countdown still pending */
private capture(requestId: string, snapshot: Snapshot) {
this.clearTimer(requestId);
snapshot(requestId, "switch");
}
private clearTimer(requestId: string) {
const timer = this.timers.get(requestId);
if (timer == null) return;
clearTimeout(timer);
this.timers.delete(requestId);
}
}
-83
View File
@@ -1,83 +0,0 @@
import type { HttpRequest, HttpResponse, HttpResponseEvent } from "@yaakapp-internal/models";
import { describe, expect, test } from "vite-plus/test";
import { cookieHeadersFromEvents, httpRequestToView, httpResponseToView } from "./httpViews";
function event(e: HttpResponseEvent["event"]): HttpResponseEvent {
return { event: e } as HttpResponseEvent;
}
describe("httpRequestToView", () => {
test("maps the request and reads its content type from the headers", () => {
const request = {
id: "rq_1",
method: "POST",
url: "https://yaak.app",
headers: [{ name: "content-type", value: "application/json", enabled: true }],
urlParameters: [{ name: "a", value: "1", enabled: true }],
bodyType: "application/json",
body: { text: "{}" },
} as unknown as HttpRequest;
expect(httpRequestToView(request)).toEqual({
id: "rq_1",
method: "POST",
url: "https://yaak.app",
headers: request.headers,
urlParameters: request.urlParameters,
bodyType: "application/json",
body: { text: "{}" },
contentType: "application/json",
auth: null,
});
});
});
describe("httpResponseToView", () => {
test("keys state to the request and puts the stored time in UTC", () => {
const response = {
id: "rs_1",
requestId: "rq_1",
state: "closed",
status: 200,
statusReason: "OK",
url: "https://yaak.app",
headers: [{ name: "Content-Type", value: "text/plain" }],
requestHeaders: [],
contentLength: 10,
contentLengthCompressed: 4,
requestContentLength: 0,
createdAt: "2026-11-04T22:10:00.123",
elapsed: 30,
elapsedHeaders: 20,
elapsedDns: 5,
error: null,
remoteAddr: null,
version: "HTTP/1.1",
} as unknown as HttpResponse;
expect(httpResponseToView(response, undefined)).toMatchObject({
id: "rs_1",
ownerId: "rq_1",
contentType: "text/plain",
startedAt: "2026-11-04T22:10:00.123Z",
elapsedMs: 30,
elapsedHeadersMs: 20,
elapsedDnsMs: 5,
contentLengthCompressed: 4,
cookieHeaders: { sent: [], received: [] },
});
});
});
describe("cookieHeadersFromEvents", () => {
test("collects Cookie headers sent and Set-Cookie headers received", () => {
const events = [
event({ type: "header_up", name: "Cookie", value: "a=1" }),
event({ type: "header_up", name: "Accept", value: "*/*" }),
event({ type: "header_down", name: "set-cookie", value: "b=2" }),
event({ type: "header_down", name: "Content-Type", value: "text/plain" }),
];
expect(cookieHeadersFromEvents(events)).toEqual({ sent: ["a=1"], received: ["b=2"] });
expect(cookieHeadersFromEvents(undefined)).toEqual({ sent: [], received: [] });
});
});
-64
View File
@@ -1,64 +0,0 @@
import type { HttpRequest, HttpResponse, HttpResponseEvent } from "@yaakapp-internal/models";
import type { HttpRequestView, HttpResponseView } from "@yaakapp-internal/ui";
import { getContentTypeFromHeaders } from "./model_util";
/** A request as the shared request pane shows it. Auth stays with the client's own editor */
export function httpRequestToView(request: HttpRequest): HttpRequestView {
return {
id: request.id,
method: request.method,
url: request.url,
headers: request.headers,
urlParameters: request.urlParameters,
bodyType: request.bodyType,
body: request.body,
contentType: getContentTypeFromHeaders(request.headers),
auth: null,
};
}
/** A response as the shared response pane shows it, with its cookies from the send's events */
export function httpResponseToView(
response: HttpResponse,
events: HttpResponseEvent[] | undefined,
): HttpResponseView {
return {
id: response.id,
ownerId: response.requestId,
state: response.state,
status: response.status,
statusReason: response.statusReason,
url: response.url,
headers: response.headers,
requestHeaders: response.requestHeaders,
contentType: getContentTypeFromHeaders(response.headers),
contentLength: response.contentLength,
contentLengthCompressed: response.contentLengthCompressed,
requestContentLength: response.requestContentLength,
// Stored as UTC without a zone
startedAt: `${response.createdAt}Z`,
elapsedMs: response.elapsed,
elapsedHeadersMs: response.elapsedHeaders,
elapsedDnsMs: response.elapsedDns,
error: response.error,
remoteAddr: response.remoteAddr,
version: response.version,
cookieHeaders: cookieHeadersFromEvents(events),
};
}
/** The `Cookie` headers sent and `Set-Cookie` headers received, from a send's events */
export function cookieHeadersFromEvents(
events: HttpResponseEvent[] | undefined,
): HttpResponseView["cookieHeaders"] {
const sent: string[] = [];
const received: string[] = [];
for (const { event } of events ?? []) {
if (event.type === "header_up" && event.name.toLowerCase() === "cookie") {
sent.push(event.value);
} else if (event.type === "header_down" && event.name.toLowerCase() === "set-cookie") {
received.push(event.value);
}
}
return { sent, received };
}
File renamed without changes.
+17 -10
View File
@@ -5,17 +5,16 @@ import type {
HttpResponseEvent,
HttpResponseHeader,
} from "@yaakapp-internal/models";
import { getMimeTypeFromContentType } from "./contentType";
export {
BODY_TYPE_BINARY,
BODY_TYPE_FORM_MULTIPART,
BODY_TYPE_FORM_URLENCODED,
BODY_TYPE_GRAPHQL,
BODY_TYPE_JSON,
BODY_TYPE_NONE,
BODY_TYPE_OTHER,
BODY_TYPE_XML,
} from "@yaakapp-internal/ui/httpTypes";
export const BODY_TYPE_NONE = null;
export const BODY_TYPE_GRAPHQL = "graphql";
export const BODY_TYPE_JSON = "application/json";
export const BODY_TYPE_BINARY = "binary";
export const BODY_TYPE_OTHER = "other";
export const BODY_TYPE_FORM_URLENCODED = "application/x-www-form-urlencoded";
export const BODY_TYPE_FORM_MULTIPART = "multipart/form-data";
export const BODY_TYPE_XML = "text/xml";
export function cookieDomain(cookie: Cookie): string {
if (cookie.domain === "NotPresent" || cookie.domain === "Empty") {
@@ -47,6 +46,14 @@ export function getContentTypeFromHeaders(headers: HttpResponseHeader[] | null):
return headers?.find((h) => h.name.toLowerCase() === "content-type")?.value ?? null;
}
export function getCharsetFromContentType(headers: HttpResponseHeader[]): string | null {
const contentType = getContentTypeFromHeaders(headers);
if (contentType == null) return null;
const mimeType = getMimeTypeFromContentType(contentType);
return mimeType.parameters.get("charset") ?? null;
}
export function isBaseEnvironment(environment: Environment): boolean {
return environment.parentModel === "workspace";
}
@@ -1,60 +0,0 @@
import { beforeEach, describe, expect, test, vi } from "vite-plus/test";
const mocks = vi.hoisted(() => ({
calls: [] as string[],
finishWrites: null as (() => void) | null,
flushAllModelWrites: vi.fn(),
rpc: vi.fn(),
}));
vi.mock("@yaakapp-internal/models", () => ({
flushAllModelWrites: mocks.flushAllModelWrites,
flushAllPendingPatches: vi.fn(),
}));
vi.mock("@yaakapp-internal/platform", () => ({ platform: {} }));
vi.mock("../hooks/useActiveRequestId", () => ({ activeRequestIdAtom: null }));
vi.mock("./jotai", () => ({ jotaiStore: {} }));
vi.mock("./rpc", () => ({ rpc: mocks.rpc }));
describe("snapshotRequestVersion", () => {
beforeEach(() => {
mocks.calls = [];
mocks.flushAllModelWrites.mockImplementation(
() =>
new Promise<void>((resolve) => {
mocks.calls.push("flush");
mocks.finishWrites = () => {
mocks.calls.push("writes landed");
resolve();
};
}),
);
mocks.rpc.mockImplementation(async (cmd: string) => {
mocks.calls.push(cmd);
return { id: "mv_1" };
});
});
test("asks for the snapshot only once pending writes have landed", async () => {
const { snapshotRequestVersion } = await import("./requestVersions");
snapshotRequestVersion("rq_1", "switch");
await Promise.resolve();
expect(mocks.calls).toEqual(["flush"]);
mocks.finishWrites?.();
await vi.waitFor(() =>
expect(mocks.calls).toEqual(["flush", "writes landed", "models_snapshot_request"]),
);
expect(mocks.rpc).toHaveBeenCalledWith("models_snapshot_request", {
requestId: "rq_1",
reason: "switch",
});
});
test("a failed write still lets the snapshot go ahead", async () => {
mocks.flushAllModelWrites.mockRejectedValue(new Error("write failed"));
const { snapshotRequestVersion } = await import("./requestVersions");
snapshotRequestVersion("rq_1", "idle");
await vi.waitFor(() => expect(mocks.rpc).toHaveBeenCalledTimes(1));
});
});
-75
View File
@@ -1,75 +0,0 @@
import { flushAllModelWrites, flushAllPendingPatches } from "@yaakapp-internal/models";
import type { ModelPayload, ModelVersion, ModelVersionReason } from "@yaakapp-internal/models";
import { platform } from "@yaakapp-internal/platform";
import { EditSessionTracker } from "./editSessionTracker";
import { activeRequestIdAtom } from "../hooks/useActiveRequestId";
import { jotaiStore } from "./jotai";
import { rpc } from "./rpc";
const REQUEST_MODELS = ["http_request", "grpc_request", "websocket_request"];
/**
* Ask the backend to capture a request's current content.
*
* Quiet by design: a version that fails to write is not worth a toast, because
* every caller below is reacting to the user leaving rather than asking for
* anything.
*/
export function snapshotRequestVersion(requestId: string, reason: ModelVersionReason) {
captureRequestVersion(requestId, reason).catch((err: unknown) => {
console.warn("Failed to snapshot request version", err);
});
}
async function captureRequestVersion(requestId: string, reason: ModelVersionReason) {
// Edits reach the database on a debounce. Wait until every pending write has
// landed, or the snapshot can read the row before the edit this boundary is
// meant to capture. A write that failed leaves nothing newer to wait for.
await flushAllModelWrites().catch((err: unknown) => {
console.warn("A pending write failed before a request snapshot", err);
});
await rpc<ModelVersion>("models_snapshot_request", { requestId, reason });
}
/**
* The same, for a window that is closing, where nothing can be awaited.
*
* The pending writes go out first and the snapshot right behind them. In the
* browser that is enough: the worker runs commands in the order they arrive.
* On the desktop each command runs on its own, so the snapshot can still reach
* the database before the last write does. The edit itself is saved either
* way, and the request's next send versions it.
*/
function snapshotRequestVersionOnClose(requestId: string, reason: ModelVersionReason) {
flushAllPendingPatches();
rpc<ModelVersion>("models_snapshot_request", { requestId, reason }).catch((err: unknown) => {
console.warn("Failed to snapshot request version", err);
});
}
export function initRequestVersionSnapshots() {
const tracker = new EditSessionTracker(snapshotRequestVersion);
platform.listen<ModelPayload[]>("model_writes", (payloads) => {
for (const payload of payloads) {
if (payload.change.type !== "upsert") continue;
if (!REQUEST_MODELS.includes(payload.model.model)) continue;
tracker.noteEdit(payload.model.id);
}
});
tracker.noteActiveRequest(jotaiStore.get(activeRequestIdAtom));
jotaiStore.sub(activeRequestIdAtom, () => {
tracker.noteActiveRequest(jotaiStore.get(activeRequestIdAtom));
});
platform.window.onFocusChanged((focused) => {
if (!focused) tracker.noteBoundary();
});
// Closing is the last boundary there is. Nothing can be awaited here, but the
// write is already on its way and the backend outlives the window.
window.addEventListener("beforeunload", () =>
tracker.noteBoundary(snapshotRequestVersionOnClose),
);
}
+1 -4
View File
@@ -3,9 +3,7 @@ import type { FilterResponse } from "@yaakapp-internal/plugins";
import type { ServerSentEvent, SseSummary } from "@yaakapp-internal/sse";
import { candidateJsonPayloadsFromSseText, computeSseSummary } from "@yaakapp-internal/sse";
import { rpc } from "./rpc";
import { decodeBodyText } from "@yaakapp-internal/lib/decodeBodyText";
import { platform } from "@yaakapp-internal/platform";
import { getContentTypeFromHeaders } from "./model_util";
/**
* Reading a response body means naming the response, never the file it lives
@@ -76,6 +74,5 @@ export async function getResponseBodyBytes(
async function getResponseBodyDecoded(response: HttpResponse): Promise<string | null> {
const bytes = await getResponseBodyBytes(response);
if (bytes == null) return null;
return decodeBodyText(bytes, getContentTypeFromHeaders(response.headers));
return bytes == null ? null : new TextDecoder("utf-8").decode(bytes);
}
@@ -1,54 +0,0 @@
import type { ModelVersion } from "@yaakapp-internal/models";
import { beforeEach, describe, expect, test, vi } from "vite-plus/test";
const mocks = vi.hoisted(() => ({
calls: [] as string[],
flushAllModelWrites: vi.fn(),
mergeModelsInStore: vi.fn(),
wasUpdatedExternally: vi.fn(),
rpc: vi.fn(),
}));
vi.mock("@yaakapp-internal/models", () => ({
flushAllModelWrites: mocks.flushAllModelWrites,
mergeModelsInStore: mocks.mergeModelsInStore,
}));
vi.mock("../hooks/useRequestUpdateKey", () => ({
wasUpdatedExternally: mocks.wasUpdatedExternally,
}));
vi.mock("./rpc", () => ({ rpc: mocks.rpc }));
vi.mock("./toast", () => ({ showToast: vi.fn() }));
const restored = { model: "http_request", id: "rq_1", url: "https://example.com/one" };
const version = { id: "mv_1", modelId: "rq_1" } as ModelVersion;
describe("applyRequestVersion", () => {
beforeEach(() => {
mocks.calls = [];
mocks.flushAllModelWrites.mockImplementation(async () => mocks.calls.push("flush"));
mocks.rpc.mockImplementation(async (cmd: string) => {
mocks.calls.push(cmd);
return restored;
});
mocks.mergeModelsInStore.mockImplementation(() => mocks.calls.push("store"));
mocks.wasUpdatedExternally.mockImplementation(() => mocks.calls.push("editors"));
});
test("puts the restored request in the store before editors re-read it", async () => {
const { applyRequestVersion } = await import("./restoreRequestVersion");
await applyRequestVersion(version);
expect(mocks.calls).toEqual(["flush", "models_restore_request_version", "store", "editors"]);
expect(mocks.mergeModelsInStore).toHaveBeenCalledWith("http_request", [restored]);
expect(mocks.wasUpdatedExternally).toHaveBeenCalledWith("rq_1");
});
test("a failed restore leaves the store and editors alone", async () => {
mocks.rpc.mockRejectedValue(new Error("no such version"));
const { applyRequestVersion } = await import("./restoreRequestVersion");
await expect(applyRequestVersion(version)).rejects.toThrow("no such version");
expect(mocks.mergeModelsInStore).not.toHaveBeenCalled();
expect(mocks.wasUpdatedExternally).not.toHaveBeenCalled();
});
});
@@ -1,44 +0,0 @@
import { flushAllModelWrites, mergeModelsInStore } from "@yaakapp-internal/models";
import type { AnyModel, ModelVersion } from "@yaakapp-internal/models";
import { wasUpdatedExternally } from "../hooks/useRequestUpdateKey";
import { fireAndForget } from "./fireAndForget";
import { rpc } from "./rpc";
import { showToast } from "./toast";
/**
* Put an old version's content back into the live request.
*
* The backend captures whatever the request currently holds before
* overwriting it, so this is not a destructive action even when the last edit
* was never versioned — but the request is still rewritten under the user's
* cursor, so it is announced.
*/
export function restoreRequestVersion(version: ModelVersion) {
fireAndForget(
(async () => {
await applyRequestVersion(version);
showToast({
id: "request-version-restored",
color: "success",
message: "Restored the request that produced this response",
});
})(),
);
}
/** The restore itself, in the order the editors need it. */
export async function applyRequestVersion(version: ModelVersion): Promise<void> {
// The backend restores from the database, so anything still sitting in a
// debounce has to land first — otherwise it would overwrite the restore
await flushAllModelWrites();
const restored = await rpc<AnyModel>("models_restore_request_version", {
versionId: version.id,
});
// Editors re-read from the store when told to, and the change event can
// arrive after this resolves, so put the restored request there first. The
// event re-applying it later changes nothing.
mergeModelsInStore(restored.model, [restored]);
// The write came from this window, so the store's echo suppression would
// otherwise leave open editors showing what was there before
wasUpdatedExternally(restored.id);
}
-126
View File
@@ -1,126 +0,0 @@
import type { LicenseCheckStatus } from "@yaakapp-internal/license";
import type { Settings } from "@yaakapp-internal/models";
import { describe, expect, test } from "vite-plus/test";
import {
licenseStatusFromCheck,
settingsValuesFromModel,
themesViewFromResolved,
} from "./settingsView";
describe("settingsValuesFromModel", () => {
test("picks the shared tabs' values and nothing client-only", () => {
const settings = {
model: "settings",
id: "st_1",
appearance: "dark",
themeLight: "yaak-light",
themeDark: "hotdog",
interfaceFont: "Inter",
interfaceFontSize: 15,
editorFont: null,
editorFontSize: 13,
editorKeymap: "emacs",
editorSoftWrap: false,
coloredMethods: true,
useNativeTitlebar: true,
hideWindowControls: true,
updateChannel: "beta",
autoupdate: false,
autoDownloadUpdates: false,
checkNotifications: false,
promptFeedback: false,
openWorkspaceNewWindow: true,
hideLicenseBadge: true,
clientCertificates: [],
} as unknown as Settings;
expect(settingsValuesFromModel(settings)).toEqual({
appearance: "dark",
themeLight: "yaak-light",
themeDark: "hotdog",
interfaceFont: "Inter",
interfaceFontSize: 15,
editorFont: null,
editorFontSize: 13,
editorKeymap: "emacs",
editorSoftWrap: false,
coloredMethods: true,
useNativeTitlebar: true,
hideWindowControls: true,
updateChannel: "beta",
autoupdate: false,
autoDownloadUpdates: false,
checkNotifications: false,
promptFeedback: false,
});
});
});
describe("licenseStatusFromCheck", () => {
test("is null without a check", () => {
expect(licenseStatusFromCheck(null)).toBeNull();
expect(licenseStatusFromCheck(undefined)).toBeNull();
});
test("keeps what each state's copy needs", () => {
const cases: [LicenseCheckStatus, unknown][] = [
[
{ status: "active", data: { periodEnd: "2027-01-01", cancelAt: null } },
{ status: "active" },
],
[
{ status: "trialing", data: { end: "2026-11-20" } },
{ status: "trialing", trialEnd: "2026-11-20" },
],
[{ status: "personal_use", data: { trial_ended: "2026-01-01" } }, { status: "personal_use" }],
[{ status: "inactive", data: { status: "canceled" } }, { status: "inactive" }],
[
{
status: "expired",
data: {
changes: 3,
changesUrl: "https://yaak.app/changes",
billingUrl: "https://yaak.app/billing",
periodEnd: "2026-03-04",
},
},
{ status: "expired", periodEnd: "2026-03-04", changesUrl: "https://yaak.app/changes" },
],
[
{ status: "past_due", data: { billingUrl: "https://yaak.app/billing", periodEnd: "x" } },
{ status: "past_due", billingUrl: "https://yaak.app/billing" },
],
[
{ status: "error", data: { message: "Boom", code: "E1" } },
{ status: "error", message: "Boom", code: "E1" },
],
];
for (const [check, expected] of cases) {
expect(licenseStatusFromCheck(check)).toEqual(expected);
}
});
});
describe("themesViewFromResolved", () => {
const light = { id: "yaak-light", label: "Yaak", dark: false, base: {} };
const dark = { id: "yaak-dark", label: "Yaak", dark: true, base: {} };
test("is null while the themes load", () => {
expect(themesViewFromResolved(undefined, "dark")).toBeNull();
});
test("keeps each theme's id, label and darkness, and the resolved appearance", () => {
expect(
themesViewFromResolved({ themes: [dark, light], light, dark, active: dark }, "dark"),
).toEqual({
themes: [
{ id: "yaak-dark", label: "Yaak", dark: true },
{ id: "yaak-light", label: "Yaak", dark: false },
],
light: { id: "yaak-light", label: "Yaak", dark: false },
dark: { id: "yaak-dark", label: "Yaak", dark: true },
active: { id: "yaak-dark", label: "Yaak", dark: true },
resolvedAppearance: "dark",
});
});
});
-84
View File
@@ -1,84 +0,0 @@
import type { LicenseCheckStatus } from "@yaakapp-internal/license";
import type { Settings } from "@yaakapp-internal/models";
import type {
SettingsLicenseStatus,
SettingsThemeOption,
SettingsThemesView,
SettingsValues,
} from "@yaakapp-internal/ui";
/** The values the shared settings tabs read, picked off the client's `Settings` model */
export function settingsValuesFromModel(settings: Settings): SettingsValues {
return {
appearance: settings.appearance,
themeLight: settings.themeLight,
themeDark: settings.themeDark,
interfaceFont: settings.interfaceFont,
interfaceFontSize: settings.interfaceFontSize,
editorFont: settings.editorFont,
editorFontSize: settings.editorFontSize,
editorKeymap: settings.editorKeymap,
editorSoftWrap: settings.editorSoftWrap,
coloredMethods: settings.coloredMethods,
useNativeTitlebar: settings.useNativeTitlebar,
hideWindowControls: settings.hideWindowControls,
updateChannel: settings.updateChannel,
autoupdate: settings.autoupdate,
autoDownloadUpdates: settings.autoDownloadUpdates,
checkNotifications: settings.checkNotifications,
promptFeedback: settings.promptFeedback,
};
}
/** A license check, as the shared License tab's copy needs it */
export function licenseStatusFromCheck(
check: LicenseCheckStatus | null | undefined,
): SettingsLicenseStatus | null {
if (check == null) return null;
switch (check.status) {
case "active":
return { status: "active" };
case "trialing":
return { status: "trialing", trialEnd: check.data.end };
case "personal_use":
return { status: "personal_use" };
case "inactive":
return { status: "inactive" };
case "expired":
return {
status: "expired",
periodEnd: check.data.periodEnd,
changesUrl: check.data.changesUrl,
};
case "past_due":
return { status: "past_due", billingUrl: check.data.billingUrl };
case "error":
return { status: "error", message: check.data.message, code: check.data.code };
}
}
/** The fields of a theme the settings show */
interface ThemeLike {
id: string;
label: string;
dark: boolean;
}
function themeOption({ id, label, dark }: ThemeLike): SettingsThemeOption {
return { id, label, dark };
}
/** The resolved themes, as the shared Theme tab shows them; null while they load */
export function themesViewFromResolved(
data: { themes: ThemeLike[]; light: ThemeLike; dark: ThemeLike; active: ThemeLike } | undefined,
resolvedAppearance: "light" | "dark",
): SettingsThemesView | null {
if (data == null) return null;
return {
themes: data.themes.map(themeOption),
light: themeOption(data.light),
dark: themeOption(data.dark),
active: themeOption(data.active),
resolvedAppearance,
};
}
-2
View File
@@ -8,7 +8,6 @@ import { createRoot } from "react-dom/client";
import { initGit } from "./init/git";
import { initSync } from "./init/sync";
import { initGlobalListeners } from "./lib/initGlobalListeners";
import { initRequestVersionSnapshots } from "./lib/requestVersions";
import { jotaiStore } from "./lib/jotai";
import { router } from "./lib/router";
@@ -37,7 +36,6 @@ initGit();
initSync();
initModelStore(jotaiStore);
initGlobalListeners();
initRequestVersionSnapshots();
await changeModelStoreWorkspace(null); // Load global models
console.log("Creating React root");
+1
View File
@@ -48,6 +48,7 @@
"focus-trap-react": "^12.0.3",
"fuzzbunny": "^1.0.1",
"graphql": "^17.0.2",
"hexy": "^0.4.0",
"history": "^5.3.0",
"jotai": "^2.18.0",
"jotai-family": "^1.0.1",
Loaded 100 of 447 files, more files were not shown because too many files have changed in this diff. Show more