mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
Compare commits
100
Commits
No files matched your search
@@ -5,3 +5,4 @@ crates/yaak-templates/pkg/* linguist-generated=true
|
||||
|
||||
# Ensure consistent line endings for test files that check exact content
|
||||
crates/yaak-http/tests/test.txt text eol=lf
|
||||
crates-proxy/yaak-proxy-lib/tests/fixtures/export.har text eol=lf
|
||||
@@ -2,10 +2,24 @@ name: Release App Artifacts
|
||||
on:
|
||||
push:
|
||||
tags: [v*]
|
||||
# The Yaak Proxy dry run only: builds, signs and notarizes the proxy for the chosen ref and
|
||||
# keeps the results as workflow artifacts. It never builds Yaak, releases, tags or publishes.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
proxy_dry_run:
|
||||
description: "Dry-run the Yaak Proxy build (artifacts only; nothing is released)"
|
||||
type: boolean
|
||||
required: true
|
||||
default: true
|
||||
proxy_version:
|
||||
description: "Version to stamp into the dry-run build"
|
||||
type: string
|
||||
required: true
|
||||
default: "0.0.1-dryrun"
|
||||
|
||||
jobs:
|
||||
build-artifacts:
|
||||
if: github.repository == 'mountain-loop/yaak'
|
||||
if: github.repository == 'mountain-loop/yaak' && github.event_name == 'push'
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -290,3 +304,178 @@ jobs:
|
||||
path: ${{ runner.temp }}/yaak-release-artifacts/
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
|
||||
# Yaak Proxy, macOS only, on the same tag and version as Yaak. Its own job, sharing nothing
|
||||
# with Yaak's, so a proxy failure never stops Yaak's artifacts; it fails the run, visibly.
|
||||
#
|
||||
# On a v* tag it runs only once the repository variable RELEASE_PROXY is "true": the website's
|
||||
# download links pick Yaak's assets by suffix and architecture, and its release audit expects
|
||||
# Yaak's exact asset set and this run's success, so the proxy must not join Yaak's release
|
||||
# until the website expects it there. The dry run (workflow_dispatch) runs regardless, and
|
||||
# only keeps workflow artifacts.
|
||||
build-proxy:
|
||||
if: >-
|
||||
github.repository == 'mountain-loop/yaak' && (
|
||||
(github.event_name == 'push' && vars.RELEASE_PROXY == 'true') ||
|
||||
(github.event_name == 'workflow_dispatch' && inputs.proxy_dry_run)
|
||||
)
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
name: Build Yaak Proxy
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- target: "aarch64-apple-darwin" # for Arm-based Macs (M1 and above).
|
||||
yaak_arch: "arm64"
|
||||
lipo_arch: "arm64"
|
||||
asset_arch: "aarch64"
|
||||
- target: "x86_64-apple-darwin" # for Intel-based Macs.
|
||||
yaak_arch: "x64"
|
||||
lipo_arch: "x86_64"
|
||||
asset_arch: "x64"
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 240
|
||||
env:
|
||||
DRY_RUN: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
# The tag, as Yaak's version is taken, or the dry run's own
|
||||
PROXY_VERSION: ${{ github.event_name == 'workflow_dispatch' && inputs.proxy_version || github.ref_name }}
|
||||
steps:
|
||||
- name: Checkout yaakapp/app
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Vite+
|
||||
uses: voidzero-dev/setup-vp@v1
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: true
|
||||
|
||||
- name: install Rust stable
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
# A cache of its own, so the proxy's builds never change what Yaak's jobs restore
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: release-proxy
|
||||
key: ${{ matrix.target }}
|
||||
cache-on-failure: true
|
||||
|
||||
- name: Install Protoc for plugin-runtime
|
||||
uses: arduino/setup-protoc@v3
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- run: vp install
|
||||
|
||||
- name: Set version
|
||||
run: npm run replace-version -- proxy
|
||||
env:
|
||||
YAAK_VERSION: ${{ env.PROXY_VERSION }}
|
||||
|
||||
- name: Import signing certificate
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
KEYCHAIN_PATH="$RUNNER_TEMP/app-signing.keychain-db"
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
||||
|
||||
echo "$APPLE_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/certificate.p12"
|
||||
security import "$RUNNER_TEMP/certificate.p12" -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
|
||||
rm "$RUNNER_TEMP/certificate.p12"
|
||||
# Lets codesign use the key unattended; without it the first signature can wait on a
|
||||
# prompt nobody answers
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" > /dev/null
|
||||
security list-keychain -d user -s "$KEYCHAIN_PATH"
|
||||
|
||||
# Builds the helper for the target and signs it and protoc before bundling (the bundle
|
||||
# config's before-build step), then signs, notarizes and staples the app and makes the
|
||||
# updater archive and its signature.
|
||||
- name: Build, sign and notarize
|
||||
run: >-
|
||||
npm run proxy:bundle --
|
||||
--target ${{ matrix.target }}
|
||||
--bundles dmg
|
||||
--config crates-tauri/yaak-app-proxy/tauri.release.conf.json
|
||||
env:
|
||||
CI: true
|
||||
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
# The helper only answers an app signed by this team
|
||||
YAAK_PROXY_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
|
||||
- name: Verify the bundle
|
||||
run: >-
|
||||
node scripts/verify-proxy-bundle.mjs
|
||||
"target/${{ matrix.target }}/release/bundle/macos/Yaak Proxy.app"
|
||||
--signed
|
||||
--version "$PROXY_VERSION"
|
||||
--arch ${{ matrix.lipo_arch }}
|
||||
--team "$YAAK_PROXY_TEAM_ID"
|
||||
env:
|
||||
YAAK_PROXY_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
|
||||
# Named Yaak.Proxy_*, so neither people nor tools take them for Yaak's Yaak_* assets.
|
||||
# The app refuses an update not named so (ARTIFACT_PREFIX in yaak-app-proxy's updates.rs).
|
||||
- name: Collect the release assets
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bundle="target/${{ matrix.target }}/release/bundle"
|
||||
version="${PROXY_VERSION#v}"
|
||||
out="$RUNNER_TEMP/yaak-proxy-release"
|
||||
mkdir -p "$out"
|
||||
dmgs=("$bundle"/dmg/*.dmg)
|
||||
if [ "${#dmgs[@]}" -ne 1 ] || [ ! -f "${dmgs[0]}" ]; then
|
||||
echo "Expected one .dmg in $bundle/dmg"; exit 1
|
||||
fi
|
||||
cp "${dmgs[0]}" "$out/Yaak.Proxy_${version}_${{ matrix.asset_arch }}.dmg"
|
||||
cp "$bundle/macos/Yaak Proxy.app.tar.gz" "$out/Yaak.Proxy_${{ matrix.asset_arch }}.app.tar.gz"
|
||||
cp "$bundle/macos/Yaak Proxy.app.tar.gz.sig" "$out/Yaak.Proxy_${{ matrix.asset_arch }}.app.tar.gz.sig"
|
||||
(cd "$out" && shasum -a 256 Yaak.Proxy_* > checksums.txt)
|
||||
ls -l "$out"
|
||||
|
||||
- name: Keep the bundles as workflow artifacts
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: proxy-${{ env.PROXY_VERSION }}-${{ matrix.asset_arch }}-${{ github.run_id }}-attempt-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/yaak-proxy-release/
|
||||
retention-days: ${{ env.DRY_RUN == 'true' && 14 || 7 }}
|
||||
if-no-files-found: error
|
||||
|
||||
# Yaak's jobs create the draft release; this waits for it rather than making its own,
|
||||
# which could race theirs into two drafts.
|
||||
- name: Upload to the release
|
||||
if: env.DRY_RUN != 'true'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in $(seq 1 120); do
|
||||
if gh release view "$GITHUB_REF_NAME" > /dev/null 2>&1; then break; fi
|
||||
if [ "$attempt" -eq 120 ]; then
|
||||
echo "No release for $GITHUB_REF_NAME after two hours; Yaak's jobs make it"; exit 1
|
||||
fi
|
||||
sleep 60
|
||||
done
|
||||
for asset in "$RUNNER_TEMP"/yaak-proxy-release/Yaak.Proxy_*; do
|
||||
for attempt in 1 2 3 4; do
|
||||
if gh release upload "$GITHUB_REF_NAME" "$asset" --clobber; then
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -eq 4 ]; then exit 1; fi
|
||||
sleep 10
|
||||
done
|
||||
done
|
||||
@@ -59,3 +59,6 @@ flatpak/node-sources.json
|
||||
# Claude Code local settings
|
||||
.claude/settings.local.json
|
||||
.claude/worktrees/
|
||||
|
||||
# Headless screenshots taken while working
|
||||
.shots/
|
||||
Generated
+444
-235
File diff suppressed because it is too large.
Load diff
@@ -19,6 +19,7 @@ members = [
|
||||
"crates/yaak-mcp",
|
||||
"crates/yaak-models",
|
||||
"crates/yaak-plugins",
|
||||
"crates/yaak-query",
|
||||
"crates/yaak-sse",
|
||||
"crates/yaak-sync",
|
||||
"crates/yaak-templates",
|
||||
@@ -40,8 +41,10 @@ members = [
|
||||
"crates-tauri/yaak-fonts",
|
||||
"crates-tauri/yaak-license",
|
||||
"crates-tauri/yaak-mac-window",
|
||||
"crates-tauri/yaak-proxy-helper",
|
||||
"crates-tauri/yaak-system-appearance",
|
||||
"crates-tauri/yaak-tauri-utils",
|
||||
"crates-tauri/yaak-updater",
|
||||
"crates-tauri/yaak-window",
|
||||
]
|
||||
|
||||
@@ -95,6 +98,7 @@ 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" }
|
||||
@@ -110,8 +114,10 @@ yaak-proxy-lib = { path = "crates-proxy/yaak-proxy-lib" }
|
||||
yaak-fonts = { path = "crates-tauri/yaak-fonts" }
|
||||
yaak-license = { path = "crates-tauri/yaak-license" }
|
||||
yaak-mac-window = { path = "crates-tauri/yaak-mac-window" }
|
||||
yaak-proxy-helper = { path = "crates-tauri/yaak-proxy-helper" }
|
||||
yaak-system-appearance = { path = "crates-tauri/yaak-system-appearance" }
|
||||
yaak-tauri-utils = { path = "crates-tauri/yaak-tauri-utils" }
|
||||
yaak-updater = { path = "crates-tauri/yaak-updater" }
|
||||
yaak-window = { path = "crates-tauri/yaak-window" }
|
||||
|
||||
[profile.release]
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
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>;
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
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}</>;
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
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}</>;
|
||||
}
|
||||
@@ -1,24 +1 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
export { EmptyStateText } from "@yaakapp-internal/ui";
|
||||
@@ -1,60 +1,19 @@
|
||||
import { Banner, Button, InlineCode } from "@yaakapp-internal/ui";
|
||||
import type { ErrorInfo, ReactNode } from "react";
|
||||
import { Component, useEffect } from "react";
|
||||
import { ErrorBoundary as BaseErrorBoundary } from "@yaakapp-internal/ui";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import RouteError from "./RouteError";
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
name: string;
|
||||
children: ReactNode;
|
||||
export function ErrorBoundary({ name, children }: { name: string; children: ReactNode }) {
|
||||
return (
|
||||
<BaseErrorBoundary name={name} onShowError={showErrorDialog}>
|
||||
{children}
|
||||
</BaseErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.warn("Error caught by ErrorBoundary:", error, info);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<Banner color="danger" className="flex items-center gap-2 overflow-auto">
|
||||
<div>
|
||||
Error rendering <InlineCode>{this.props.name}</InlineCode> component
|
||||
</div>
|
||||
<Button
|
||||
className="inline-flex"
|
||||
variant="border"
|
||||
color="danger"
|
||||
size="2xs"
|
||||
onClick={() => {
|
||||
showDialog({
|
||||
id: "error-boundary",
|
||||
render: () => <RouteError error={this.state.error} />,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Show
|
||||
</Button>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
export function showErrorDialog(error: unknown) {
|
||||
showDialog({ id: "error-boundary", render: () => <RouteError error={error} /> });
|
||||
}
|
||||
|
||||
export function ErrorBoundaryTestThrow() {
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
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}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,180 +1 @@
|
||||
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
|
||||
},
|
||||
),
|
||||
};
|
||||
export { HeadersEditor } from "@yaakapp-internal/ui";
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
Workspace,
|
||||
} from "@yaakapp-internal/models";
|
||||
import { patchModel } from "@yaakapp-internal/models";
|
||||
import { HStack, Icon, InlineCode } from "@yaakapp-internal/ui";
|
||||
import { HStack, Icon, InlineCode, SegmentedControl } from "@yaakapp-internal/ui";
|
||||
import { useCallback } from "react";
|
||||
import { openFolderSettings } from "../commands/openFolderSettings";
|
||||
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
|
||||
@@ -21,7 +21,6 @@ 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";
|
||||
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||
import { getModel, patchModel, patchModelDebounced } from "@yaakapp-internal/models";
|
||||
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
|
||||
import classNames from "classnames";
|
||||
import {
|
||||
CountBadge,
|
||||
type EditablePair,
|
||||
type ExtraPaneTab,
|
||||
HttpRequestPaneView,
|
||||
InlineCode,
|
||||
languageFromContentType,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { atom, useAtomValue } from "jotai";
|
||||
import type { CSSProperties } from "react";
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { lazy, Suspense, useCallback, useMemo, useRef, useState } from "react";
|
||||
import { importCurl, looksLikeCurl } from "../commands/importCurl";
|
||||
import { allRequestUrlsAtom } from "../hooks/useAllRequests";
|
||||
@@ -15,8 +22,8 @@ import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
|
||||
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
|
||||
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
|
||||
import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
|
||||
import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
|
||||
import { convertRequestBody } from "../lib/requestBodyConversion";
|
||||
import { fireAndForget } from "../lib/fireAndForget";
|
||||
import { httpRequestToView } from "../lib/httpViews";
|
||||
import {
|
||||
BODY_TYPE_BINARY,
|
||||
BODY_TYPE_FORM_MULTIPART,
|
||||
@@ -28,28 +35,22 @@ 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, TabContent, Tabs } from "./core/Tabs/Tabs";
|
||||
import { EmptyStateText } from "./EmptyStateText";
|
||||
import { FormMultipartEditor } from "./FormMultipartEditor";
|
||||
import { FormUrlencodedEditor } from "./FormUrlencodedEditor";
|
||||
import { HeadersEditor } from "./HeadersEditor";
|
||||
import { setActiveTab } from "./core/Tabs/Tabs";
|
||||
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
|
||||
import { JsonBodyEditor } from "./JsonBodyEditor";
|
||||
import { MarkdownEditor } from "./MarkdownEditor";
|
||||
import { RequestMethodDropdown } from "./RequestMethodDropdown";
|
||||
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
|
||||
import { UrlBar } from "./UrlBar";
|
||||
import { RequestMethodDropdown } from "./RequestMethodDropdown";
|
||||
import { UrlParametersEditor } from "./UrlParameterEditor";
|
||||
import { generateId } from "@yaakapp-internal/lib/generateId";
|
||||
|
||||
@@ -165,9 +166,9 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
|
||||
const hasDescription = activeRequest.description.trim().length > 0;
|
||||
|
||||
const tabs = useMemo<TabItem[]>(
|
||||
() => [
|
||||
{
|
||||
const bodyTab = useMemo<TabItem>(
|
||||
() =>
|
||||
({
|
||||
value: TAB_BODY,
|
||||
rightSlot: numParams > 0 ? <CountBadge count={numParams} /> : null,
|
||||
options: {
|
||||
@@ -244,36 +245,8 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
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,
|
||||
],
|
||||
}) satisfies TabItem,
|
||||
[activeRequest, contentType, handleContentTypeChange, numParams],
|
||||
);
|
||||
|
||||
const { mutate: sendRequest } = useSendAnyHttpRequest();
|
||||
@@ -335,154 +308,152 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
[activeRequest],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
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"}
|
||||
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}
|
||||
/>
|
||||
<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>
|
||||
) : (
|
||||
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
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,56 +1,36 @@
|
||||
import type { HttpResponse, HttpResponseEvent } from "@yaakapp-internal/models";
|
||||
import {
|
||||
Banner,
|
||||
HStack,
|
||||
Icon,
|
||||
LoadingIcon,
|
||||
VStack,
|
||||
CountBadge,
|
||||
HttpResponseDurationTag,
|
||||
HttpStatusTag,
|
||||
SizeTag,
|
||||
type ExtraPaneTab,
|
||||
type HostBodyViewerArgs,
|
||||
HttpResponsePaneView,
|
||||
Icon,
|
||||
type ResponseTextViewerExtras,
|
||||
type TextFilterApi,
|
||||
Tooltip,
|
||||
AudioViewer,
|
||||
CsvViewer,
|
||||
ImageViewer,
|
||||
SvgViewer,
|
||||
VideoViewer,
|
||||
VStack,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import type { ComponentType, CSSProperties } from "react";
|
||||
import { lazy, Suspense, useMemo } from "react";
|
||||
import type { CSSProperties } from "react";
|
||||
import { useCallback, 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 { useResponseBodyBytes, useResponseBodyText } from "../hooks/useResponseBodyText";
|
||||
import { useResponseBodyUrl } from "../hooks/useResponseBodyUrl";
|
||||
import { useRecentFilters } from "../hooks/useRecentFilters";
|
||||
import { useResponseViewMode } from "../hooks/useResponseViewMode";
|
||||
import { useSaveResponse } from "../hooks/useSaveResponse";
|
||||
import { useTimelineViewMode } from "../hooks/useTimelineViewMode";
|
||||
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 { httpResponseToView } from "../lib/httpViews";
|
||||
import { getCookieCounts } from "../lib/model_util";
|
||||
import { CopyButton } from "./CopyButton";
|
||||
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 { RequestBodyViewer } from "./RequestBodyViewer";
|
||||
import { ResponseCookies } from "./ResponseCookies";
|
||||
import { ResponseHeaders } from "./ResponseHeaders";
|
||||
import { EventStreamViewer } from "./responseViewers/EventStreamViewer";
|
||||
import { HTMLOrTextViewer } from "./responseViewers/HTMLOrTextViewer";
|
||||
import { MultipartViewer } from "./responseViewers/MultipartViewer";
|
||||
|
||||
const PdfViewer = lazy(() =>
|
||||
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
|
||||
);
|
||||
import { RecentFiltersDropdown } from "./responseViewers/RecentFiltersDropdown";
|
||||
|
||||
interface Props {
|
||||
style?: CSSProperties;
|
||||
@@ -58,10 +38,6 @@ 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";
|
||||
@@ -71,12 +47,11 @@ 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(
|
||||
@@ -89,280 +64,181 @@ 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 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: [
|
||||
{
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
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 responseView = useMemo(
|
||||
() => (activeResponse == null ? null : httpResponseToView(activeResponse, responseEvents.data)),
|
||||
[activeResponse, responseEvents.data],
|
||||
);
|
||||
|
||||
const cancel = useCancelHttpResponse(activeResponse?.id ?? null);
|
||||
const extraTabs = useMemo<ExtraPaneTab[]>(
|
||||
() =>
|
||||
activeResponse == null
|
||||
? []
|
||||
: [
|
||||
{
|
||||
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} />
|
||||
),
|
||||
},
|
||||
],
|
||||
[activeResponse, responseEvents.data?.length, setTimelineViewMode, timelineViewMode],
|
||||
);
|
||||
|
||||
const renderBodyViewer = useCallback(
|
||||
({ mimeType, viewMode }: HostBodyViewerArgs) =>
|
||||
activeResponse != null && mimeType?.match(/^text\/event-stream/i) && viewMode === "pretty" ? (
|
||||
<EventStreamViewer response={activeResponse} />
|
||||
) : null,
|
||||
[activeResponse],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
<HttpResponsePaneView
|
||||
style={style}
|
||||
className={classNames(
|
||||
className,
|
||||
"x-theme-responsePane",
|
||||
"max-h-full h-full",
|
||||
"bg-surface rounded-md border border-border-subtle overflow-hidden",
|
||||
"relative",
|
||||
)}
|
||||
>
|
||||
{activeResponse == null ? (
|
||||
className={className}
|
||||
response={responseView}
|
||||
body={body}
|
||||
requestBody={requestBody}
|
||||
emptyState={
|
||||
<HotkeyList hotkeys={["request.send", "model.create", "sidebar.focus", "url_bar.focus"]} />
|
||||
) : (
|
||||
<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",
|
||||
)}
|
||||
>
|
||||
{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",
|
||||
}
|
||||
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>
|
||||
)}
|
||||
>
|
||||
<HStack space={2} className="w-full shrink-0">
|
||||
{activeResponse.state !== "closed" && <LoadingIcon size="sm" />}
|
||||
<HttpStatusTag showReason response={activeResponse} />
|
||||
<span>•</span>
|
||||
<HttpResponseDurationTag response={activeResponse} />
|
||||
<span>•</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>
|
||||
{redirectDropWarning.droppedHeaders.length > 0 && (
|
||||
<span>
|
||||
Headers dropped:{" "}
|
||||
<span className="font-mono">
|
||||
{redirectDropWarning.droppedHeaders.join(", ")}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span />
|
||||
</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>
|
||||
<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>
|
||||
) : null
|
||||
}
|
||||
statusLineRight={
|
||||
activeResponse != null && (
|
||||
<RecentHttpResponsesDropdown
|
||||
responses={responses}
|
||||
activeResponse={activeResponse}
|
||||
onPinnedResponseId={setPinnedResponseId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
@@ -410,58 +286,3 @@ 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,7 +5,15 @@ import {
|
||||
type ImportSource,
|
||||
type Workspace,
|
||||
} from "@yaakapp-internal/models";
|
||||
import { Banner, FormattedError, HStack, Icon, VStack, IconTooltip } from "@yaakapp-internal/ui";
|
||||
import {
|
||||
Banner,
|
||||
FormattedError,
|
||||
HStack,
|
||||
Icon,
|
||||
VStack,
|
||||
IconTooltip,
|
||||
SegmentedControl,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { type ComponentProps, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { errorMessage } from "../lib/errorMessage";
|
||||
@@ -22,7 +30,6 @@ 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 {
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
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";
|
||||
|
||||
@@ -9,8 +9,6 @@ import {
|
||||
isYesterday,
|
||||
} from "date-fns";
|
||||
import { useDeleteHttpResponses } from "../hooks/useDeleteHttpResponses";
|
||||
import { useKeyValue } from "../hooks/useKeyValue";
|
||||
import { DismissibleBanner } from "./core/DismissibleBanner";
|
||||
import { Dropdown, type DropdownItem } from "./core/Dropdown";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
|
||||
@@ -27,12 +25,6 @@ export const RecentHttpResponsesDropdown = function ResponsePane({
|
||||
onPinnedResponseId,
|
||||
}: Props) {
|
||||
const deleteAllResponses = useDeleteHttpResponses(activeResponse?.requestId);
|
||||
const movedActionsBannerId = "response-actions-moved-to-response-menu-2026-07-02-v2";
|
||||
const { value: dismissedMovedActions } = useKeyValue<boolean>({
|
||||
namespace: "global",
|
||||
key: ["dismiss-banner", movedActionsBannerId],
|
||||
fallback: false,
|
||||
});
|
||||
const latestResponseId = responses[0]?.id ?? "n/a";
|
||||
const responseHistoryItems: DropdownItem[] = [];
|
||||
let lastHistoryGroup: string | null = null;
|
||||
@@ -123,20 +115,6 @@ export const RecentHttpResponsesDropdown = function ResponsePane({
|
||||
hidden: latestResponseId === activeResponse.id,
|
||||
disabled: responses.length === 0,
|
||||
},
|
||||
{
|
||||
type: "content",
|
||||
hidden: dismissedMovedActions === true,
|
||||
label: (
|
||||
<DismissibleBanner
|
||||
id={movedActionsBannerId}
|
||||
color="info"
|
||||
size="xs"
|
||||
className="max-w-72"
|
||||
>
|
||||
<p>Copy and save actions moved to the Response tab menu.</p>
|
||||
</DismissibleBanner>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: "separator",
|
||||
label: "Recent",
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
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,38 +1,20 @@
|
||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||
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";
|
||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
||||
import { RequestMethodDropdown as BaseRequestMethodDropdown } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { memo, useCallback } from "react";
|
||||
|
||||
type Props = {
|
||||
request: HttpRequest;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const radioItems: RadioDropdownItem<string>[] = [
|
||||
"GET",
|
||||
"PUT",
|
||||
"POST",
|
||||
"PATCH",
|
||||
"DELETE",
|
||||
"OPTIONS",
|
||||
"QUERY",
|
||||
"HEAD",
|
||||
].map((m) => ({
|
||||
value: m,
|
||||
label: <HttpMethodTagRaw method={m} />,
|
||||
}));
|
||||
|
||||
/** The shared method menu, writing to the request and coloured by the user's setting */
|
||||
export const RequestMethodDropdown = memo(function RequestMethodDropdown({
|
||||
request,
|
||||
className,
|
||||
}: Props) {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const handleChange = useCallback(
|
||||
async (method: string) => {
|
||||
await patchModel(request, { method });
|
||||
@@ -40,39 +22,12 @@ 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 (
|
||||
<RadioDropdown
|
||||
value={request.method}
|
||||
items={radioItems}
|
||||
itemsAfter={itemsAfter}
|
||||
<BaseRequestMethodDropdown
|
||||
method={request.method}
|
||||
colored={settings.coloredMethods}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<Button size="xs" className={classNames(className, "text-text-subtle hover:text")}>
|
||||
<HttpMethodTag request={request} noAlias />
|
||||
</Button>
|
||||
</RadioDropdown>
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,19 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { useLicense } from "@yaakapp-internal/license";
|
||||
import { pluginsAtom, settingsAtom } from "@yaakapp-internal/models";
|
||||
import { HeaderSize, HStack, Icon, CountBadge } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import {
|
||||
CountBadge,
|
||||
SETTINGS_TAB_GENERAL,
|
||||
SETTINGS_TAB_INTERFACE,
|
||||
SETTINGS_TAB_LICENSE,
|
||||
SETTINGS_TAB_THEME,
|
||||
SettingsDialog,
|
||||
type SettingsTabDef,
|
||||
} from "@yaakapp-internal/ui";
|
||||
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";
|
||||
@@ -24,15 +30,15 @@ interface Props {
|
||||
hide?: () => void;
|
||||
}
|
||||
|
||||
const TAB_GENERAL = "general";
|
||||
const TAB_INTERFACE = "interface";
|
||||
const TAB_THEME = "theme";
|
||||
const TAB_GENERAL = SETTINGS_TAB_GENERAL;
|
||||
const TAB_INTERFACE = SETTINGS_TAB_INTERFACE;
|
||||
const TAB_THEME = SETTINGS_TAB_THEME;
|
||||
const TAB_SHORTCUTS = "shortcuts";
|
||||
const TAB_PROXY = "proxy";
|
||||
const TAB_CERTIFICATES = "certificates";
|
||||
const TAB_PLUGINS = "plugins";
|
||||
const TAB_AGENTS = "agents";
|
||||
const TAB_LICENSE = "license";
|
||||
const TAB_LICENSE = SETTINGS_TAB_LICENSE;
|
||||
const tabs = [
|
||||
TAB_GENERAL,
|
||||
TAB_THEME,
|
||||
@@ -60,107 +66,57 @@ 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 (
|
||||
<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_AGENTS && settings.mcpServerEnabled ? (
|
||||
<CountBadge count />
|
||||
) : 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>
|
||||
<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] }))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,145 +1,63 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { settingsAtom } from "@yaakapp-internal/models";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { Heading, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import type { AppMetaData } from "@yaakapp-internal/rpc-schema";
|
||||
import { useAtomValue } from "jotai";
|
||||
import type { AgentSetupStatus, AppMetaData } from "@yaakapp-internal/rpc-schema";
|
||||
import { Banner, Icon, InlineCode, SegmentedControl, SettingsPage } from "@yaakapp-internal/ui";
|
||||
import { useEffect } from "react";
|
||||
import { useKeyValue } from "../../hooks/useKeyValue";
|
||||
import { appInfo } from "../../lib/appInfo";
|
||||
import { usePluginInfos } from "../../hooks/usePluginInfo";
|
||||
import {
|
||||
AGENT_CLIENTS,
|
||||
type AgentClient,
|
||||
type AgentSetup,
|
||||
appSetup,
|
||||
cliSetup,
|
||||
skillInstallCommand,
|
||||
} from "../../lib/agentSetup";
|
||||
import { appInfo } from "../../lib/appInfo";
|
||||
import { rpc } from "../../lib/rpc";
|
||||
import { CopyIconButton } from "../CopyIconButton";
|
||||
import { Button } from "../core/Button";
|
||||
import { Editor } from "../core/Editor/LazyEditor";
|
||||
import { IconButton } from "../core/IconButton";
|
||||
import { SegmentedControl } from "../core/SegmentedControl";
|
||||
import {
|
||||
ModelSettingRowBoolean,
|
||||
SettingRow,
|
||||
SettingValue,
|
||||
SettingsList,
|
||||
SettingsSection,
|
||||
} from "../core/SettingRow";
|
||||
import { Link } from "../core/Link";
|
||||
import { SettingCommand, SettingSnippet, SettingsList, SettingsSection } from "../core/SettingRow";
|
||||
import { DeprecatedPluginBanner } from "./SettingsPlugins";
|
||||
|
||||
const MCP_URL = "http://127.0.0.1:64343/mcp";
|
||||
const CLI_INSTALL_COMMAND = "npm install -g @yaakapp/cli";
|
||||
const SKILL_INSTALL_COMMAND = "yaak agent install";
|
||||
const MCP_SERVER_PLUGIN = "@yaak/mcp-server";
|
||||
|
||||
export function SettingsAgents() {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const { value: token } = useKeyValue<string | null>({
|
||||
namespace: "no_sync",
|
||||
key: "mcp_server_token",
|
||||
fallback: null,
|
||||
});
|
||||
const queryClient = useQueryClient();
|
||||
const { value: storedClient, set: setClient } = useKeyValue<AgentClient>({
|
||||
key: "agents_setup_client",
|
||||
fallback: "claude_code",
|
||||
});
|
||||
const client = AGENT_CLIENTS.find((c) => c.value === storedClient)?.value ?? "claude_code";
|
||||
const clientLabel = AGENT_CLIENTS.find((c) => c.value === client)?.label ?? "your agent";
|
||||
const cliVersion = useQuery({
|
||||
queryKey: ["cli_version"],
|
||||
queryFn: async () => (await rpc<AppMetaData>("cmd_metadata")).cliVersion,
|
||||
initialData: appInfo.cliVersion,
|
||||
});
|
||||
const setupStatus = useQuery({
|
||||
queryKey: ["agent_setup_status", client],
|
||||
queryFn: () => rpc<AgentSetupStatus>("cmd_agent_setup_status", { agent: client }),
|
||||
});
|
||||
const mcpServerPlugin = usePluginInfos().find(({ info }) => info.name === MCP_SERVER_PLUGIN);
|
||||
|
||||
useEffect(() => {
|
||||
return platform.window.onFocusChanged((focused) => {
|
||||
if (!focused) return;
|
||||
void queryClient.invalidateQueries({ queryKey: ["cli_version"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["agent_setup_status"] });
|
||||
});
|
||||
}, [queryClient]);
|
||||
|
||||
const cliInstalled = cliVersion.data != null;
|
||||
const cli = cliSetup(client);
|
||||
|
||||
return (
|
||||
<VStack space={1.5} className="mb-4">
|
||||
<div className="mb-3">
|
||||
<Heading>Agents</Heading>
|
||||
<p className="text-text-subtle">
|
||||
Connect AI coding agents like Claude Code and Cursor to Yaak, so they can read, edit, and
|
||||
send your requests.
|
||||
</p>
|
||||
</div>
|
||||
<SettingsList className="space-y-8">
|
||||
<SettingsSection
|
||||
title="Yaak CLI"
|
||||
description="Lets agents use Yaak from the command line, even with the app closed."
|
||||
>
|
||||
<SettingRow
|
||||
title="Status"
|
||||
description={
|
||||
cliVersion.data == null
|
||||
? "The Yaak CLI is not installed."
|
||||
: `Yaak CLI ${cliVersion.data} is installed.`
|
||||
}
|
||||
controlClassName="gap-2"
|
||||
>
|
||||
<IconButton
|
||||
size="sm"
|
||||
icon="refresh"
|
||||
title="Check again"
|
||||
spin={cliVersion.isFetching}
|
||||
onClick={() => cliVersion.refetch()}
|
||||
/>
|
||||
{cliVersion.data == null && (
|
||||
<Button
|
||||
variant="border"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={() => platform.openUrl("https://yaak.app/docs/cli")}
|
||||
rightSlot={<Icon icon="external_link" />}
|
||||
>
|
||||
Install CLI
|
||||
</Button>
|
||||
)}
|
||||
</SettingRow>
|
||||
{cliVersion.data == null ? (
|
||||
<SettingRow title="Install" description="Install it with npm.">
|
||||
<SettingValue value={CLI_INSTALL_COMMAND} />
|
||||
</SettingRow>
|
||||
) : (
|
||||
<SettingRow
|
||||
title="Agent skill"
|
||||
description={
|
||||
<>
|
||||
Teach Claude Code, Cursor, Codex, and opencode to drive the{" "}
|
||||
<InlineCode>yaak</InlineCode> command, wherever they're installed.
|
||||
</>
|
||||
}
|
||||
>
|
||||
<SettingValue value={SKILL_INSTALL_COMMAND} />
|
||||
</SettingRow>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title="MCP server in the app"
|
||||
description="For agents that connect over HTTP, or to act on the workspaces open in Yaak."
|
||||
>
|
||||
<ModelSettingRowBoolean
|
||||
model={settings}
|
||||
modelKey="mcpServerEnabled"
|
||||
title="Enable MCP server"
|
||||
description={
|
||||
<>
|
||||
Serve Yaak's tools at <InlineCode>{MCP_URL}</InlineCode> while Yaak is open.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{settings.mcpServerEnabled && token != null && (
|
||||
<SettingRow
|
||||
title="Token"
|
||||
description="Agents send this as a bearer token."
|
||||
controlClassName="min-w-0 max-w-[min(24rem,40vw)] gap-2"
|
||||
>
|
||||
<SettingValue value={token} />
|
||||
</SettingRow>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title="Connect an agent"
|
||||
description="Pick your agent or editor to see how to add Yaak's MCP tools to it."
|
||||
>
|
||||
<div className="py-4 border-b border-border-subtle">
|
||||
<SettingsPage
|
||||
title="Agents"
|
||||
description="Connect AI coding agents like Claude Code and Cursor to Yaak."
|
||||
>
|
||||
<SettingsList>
|
||||
<SettingsSection title={null}>
|
||||
<div className="pb-4 border-b border-border-subtle">
|
||||
<SegmentedControl
|
||||
name="agents_setup_client"
|
||||
label="Agent"
|
||||
@@ -149,59 +67,65 @@ export function SettingsAgents() {
|
||||
options={AGENT_CLIENTS}
|
||||
/>
|
||||
</div>
|
||||
<SetupRow
|
||||
title="Through the Yaak CLI"
|
||||
description={
|
||||
cliVersion.data == null
|
||||
? "Recommended. Install the Yaak CLI above first."
|
||||
: `Recommended. ${cliSetup(client).where}`
|
||||
}
|
||||
setup={cliSetup(client)}
|
||||
/>
|
||||
{settings.mcpServerEnabled && token != null ? (
|
||||
<SetupRow
|
||||
title="Through the app"
|
||||
description={appSetup(client, MCP_URL, token).where}
|
||||
setup={appSetup(client, MCP_URL, token)}
|
||||
/>
|
||||
{cliInstalled ? (
|
||||
<div className="flex items-center gap-2 py-3 text-sm text-text-subtle border-b border-border-subtle">
|
||||
<Icon icon="check" size="sm" color="success" />
|
||||
<span>
|
||||
Yaak CLI <span className="text-text">{cliVersion.data}</span> is installed.
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<SettingRow
|
||||
title="Through the app"
|
||||
description="Turn on the MCP server above to connect this way."
|
||||
>
|
||||
<span />
|
||||
</SettingRow>
|
||||
<div className="py-4 border-b border-border-subtle">
|
||||
<Banner color="info">
|
||||
<div className="flex items-center gap-2 text text-text">
|
||||
<Icon icon="square_terminal" color="info" />
|
||||
Install the Yaak CLI first
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-text-subtle">
|
||||
Agents launch it to reach Yaak, even with the app closed.
|
||||
</p>
|
||||
<SettingSnippet className="mt-3" language="shell" snippet={CLI_INSTALL_COMMAND} />
|
||||
<div className="mt-3 mb-1 flex items-center gap-4 text-sm">
|
||||
<Link href="https://yaak.app/docs/cli">Docs</Link>
|
||||
<span className="text-text-subtlest">
|
||||
Checks again when you come back to Yaak.
|
||||
</span>
|
||||
</div>
|
||||
</Banner>
|
||||
</div>
|
||||
)}
|
||||
{mcpServerPlugin != null && (
|
||||
<div className="py-4 border-b border-border-subtle">
|
||||
<DeprecatedPluginBanner
|
||||
plugin={mcpServerPlugin.plugin}
|
||||
name={mcpServerPlugin.info.displayName}
|
||||
>
|
||||
The two steps below replace it. Uninstall it once your agent is connected.
|
||||
</DeprecatedPluginBanner>
|
||||
</div>
|
||||
)}
|
||||
<SettingCommand
|
||||
title="Add the MCP server"
|
||||
description={cli.where}
|
||||
snippet={cli.snippet}
|
||||
language={cli.language}
|
||||
disabled={!cliInstalled}
|
||||
done={setupStatus.data?.mcpServer ? "Added" : null}
|
||||
/>
|
||||
<SettingCommand
|
||||
title="Install the skill"
|
||||
description={
|
||||
<>
|
||||
Lets {clientLabel} run the <InlineCode>yaak</InlineCode> command directly.
|
||||
</>
|
||||
}
|
||||
snippet={skillInstallCommand(client)}
|
||||
language="shell"
|
||||
disabled={!cliInstalled}
|
||||
done={setupStatus.data?.skill ? "Installed" : null}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</SettingsList>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function SetupRow({
|
||||
title,
|
||||
description,
|
||||
setup,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
setup: AgentSetup;
|
||||
}) {
|
||||
return (
|
||||
<div className="py-4 border-b border-border-subtle">
|
||||
<div className="text text-text">{title}</div>
|
||||
<div className="mt-1 text-sm text-text-subtle">{description}</div>
|
||||
<Editor
|
||||
readOnly
|
||||
hideGutter
|
||||
wrapLines
|
||||
heightMode="auto"
|
||||
stateKey={null}
|
||||
language={setup.language}
|
||||
defaultValue={setup.snippet}
|
||||
className="mt-3 border border-border-subtle rounded-md px-2"
|
||||
actions={<CopyIconButton size="sm" variant="border" text={setup.snippet} title="Copy setup" />}
|
||||
/>
|
||||
</div>
|
||||
</SettingsPage>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ClientCertificate } from "@yaakapp-internal/models";
|
||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
||||
import { Heading, HStack, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import { HStack, InlineCode, SettingsPage, VStack } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useRef } from "react";
|
||||
import { showConfirmDelete } from "../../lib/confirm";
|
||||
@@ -218,24 +218,19 @@ export function SettingsCertificates() {
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack space={3}>
|
||||
<div className="mb-3">
|
||||
<HStack justifyContent="between" alignItems="start">
|
||||
<div>
|
||||
<Heading>Client Certificates</Heading>
|
||||
<p className="text-text-subtle">
|
||||
Add and manage TLS certificates on a per domain basis
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="border" size="sm" color="secondary" onClick={handleAdd}>
|
||||
Add Certificate
|
||||
</Button>
|
||||
</HStack>
|
||||
</div>
|
||||
|
||||
<CommercialUseBanner source="client-certificates" title="Using certificates for work?" />
|
||||
|
||||
{certificates.length > 0 && (
|
||||
<SettingsPage
|
||||
title="Client Certificates"
|
||||
description="Add and manage TLS certificates on a per domain basis"
|
||||
actions={
|
||||
<Button variant="border" size="sm" color="secondary" onClick={handleAdd}>
|
||||
Add Certificate
|
||||
</Button>
|
||||
}
|
||||
banner={
|
||||
<CommercialUseBanner source="client-certificates" title="Using certificates for work?" />
|
||||
}
|
||||
>
|
||||
{certificates.length > 0 ? (
|
||||
<VStack space={3}>
|
||||
{certificates.map((cert, index) => (
|
||||
<CertificateEditor
|
||||
@@ -248,7 +243,7 @@ export function SettingsCertificates() {
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</VStack>
|
||||
) : null}
|
||||
</SettingsPage>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +1,17 @@
|
||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
||||
import { Heading, VStack } from "@yaakapp-internal/ui";
|
||||
import { settingsAtom } from "@yaakapp-internal/models";
|
||||
import { SettingsGeneral as SharedSettingsGeneral } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCheckForUpdates } from "../../hooks/useCheckForUpdates";
|
||||
import { appInfo } from "../../lib/appInfo";
|
||||
import { revealInFinderText } from "../../lib/reveal";
|
||||
import { CargoFeature } from "../CargoFeature";
|
||||
import { useSettingsView } from "../../hooks/useSettingsView";
|
||||
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 checkForUpdates = useCheckForUpdates();
|
||||
const bound = useSettingsView();
|
||||
|
||||
if (settings == null) {
|
||||
if (settings == null || bound == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -34,147 +19,24 @@ export function SettingsGeneral() {
|
||||
settings.createdAt.slice(0, 10) < WORKSPACE_SETTINGS_MOVED_AT;
|
||||
|
||||
return (
|
||||
<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 && (
|
||||
<SharedSettingsGeneral
|
||||
view={bound.view}
|
||||
callbacks={bound.callbacks}
|
||||
banner={<CommercialUseBanner source="settings-general" title="Using Yaak for work?" />}
|
||||
extraSections={
|
||||
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,8 +1,8 @@
|
||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
||||
import {
|
||||
Heading,
|
||||
HStack,
|
||||
Icon,
|
||||
SettingsPage,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
@@ -94,59 +94,58 @@ export function SettingsHotkeys() {
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack space={3} className="mb-4">
|
||||
<div className="mb-3">
|
||||
<Heading>Keyboard Shortcuts</Heading>
|
||||
<p className="text-text-subtle">
|
||||
Click the menu button to add, remove, or reset keyboard shortcuts.
|
||||
</p>
|
||||
</div>
|
||||
<PlainInput
|
||||
label="Filter"
|
||||
placeholder="Filter shortcuts..."
|
||||
defaultValue={filter}
|
||||
onChange={setFilter}
|
||||
hideLabel
|
||||
containerClassName="max-w-xs"
|
||||
/>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Scope</TableHeaderCell>
|
||||
<TableHeaderCell>Action</TableHeaderCell>
|
||||
<TableHeaderCell>Shortcut</TableHeaderCell>
|
||||
<TableHeaderCell></TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
{/* key={filter} forces re-render on filter change to fix Safari table rendering bug */}
|
||||
<TableBody key={filter}>
|
||||
{filteredActions.map((action) => (
|
||||
<HotkeyRow
|
||||
key={action}
|
||||
action={action}
|
||||
currentKeys={hotkeys[action] ?? []}
|
||||
defaultKeys={defaultHotkeys[action]}
|
||||
onSave={async (keys) => {
|
||||
const newHotkeys = { ...settings.hotkeys };
|
||||
if (arraysEqual(keys, defaultHotkeys[action])) {
|
||||
// Remove from settings if it matches default (use default)
|
||||
<SettingsPage
|
||||
title="Keyboard Shortcuts"
|
||||
description="Click the menu button to add, remove, or reset keyboard shortcuts."
|
||||
>
|
||||
<VStack space={3}>
|
||||
<PlainInput
|
||||
label="Filter"
|
||||
placeholder="Filter shortcuts..."
|
||||
defaultValue={filter}
|
||||
onChange={setFilter}
|
||||
hideLabel
|
||||
containerClassName="max-w-xs"
|
||||
/>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Scope</TableHeaderCell>
|
||||
<TableHeaderCell>Action</TableHeaderCell>
|
||||
<TableHeaderCell>Shortcut</TableHeaderCell>
|
||||
<TableHeaderCell></TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
{/* key={filter} forces re-render on filter change to fix Safari table rendering bug */}
|
||||
<TableBody key={filter}>
|
||||
{filteredActions.map((action) => (
|
||||
<HotkeyRow
|
||||
key={action}
|
||||
action={action}
|
||||
currentKeys={hotkeys[action] ?? []}
|
||||
defaultKeys={defaultHotkeys[action]}
|
||||
onSave={async (keys) => {
|
||||
const newHotkeys = { ...settings.hotkeys };
|
||||
if (arraysEqual(keys, defaultHotkeys[action])) {
|
||||
// Remove from settings if it matches default (use default)
|
||||
delete newHotkeys[action];
|
||||
} else {
|
||||
// Store the keys (including empty array to disable)
|
||||
newHotkeys[action] = keys;
|
||||
}
|
||||
await patchModel(settings, { hotkeys: newHotkeys });
|
||||
}}
|
||||
onReset={async () => {
|
||||
const newHotkeys = { ...settings.hotkeys };
|
||||
delete newHotkeys[action];
|
||||
} else {
|
||||
// Store the keys (including empty array to disable)
|
||||
newHotkeys[action] = keys;
|
||||
}
|
||||
await patchModel(settings, { hotkeys: newHotkeys });
|
||||
}}
|
||||
onReset={async () => {
|
||||
const newHotkeys = { ...settings.hotkeys };
|
||||
delete newHotkeys[action];
|
||||
await patchModel(settings, { hotkeys: newHotkeys });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</VStack>
|
||||
await patchModel(settings, { hotkeys: newHotkeys });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</VStack>
|
||||
</SettingsPage>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,59 +1,30 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { useFonts } from "@yaakapp-internal/fonts";
|
||||
import { useLicense } from "@yaakapp-internal/license";
|
||||
import type { EditorKeymap, Settings } from "@yaakapp-internal/models";
|
||||
import type { Settings } from "@yaakapp-internal/models";
|
||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
||||
import { clamp, Heading, VStack } from "@yaakapp-internal/ui";
|
||||
import { SettingsInterface as SharedSettingsInterface, 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 {
|
||||
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" },
|
||||
];
|
||||
import { SettingRowBoolean, SettingRowSelect, SettingsSection } from "../core/SettingRow";
|
||||
|
||||
export function SettingsInterface() {
|
||||
const workspace = useAtomValue(activeWorkspaceAtom);
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const fonts = useFonts();
|
||||
const bound = useSettingsView();
|
||||
|
||||
if (settings == null || workspace == null) {
|
||||
if (settings == null || workspace == null || bound == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
<SharedSettingsInterface
|
||||
view={bound.view}
|
||||
callbacks={bound.callbacks}
|
||||
leadingSections={
|
||||
<SettingsSection title="Workspaces">
|
||||
<SettingRowSelect
|
||||
title="Open workspace behavior"
|
||||
@@ -78,151 +49,13 @@ export function SettingsInterface() {
|
||||
]}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<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>
|
||||
|
||||
}
|
||||
trailingSections={
|
||||
<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,17 +1,6 @@
|
||||
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 { SettingsLicense as SharedSettingsLicense } from "@yaakapp-internal/ui";
|
||||
import { useSettingsView } from "../../hooks/useSettingsView";
|
||||
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 (
|
||||
@@ -22,166 +11,7 @@ export function SettingsLicense() {
|
||||
}
|
||||
|
||||
function SettingsLicenseCmp() {
|
||||
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>
|
||||
);
|
||||
const bound = useSettingsView();
|
||||
if (bound == null) return null;
|
||||
return <SharedSettingsLicense view={bound.view} callbacks={bound.callbacks} />;
|
||||
}
|
||||
@@ -2,13 +2,9 @@ import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import type { Plugin } from "@yaakapp-internal/models";
|
||||
import { patchModel, pluginsAtom } from "@yaakapp-internal/models";
|
||||
import type { PluginVersion } from "@yaakapp-internal/plugins";
|
||||
import { checkPluginUpdates, installPlugin, searchPlugins } from "@yaakapp-internal/plugins";
|
||||
import {
|
||||
checkPluginUpdates,
|
||||
installPlugin,
|
||||
searchPlugins,
|
||||
uninstallPlugin,
|
||||
} from "@yaakapp-internal/plugins";
|
||||
import {
|
||||
Banner,
|
||||
HStack,
|
||||
Icon,
|
||||
InlineCode,
|
||||
@@ -24,12 +20,11 @@ import {
|
||||
} from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useState } from "react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { useInstallPlugin } from "../../hooks/useInstallPlugin";
|
||||
import { usePluginInfo } from "../../hooks/usePluginInfo";
|
||||
import { usePluginInfo, usePluginInfos } from "../../hooks/usePluginInfo";
|
||||
import { usePluginsKey, useRefreshPlugins } from "../../hooks/usePlugins";
|
||||
import { showConfirmDelete } from "../../lib/confirm";
|
||||
import { minPromiseMillis } from "../../lib/minPromiseMillis";
|
||||
import { usePromptUninstallPlugin } from "../../hooks/useUninstallPlugin";
|
||||
import { Button } from "../core/Button";
|
||||
import { Checkbox } from "../core/Checkbox";
|
||||
import { IconButton } from "../core/IconButton";
|
||||
@@ -205,7 +200,7 @@ function PluginTableRow({
|
||||
mutationKey: ["install_plugin", name],
|
||||
mutationFn: (name: string) => installPlugin(name, null),
|
||||
});
|
||||
const uninstall = usePromptUninstall(plugin?.id ?? null, displayName);
|
||||
const uninstall = usePromptUninstallPlugin(plugin?.id ?? null, displayName);
|
||||
const refreshPlugins = useRefreshPlugins();
|
||||
|
||||
return (
|
||||
@@ -296,7 +291,10 @@ function PluginSearch() {
|
||||
const debouncedQuery = useDebouncedValue(query);
|
||||
const results = useQuery({
|
||||
queryKey: ["plugins", debouncedQuery],
|
||||
queryFn: () => searchPlugins(query),
|
||||
queryFn: async () => {
|
||||
const { plugins } = await searchPlugins(query);
|
||||
return { plugins: plugins.filter((p) => p.deprecated == null) };
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -349,22 +347,77 @@ function InstalledPlugins({ plugins, className }: { plugins: Plugin[]; className
|
||||
</EmptyStateText>
|
||||
</div>
|
||||
) : (
|
||||
<Table scrollable className={className}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell className="w-0" />
|
||||
<TableHeaderCell>Display Name</TableHeaderCell>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>Version</TableHeaderCell>
|
||||
<TableHeaderCell />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<tbody className="divide-y divide-surface-highlight">
|
||||
{plugins.map((p) => (
|
||||
<PluginTableRowForInstalledPlugin key={p.id} plugin={p} />
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
<div className={classNames(className, "h-full grid grid-rows-[auto_minmax(0,1fr)]")}>
|
||||
<DeprecatedPluginBanners />
|
||||
<Table scrollable>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell className="w-0" />
|
||||
<TableHeaderCell>Display Name</TableHeaderCell>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>Version</TableHeaderCell>
|
||||
<TableHeaderCell />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<tbody className="divide-y divide-surface-highlight">
|
||||
{plugins.map((p) => (
|
||||
<PluginTableRowForInstalledPlugin key={p.id} plugin={p} />
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeprecatedPluginBanners() {
|
||||
const deprecated = usePluginInfos().filter(
|
||||
({ plugin, info }) => plugin.source !== "bundled" && info.deprecated != null,
|
||||
);
|
||||
return (
|
||||
<div className={classNames("flex flex-col gap-2", deprecated.length > 0 && "pb-3")}>
|
||||
{deprecated.map(({ plugin, info }) => (
|
||||
<DeprecatedPluginBanner key={plugin.id} plugin={plugin} name={info.displayName}>
|
||||
{withInlineCode(info.deprecated ?? "")}
|
||||
</DeprecatedPluginBanner>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Backtick spans in a package.json string, set as code. */
|
||||
function withInlineCode(text: string): ReactNode {
|
||||
return text
|
||||
.split("`")
|
||||
.map((part, i) => (i % 2 === 1 ? <InlineCode key={i}>{part}</InlineCode> : part));
|
||||
}
|
||||
|
||||
export function DeprecatedPluginBanner({
|
||||
children,
|
||||
name,
|
||||
plugin,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
name: string;
|
||||
plugin: Plugin;
|
||||
}) {
|
||||
const uninstall = usePromptUninstallPlugin(plugin.id, name);
|
||||
return (
|
||||
<Banner color="info" className="flex items-center gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text text-text">The {name} plugin is deprecated.</div>
|
||||
<p className="mt-1 text-sm text-text-subtle">{children}</p>
|
||||
</div>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="border"
|
||||
title="Uninstall plugin"
|
||||
className="shrink-0"
|
||||
isLoading={uninstall?.isPending}
|
||||
onClick={() => uninstall?.mutate()}
|
||||
>
|
||||
Uninstall
|
||||
</Button>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -393,31 +446,6 @@ function BundledPlugins({ plugins }: { plugins: Plugin[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function usePromptUninstall(pluginId: string | null, name: string) {
|
||||
const mut = useMutation({
|
||||
mutationKey: ["uninstall_plugin", pluginId],
|
||||
mutationFn: async () => {
|
||||
if (pluginId == null) return;
|
||||
|
||||
const confirmed = await showConfirmDelete({
|
||||
id: `uninstall-plugin-${pluginId}`,
|
||||
title: "Uninstall Plugin",
|
||||
confirmText: "Uninstall",
|
||||
description: (
|
||||
<>
|
||||
Permanently uninstall <InlineCode>{name}</InlineCode>?
|
||||
</>
|
||||
),
|
||||
});
|
||||
if (confirmed) {
|
||||
await minPromiseMillis(uninstallPlugin(pluginId), 700);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return pluginId == null ? null : mut;
|
||||
}
|
||||
|
||||
function usePluginUpdates() {
|
||||
return useQuery({
|
||||
queryKey: ["plugin_updates", usePluginsKey()],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
||||
import type { ProxySetting } from "@yaakapp-internal/models";
|
||||
import { Heading, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import { InlineCode, SettingsPage } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { CommercialUseBanner } from "../CommercialUseBanner";
|
||||
import {
|
||||
@@ -26,15 +26,11 @@ export function SettingsProxy() {
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack space={1.5} className="mb-4">
|
||||
<div className="mb-3">
|
||||
<Heading>Proxy</Heading>
|
||||
<p className="text-text-subtle">
|
||||
Configure a proxy server for HTTP requests. Useful for corporate firewalls, debugging
|
||||
traffic, or routing through specific infrastructure.
|
||||
</p>
|
||||
</div>
|
||||
<CommercialUseBanner source="proxy-settings" title="Using a proxy for work?" />
|
||||
<SettingsPage
|
||||
title="Proxy"
|
||||
description="Configure a proxy server for HTTP requests. Useful for corporate firewalls, debugging traffic, or routing through specific infrastructure."
|
||||
banner={<CommercialUseBanner source="proxy-settings" title="Using a proxy for work?" />}
|
||||
>
|
||||
<SettingsList className="space-y-8">
|
||||
<SettingsSection title="Proxy">
|
||||
<SettingRowSelect
|
||||
@@ -160,7 +156,7 @@ export function SettingsProxy() {
|
||||
</>
|
||||
)}
|
||||
</SettingsList>
|
||||
</VStack>
|
||||
</SettingsPage>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,173 +1,15 @@
|
||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
||||
import { Heading, HStack, Icon, type IconProps, VStack } from "@yaakapp-internal/ui";
|
||||
import { SettingsTheme as SharedSettingsTheme } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { lazy, Suspense } from "react";
|
||||
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
|
||||
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",
|
||||
];
|
||||
import { useSettingsView } from "../../hooks/useSettingsView";
|
||||
|
||||
export function SettingsTheme() {
|
||||
const workspace = useAtomValue(activeWorkspaceAtom);
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const appearance = useResolvedAppearance();
|
||||
const activeTheme = useResolvedTheme();
|
||||
const bound = useSettingsView();
|
||||
|
||||
if (settings == null || workspace == null || activeTheme.data == null) {
|
||||
if (workspace == null || bound == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
return <SharedSettingsTheme view={bound.view} callbacks={bound.callbacks} />;
|
||||
}
|
||||
@@ -63,11 +63,9 @@ 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 { 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 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 { HttpMethodTag } from "./core/HttpMethodTag";
|
||||
import {
|
||||
Icon,
|
||||
@@ -112,7 +110,7 @@ function Sidebar({ className }: { className?: string }) {
|
||||
const activeWorkspaceId = useAtomValue(activeWorkspaceAtom)?.id;
|
||||
const treeId = `tree.${activeWorkspaceId ?? "unknown"}`;
|
||||
const filterText = useAtomValue(sidebarFilterAtom);
|
||||
const [tree, allFields, emptyFilterSuggestions] = useAtomValue(sidebarTreeAtom) ?? [];
|
||||
const [tree, fieldValues, emptyFilterSuggestions] = useAtomValue(sidebarTreeAtom) ?? [];
|
||||
|
||||
const wrapperRef = useRef<HTMLElement>(null);
|
||||
const treeRef = useRef<TreeHandle>(null);
|
||||
@@ -553,18 +551,18 @@ function Sidebar({ className }: { className?: string }) {
|
||||
const filterCompartmentMountExtRef = useRef<Extension | null>(null);
|
||||
if (filterCompartmentMountExtRef.current == null) {
|
||||
filterCompartmentMountExtRef.current = filterLanguageCompartmentRef.current.of(
|
||||
filter({ fields: allFields ?? [] }),
|
||||
queryFilter({ schema: sidebarFilterSchema, values: fieldValues }),
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const view = filterRef.current;
|
||||
if (!view) return;
|
||||
const ext = filter({ fields: allFields ?? [] });
|
||||
const ext = queryFilter({ schema: sidebarFilterSchema, values: fieldValues });
|
||||
view.dispatch({
|
||||
effects: filterLanguageCompartmentRef.current.reconfigure(ext),
|
||||
});
|
||||
}, [allFields]);
|
||||
}, [fieldValues]);
|
||||
|
||||
if (tree == null || hidden) {
|
||||
return null;
|
||||
@@ -852,16 +850,44 @@ function setSidebarFilterText(text: string) {
|
||||
jotaiStore.set(sidebarFilterAtom, { text, key: `${Math.random()}` });
|
||||
}
|
||||
|
||||
function getSidebarSuggestionValue(ast: Ast | null) {
|
||||
/**
|
||||
* 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) {
|
||||
if (ast == null) return null;
|
||||
|
||||
if (ast.type === "Term" || ast.type === "Phrase") {
|
||||
const value = ast.value.trim();
|
||||
const value = ast.text.trim();
|
||||
return value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
if (ast.type === "Field") {
|
||||
const value = ast.value.trim();
|
||||
const raw =
|
||||
ast.value.type === "text"
|
||||
? ast.value.text
|
||||
: query.slice(ast.valueSpan.start, ast.valueSpan.end);
|
||||
const value = raw.trim();
|
||||
return value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
@@ -883,7 +909,7 @@ const sidebarSuggestionFieldOrder = [
|
||||
];
|
||||
|
||||
const sidebarTreeAtom = atom<
|
||||
[TreeNode<SidebarModel>, FieldDef[], SidebarFilterSuggestion[]] | null
|
||||
[TreeNode<SidebarModel>, Record<string, string[]>, SidebarFilterSuggestion[]] | null
|
||||
>((get) => {
|
||||
const allModels = get(memoAllPotentialChildrenAtom);
|
||||
const activeWorkspace = get(activeWorkspaceAtom);
|
||||
@@ -904,15 +930,18 @@ const sidebarTreeAtom = atom<
|
||||
return null;
|
||||
}
|
||||
|
||||
const queryAst = parseQuery(filter.text);
|
||||
const suggestionValue = getSidebarSuggestionValue(queryAst);
|
||||
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)[] = [];
|
||||
|
||||
// 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): boolean => {
|
||||
const build = (node: TreeNode<SidebarModel>, depth: number) => {
|
||||
const childItems = childrenMap[node.item.id] ?? [];
|
||||
let matchesSelf = true;
|
||||
const fields = getItemFields(node);
|
||||
const model = node.item.model;
|
||||
const isLeafNode = !(model === "folder" || model === "workspace");
|
||||
@@ -930,12 +959,12 @@ const sidebarTreeAtom = atom<
|
||||
}
|
||||
}
|
||||
|
||||
if (queryAst != null) {
|
||||
matchesSelf = isLeafNode && evaluate(queryAst, { text: getItemText(node.item), fields });
|
||||
if (isLeafNode && !matcher.isEmpty) {
|
||||
leaves.push(node);
|
||||
cells.push(getItemText(node.item));
|
||||
for (const column of sidebarFilterColumns) cells.push(fields[column] ?? null);
|
||||
}
|
||||
|
||||
let matchesChild = false;
|
||||
|
||||
// Recurse to children
|
||||
node.children = !isLeafNode ? [] : undefined;
|
||||
|
||||
@@ -953,19 +982,21 @@ const sidebarTreeAtom = atom<
|
||||
|
||||
for (const item of childItems) {
|
||||
const childNode = { item, parent: node, depth };
|
||||
const childMatches = build(childNode, depth + 1);
|
||||
if (childMatches) {
|
||||
matchesChild = true;
|
||||
}
|
||||
build(childNode, depth + 1);
|
||||
node.children.push(childNode);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// hide node IFF nothing in its subtree matches
|
||||
const anyMatch = matchesSelf || matchesChild;
|
||||
node.hidden = !anyMatch;
|
||||
|
||||
return anyMatch;
|
||||
// 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;
|
||||
};
|
||||
|
||||
const root: TreeNode<SidebarModel> = {
|
||||
@@ -975,15 +1006,20 @@ 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);
|
||||
|
||||
const fields: FieldDef[] = [];
|
||||
// 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[]> = {};
|
||||
for (const [name, values] of Object.entries(allFields)) {
|
||||
fields.push({
|
||||
name,
|
||||
values: Array.from(values).filter((v) => v.length < 20),
|
||||
});
|
||||
if (name === "url" || name === "name") continue;
|
||||
fieldValues[name] = Array.from(values);
|
||||
}
|
||||
const suggestions = Array.from(suggestionFields)
|
||||
.sort((a, b) => {
|
||||
@@ -994,9 +1030,9 @@ const sidebarTreeAtom = atom<
|
||||
})
|
||||
.map((field) => ({
|
||||
field,
|
||||
filterText: formatFieldFilter(field, suggestionValue ?? ""),
|
||||
filterText: formatField(field, suggestionValue ?? ""),
|
||||
}));
|
||||
return [root, fields, suggestions] as const;
|
||||
return [root, fieldValues, suggestions] as const;
|
||||
});
|
||||
|
||||
const sidebarGitStatusByModelIdAtom = atom<Record<string, GitStatus>>((get) => {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import type { Workspace } from "@yaakapp-internal/models";
|
||||
import type { TemplateFunction } from "@yaakapp-internal/plugins";
|
||||
import type { Tokens } from "@yaakapp-internal/templates";
|
||||
import { act, type ReactNode } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeAll, beforeEach, expect, test, vi } from "vite-plus/test";
|
||||
import { TemplateFunctionDialog } from "./TemplateFunctionDialog";
|
||||
|
||||
interface FormProps {
|
||||
data: Record<string, string | boolean>;
|
||||
onChange: (values: Record<string, string | boolean>) => void;
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
parse: vi.fn<(text: string) => Tokens>(),
|
||||
print: vi.fn<(tokens: Tokens) => Promise<string>>(),
|
||||
serialize: vi.fn<(tokens: Tokens) => { data: string }>(),
|
||||
preview: vi.fn<({ template }: { template: string }) => { data: string }>(),
|
||||
form: null as FormProps | null,
|
||||
config: {
|
||||
name: "timestamp.format",
|
||||
args: [
|
||||
{ type: "text", name: "date" },
|
||||
{ type: "text", name: "format" },
|
||||
],
|
||||
} as TemplateFunction,
|
||||
}));
|
||||
|
||||
vi.mock("@yaakapp-internal/templates", () => ({ parseTemplate: mocks.parse }));
|
||||
vi.mock("@yaakapp-internal/ui", () => ({
|
||||
HStack: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
InlineCode: "code",
|
||||
LoadingIcon: () => null,
|
||||
useDebouncedValue: (value: string) => value,
|
||||
}));
|
||||
vi.mock("../hooks/useActiveWorkspace", () => ({ activeWorkspaceAtom: null }));
|
||||
vi.mock("../hooks/useRenderTemplate", () => ({ useRenderTemplate: mocks.preview }));
|
||||
vi.mock("../hooks/useTemplateFunctionConfig", () => ({
|
||||
useTemplateFunctionConfig: () => ({ data: mocks.config }),
|
||||
}));
|
||||
vi.mock("../hooks/useTemplateTokensToString", () => ({
|
||||
templateTokensToString: mocks.print,
|
||||
useTemplateTokensToString: mocks.serialize,
|
||||
}));
|
||||
vi.mock("../lib/dialog", () => ({ showDialog: vi.fn() }));
|
||||
vi.mock("../lib/encryption", () => ({ convertTemplateToInsecure: vi.fn() }));
|
||||
vi.mock("../lib/jotai", () => ({ jotaiStore: { get: vi.fn() } }));
|
||||
vi.mock("../lib/setupOrConfigureEncryption", () => ({ setupOrConfigureEncryption: vi.fn() }));
|
||||
vi.mock("./core/Dialog", () => ({ DialogFooter: () => <button type="submit">Save</button> }));
|
||||
vi.mock("./core/IconButton", () => ({ IconButton: () => <button type="button" /> }));
|
||||
vi.mock("./core/PlainInput", () => ({ PlainInput: () => null }));
|
||||
vi.mock("./DynamicForm", () => ({
|
||||
DYNAMIC_FORM_NULL_ARG: "__NULL__",
|
||||
DynamicForm: (props: FormProps) => {
|
||||
mocks.form = props;
|
||||
return <input aria-label="Timestamp" readOnly value={String(props.data.date ?? "")} />;
|
||||
},
|
||||
}));
|
||||
|
||||
beforeAll(async () => {
|
||||
const base = pathToFileURL(
|
||||
resolve(import.meta.dirname, "../../../crates/yaak-templates/pkg") + "/",
|
||||
);
|
||||
const bindings = await import(new URL("yaak_templates_bg.js", base).href);
|
||||
const { instance } = await WebAssembly.instantiate(
|
||||
await readFile(new URL("yaak_templates_bg.wasm", base)),
|
||||
{ "./yaak_templates_bg.js": bindings },
|
||||
);
|
||||
bindings.__wbg_set_wasm(instance.exports);
|
||||
(instance.exports.__wbindgen_start as () => void)();
|
||||
mocks.parse.mockImplementation(bindings.parse_template);
|
||||
});
|
||||
|
||||
let root: Root;
|
||||
let host: HTMLDivElement;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
mocks.form = null;
|
||||
// The transport returns an opaque string. Carry the AST as JSON so the assertions can
|
||||
// inspect what Preview and Save receive, without duplicating the backend's printer.
|
||||
mocks.serialize.mockImplementation((tokens) => ({ data: JSON.stringify(tokens) }));
|
||||
mocks.preview.mockImplementation(({ template }) => ({ data: template }));
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function functionValue(tokens: Tokens) {
|
||||
const first = tokens.tokens[0];
|
||||
if (first?.type !== "tag" || first.val.type !== "fn") throw new Error("Expected function");
|
||||
return first.val;
|
||||
}
|
||||
|
||||
test.each([
|
||||
["timestamp.offset(expression='-1h')", "${[ timestamp.offset(expression='-1h') ]}"],
|
||||
["date_var", "${[ date_var ]}"],
|
||||
["dates.previous", "${[ dates.previous ]}"],
|
||||
["'${[ date_var ]}'", "${[ date_var ]}"],
|
||||
])("Preview and Save preserve %s until that input changes", async (expression, display) => {
|
||||
const initialTokens = mocks.parse(`\${[ timestamp.format(date=${expression}, format='HH:mm') ]}`);
|
||||
const originalDate = functionValue(initialTokens).args[0];
|
||||
const saved = vi.fn<(value: string) => void>();
|
||||
mocks.print.mockResolvedValue(display);
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<TemplateFunctionDialog
|
||||
templateFunction={mocks.config}
|
||||
initialTokens={initialTokens}
|
||||
model={{ id: "wk_test" } as Workspace}
|
||||
onChange={saved}
|
||||
hide={() => {}}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
expect(host.querySelector<HTMLInputElement>("input")?.value).toBe(display);
|
||||
|
||||
async function submit() {
|
||||
const form = host.querySelector("form");
|
||||
if (form == null) throw new Error("Dialog did not initialize");
|
||||
await act(async () => {
|
||||
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
const value = saved.mock.calls.at(-1)?.[0];
|
||||
expect(value).toBe(mocks.preview.mock.calls.at(-1)?.[0].template);
|
||||
return functionValue(JSON.parse(value ?? "null") as Tokens).args;
|
||||
}
|
||||
|
||||
expect((await submit())[0]).toEqual(originalDate);
|
||||
await act(async () => mocks.form?.onChange({ ...mocks.form.data, format: "yyyy" }));
|
||||
expect(await submit()).toEqual([
|
||||
originalDate,
|
||||
{ name: "format", value: { type: "str", text: "yyyy" } },
|
||||
]);
|
||||
|
||||
const edited = "${[ timestamp.offset(expression='-2h') ]}";
|
||||
await act(async () => mocks.form?.onChange({ ...mocks.form.data, date: edited }));
|
||||
expect((await submit())[0]).toEqual({ name: "date", value: { type: "str", text: edited } });
|
||||
await act(async () => mocks.form?.onChange({ ...mocks.form.data, date: "" }));
|
||||
expect((await submit())[0]).toEqual({ name: "date", value: { type: "str", text: "" } });
|
||||
});
|
||||
@@ -24,8 +24,11 @@ import { showDialog } from "../lib/dialog";
|
||||
import { convertTemplateToInsecure } from "../lib/encryption";
|
||||
import { jotaiStore } from "../lib/jotai";
|
||||
import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
|
||||
import {
|
||||
collectEditableArgumentValues,
|
||||
preserveUnchangedArgumentExpressions,
|
||||
} from "../lib/templateFunctionArguments";
|
||||
import { DialogFooter } from "./core/Dialog";
|
||||
import { collectArgumentValues } from "./core/Editor/twig/util";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import { DYNAMIC_FORM_NULL_ARG, DynamicForm } from "./DynamicForm";
|
||||
@@ -48,7 +51,11 @@ export function TemplateFunctionDialog({ initialTokens, templateFunction, ...pro
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const initial = collectArgumentValues(initialTokens, templateFunction);
|
||||
const initial = await collectEditableArgumentValues(
|
||||
initialTokens,
|
||||
templateFunction,
|
||||
templateTokensToString,
|
||||
);
|
||||
|
||||
// HACK: Replace the secure() function's encrypted `value` arg with the decrypted version so
|
||||
// we can display it in the editor input.
|
||||
@@ -74,6 +81,7 @@ export function TemplateFunctionDialog({ initialTokens, templateFunction, ...pro
|
||||
<InitializedTemplateFunctionDialog
|
||||
{...props}
|
||||
templateFunction={templateFunction}
|
||||
initialTokens={initialTokens}
|
||||
initialArgValues={initialArgValues}
|
||||
/>
|
||||
);
|
||||
@@ -82,10 +90,11 @@ export function TemplateFunctionDialog({ initialTokens, templateFunction, ...pro
|
||||
function InitializedTemplateFunctionDialog({
|
||||
templateFunction: { name, previewType: ogPreviewType },
|
||||
initialArgValues,
|
||||
initialTokens,
|
||||
hide,
|
||||
onChange,
|
||||
model,
|
||||
}: Omit<Props, "initialTokens"> & {
|
||||
}: Props & {
|
||||
initialArgValues: Record<string, string | boolean>;
|
||||
}) {
|
||||
const previewType = ogPreviewType == null ? "live" : ogPreviewType;
|
||||
@@ -111,12 +120,17 @@ function InitializedTemplateFunctionDialog({
|
||||
val: {
|
||||
type: "fn",
|
||||
name,
|
||||
args: argTokens,
|
||||
args: preserveUnchangedArgumentExpressions(
|
||||
argTokens,
|
||||
initialTokens,
|
||||
initialArgValues,
|
||||
argValues,
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [argValues, name]);
|
||||
}, [argValues, initialArgValues, initialTokens, name]);
|
||||
|
||||
const tagText = useTemplateTokensToString(tokens);
|
||||
const templateFunction = useTemplateFunctionConfig(name, argValues, model);
|
||||
|
||||
@@ -1,43 +1,6 @@
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AnimatePresence } from "motion/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { hideToast, toastsAtom } from "../lib/toast";
|
||||
import { Toast, type ToastProps } from "./core/Toast";
|
||||
import { ErrorBoundary } from "./ErrorBoundary";
|
||||
import { Portal } from "@yaakapp-internal/ui";
|
||||
import { Toasts as BaseToasts } from "@yaakapp-internal/ui";
|
||||
import { showErrorDialog } from "./ErrorBoundary";
|
||||
|
||||
export type ToastInstance = {
|
||||
id: string;
|
||||
uniqueKey: string;
|
||||
message: ReactNode;
|
||||
timeout: 3000 | 5000 | 8000 | (number & {}) | null;
|
||||
onClose?: ToastProps["onClose"];
|
||||
} & Omit<ToastProps, "onClose" | "open" | "children" | "timeout">;
|
||||
export type { ToastInstance } from "@yaakapp-internal/ui";
|
||||
|
||||
export const Toasts = () => {
|
||||
const toasts = useAtomValue(toastsAtom);
|
||||
return (
|
||||
<Portal name="toasts">
|
||||
<div className="absolute right-0 bottom-0 z-50">
|
||||
<AnimatePresence>
|
||||
{toasts.map((toast: ToastInstance) => {
|
||||
const { message, uniqueKey, ...props } = toast;
|
||||
return (
|
||||
<ErrorBoundary key={uniqueKey} name={`Toast ${uniqueKey}`}>
|
||||
<Toast
|
||||
open
|
||||
{...props}
|
||||
// We call onClose inside actions.hide instead of passing to toast so that
|
||||
// it gets called from external close calls as well
|
||||
onClose={() => hideToast(toast)}
|
||||
>
|
||||
{message}
|
||||
</Toast>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
export const Toasts = () => <BaseToasts onShowError={showErrorDialog} />;
|
||||
@@ -1,113 +1,14 @@
|
||||
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";
|
||||
import { UrlBar as BaseUrlBar, type UrlBarProps } from "@yaakapp-internal/ui";
|
||||
import { memo } from "react";
|
||||
|
||||
type Props = Pick<HttpRequest, "url"> & {
|
||||
className?: string;
|
||||
placeholder: string;
|
||||
type Props = Omit<UrlBarProps, "sendHotkeyAction" | "focusHotkeyAction" | "readOnly"> & {
|
||||
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"];
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
/** 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" />;
|
||||
});
|
||||
@@ -1,8 +1,11 @@
|
||||
import { VStack } from "@yaakapp-internal/ui";
|
||||
import {
|
||||
type EditablePair,
|
||||
type PairEditorHandle,
|
||||
type PairEditorProps,
|
||||
UrlParametersEditor as BaseUrlParametersEditor,
|
||||
} 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;
|
||||
@@ -11,6 +14,7 @@ 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) => {
|
||||
@@ -33,22 +37,12 @@ export function UrlParametersEditor({ pairs, forceUpdateKey, onChange, stateKey
|
||||
);
|
||||
|
||||
return (
|
||||
<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>
|
||||
<BaseUrlParametersEditor
|
||||
setRef={handleInitPairEditorRef}
|
||||
forceUpdateKey={forceUpdateKey + urlParametersKey}
|
||||
onChange={onChange}
|
||||
pairs={pairs}
|
||||
stateKey={stateKey}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
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";
|
||||
@@ -19,6 +20,7 @@ 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";
|
||||
@@ -122,10 +124,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" ? (
|
||||
@@ -172,7 +174,7 @@ function WebsocketEventDetail({
|
||||
}) {
|
||||
const message = useMemo(() => {
|
||||
if (hexDump) {
|
||||
return event.message ? hexy(event.message) : "";
|
||||
return event.message ? hexdump(event.message) : "";
|
||||
}
|
||||
return event.message ? new TextDecoder("utf-8").decode(Uint8Array.from(event.message)) : "";
|
||||
}, [event.message, hexDump]);
|
||||
@@ -232,6 +234,8 @@ function WebsocketEventDetail({
|
||||
</VStack>
|
||||
) : event.message.length === 0 ? (
|
||||
<EmptyStateText>No Content</EmptyStateText>
|
||||
) : hexDump ? (
|
||||
<HexdumpViewer data={event.message} renderEditor={renderEditor} />
|
||||
) : (
|
||||
<Editor
|
||||
language={language}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { settingsAtom, workspacesAtom } from "@yaakapp-internal/models";
|
||||
import { Banner, HeaderSize, HStack, SidebarLayout } from "@yaakapp-internal/ui";
|
||||
import { Banner, HeaderSize, HeaderTint, HStack, SidebarLayout } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useAtomValue } from "jotai";
|
||||
import * as m from "motion/react-m";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
getActiveCookieJar,
|
||||
useEnsureActiveCookieJar,
|
||||
@@ -61,12 +61,6 @@ 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 />;
|
||||
}
|
||||
@@ -81,13 +75,7 @@ export function Workspace() {
|
||||
useNativeTitlebar={settings.useNativeTitlebar}
|
||||
interfaceScale={settings.interfaceScale}
|
||||
>
|
||||
<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>
|
||||
<HeaderTint color={activeEnvironment?.color ?? null} />
|
||||
<WorkspaceHeader className="pointer-events-none" floatingSidebar={floating} />
|
||||
</HeaderSize>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
// @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,12 +1,20 @@
|
||||
import { patchModel, workspaceMetasAtom, workspacesAtom } from "@yaakapp-internal/models";
|
||||
import type { WorkspaceMeta } from "@yaakapp-internal/models";
|
||||
import {
|
||||
mergeModelsInStore,
|
||||
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";
|
||||
@@ -47,6 +55,10 @@ 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 (
|
||||
@@ -56,13 +68,6 @@ 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}
|
||||
@@ -105,15 +110,26 @@ 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}>
|
||||
<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>
|
||||
{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}
|
||||
</SettingsSection>
|
||||
<ModelSettingsEditor model={workspace} showSectionTitles />
|
||||
</SettingsList>
|
||||
@@ -177,6 +193,34 @@ 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,72 +1,2 @@
|
||||
import { HStack, Icon, IconTooltip } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface CheckboxProps {
|
||||
checked: boolean | "indeterminate";
|
||||
title: ReactNode;
|
||||
onChange: (checked: boolean) => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
inputWrapperClassName?: string;
|
||||
hideLabel?: boolean;
|
||||
fullWidth?: boolean;
|
||||
help?: ReactNode;
|
||||
size?: "sm" | "md";
|
||||
}
|
||||
|
||||
export function Checkbox({
|
||||
checked,
|
||||
onChange,
|
||||
className,
|
||||
inputWrapperClassName,
|
||||
disabled,
|
||||
title,
|
||||
hideLabel,
|
||||
fullWidth,
|
||||
help,
|
||||
size = "sm",
|
||||
}: CheckboxProps) {
|
||||
return (
|
||||
<HStack
|
||||
as="label"
|
||||
alignItems="center"
|
||||
space={2}
|
||||
className={classNames(className, "text-text mr-auto")}
|
||||
>
|
||||
<div className={classNames(inputWrapperClassName, "x-theme-input", "relative flex mr-0.5")}>
|
||||
<input
|
||||
aria-hidden
|
||||
className={classNames(
|
||||
"appearance-none shrink-0 border border-border",
|
||||
size === "sm" && "w-4 h-4",
|
||||
size === "md" && "w-5 h-5",
|
||||
"rounded-sm outline-hidden ring-0",
|
||||
!disabled && "hocus:border-border-focus hocus:bg-focus/[5%]",
|
||||
disabled && "border-dotted",
|
||||
)}
|
||||
type="checkbox"
|
||||
disabled={disabled}
|
||||
onChange={() => {
|
||||
onChange(checked === "indeterminate" ? true : !checked);
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Icon
|
||||
size={size}
|
||||
className={classNames(disabled && "opacity-disabled")}
|
||||
icon={checked === "indeterminate" ? "minus" : checked ? "check" : "empty"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!hideLabel && (
|
||||
<div
|
||||
className={classNames("text-sm", fullWidth && "w-full", disabled && "opacity-disabled")}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
{help && <IconTooltip content={help} />}
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
export type { CheckboxProps } from "@yaakapp-internal/ui";
|
||||
export { Checkbox } from "@yaakapp-internal/ui";
|
||||
@@ -1,200 +1,2 @@
|
||||
import type { DialogSize } from "@yaakapp-internal/plugins";
|
||||
import { Heading, HStack, Overlay } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import * as m from "motion/react-m";
|
||||
import type { ReactNode } from "react";
|
||||
import { createContext, useContext, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Button, type ButtonProps } from "./Button";
|
||||
import { IconButton } from "./IconButton";
|
||||
|
||||
export interface DialogProps {
|
||||
children: ReactNode;
|
||||
open: boolean;
|
||||
onClose?: () => void;
|
||||
/** Block dismissal from the backdrop, Escape key, and built-in close button. */
|
||||
disableClose?: boolean;
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
className?: string;
|
||||
size?: DialogSize;
|
||||
/** Hide the built-in close button without changing backdrop or Escape behavior. */
|
||||
hideX?: boolean;
|
||||
noPadding?: boolean;
|
||||
noScroll?: boolean;
|
||||
vAlign?: "top" | "center";
|
||||
}
|
||||
|
||||
export function Dialog({
|
||||
children,
|
||||
className,
|
||||
size = "full",
|
||||
open,
|
||||
onClose,
|
||||
disableClose,
|
||||
title,
|
||||
description,
|
||||
hideX,
|
||||
noPadding,
|
||||
noScroll,
|
||||
vAlign = "center",
|
||||
}: DialogProps) {
|
||||
const titleId = useMemo(() => Math.random().toString(36).slice(2), []);
|
||||
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
|
||||
const descriptionId = useMemo(
|
||||
() => (description ? Math.random().toString(36).slice(2) : undefined),
|
||||
[description],
|
||||
);
|
||||
|
||||
return (
|
||||
<Overlay open={open} onClose={disableClose ? undefined : onClose} portalName="dialog">
|
||||
<div
|
||||
role="dialog"
|
||||
className={classNames(
|
||||
"py-4 x-theme-dialog absolute inset-0 pointer-events-none",
|
||||
"h-full flex flex-col items-center justify-center",
|
||||
vAlign === "top" && "justify-start",
|
||||
vAlign === "center" && "justify-center",
|
||||
)}
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={descriptionId}
|
||||
tabIndex={-1}
|
||||
onKeyDown={(e) => {
|
||||
// NOTE: We handle Escape on the element itself so that it doesn't close multiple
|
||||
// dialogs and can be intercepted by children if needed.
|
||||
if (e.key === "Escape") {
|
||||
if (!disableClose) onClose?.();
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<m.div
|
||||
initial={{ top: 5, scale: 0.97 }}
|
||||
animate={{ top: 0, scale: 1 }}
|
||||
className={classNames(
|
||||
className,
|
||||
"grid grid-rows-[auto_auto_minmax(0,1fr)_auto]",
|
||||
"grid-cols-1", // must be here for inline code blocks to correctly break words
|
||||
"relative bg-surface pointer-events-auto",
|
||||
"rounded-lg",
|
||||
"border border-border-subtle shadow-lg shadow-[rgba(0,0,0,0.1)]",
|
||||
"min-h-40",
|
||||
"max-w-[calc(100vw-5rem)] max-h-[calc(100vh-5rem)]",
|
||||
size === "sm" && "w-120",
|
||||
size === "md" && "w-200",
|
||||
size === "lg" && "w-280",
|
||||
size === "full" && "w-screen h-screen",
|
||||
size === "dynamic" && "min-w-80 max-w-[100vw]",
|
||||
)}
|
||||
>
|
||||
{title ? (
|
||||
<Heading className="px-6 mt-4 mb-2" level={1} id={titleId}>
|
||||
{title}
|
||||
</Heading>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
|
||||
{description ? (
|
||||
<div className="min-h-0 px-6 text-text-subtle mb-3" id={descriptionId}>
|
||||
{description}
|
||||
</div>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
"h-full w-full grid grid-cols-[minmax(0,1fr)] grid-rows-1",
|
||||
!noPadding && "px-6 py-2",
|
||||
!noScroll && "overflow-y-auto overflow-x-hidden",
|
||||
)}
|
||||
>
|
||||
<DialogFooterSlot.Provider value={footerEl}>{children}</DialogFooterSlot.Provider>
|
||||
</div>
|
||||
|
||||
<div ref={setFooterEl} className="contents" />
|
||||
|
||||
{/*Put close at the end so that it's the last thing to be tabbed to*/}
|
||||
{!disableClose && !hideX && (
|
||||
<div className="ml-auto absolute right-1 top-1">
|
||||
<IconButton
|
||||
className="opacity-70 hover:opacity-100"
|
||||
onClick={onClose}
|
||||
title="Close dialog (Esc)"
|
||||
aria-label="Close"
|
||||
size="sm"
|
||||
icon="x"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</m.div>
|
||||
</div>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
|
||||
const DialogFooterSlot = createContext<HTMLDivElement | null>(null);
|
||||
|
||||
export interface DialogFooterAction {
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
/** Submit the form with this id, which can be anywhere in the dialog body */
|
||||
form?: string;
|
||||
color?: ButtonProps["color"];
|
||||
variant?: ButtonProps["variant"];
|
||||
disabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
autoFocus?: boolean;
|
||||
leftSlot?: ReactNode;
|
||||
rightSlot?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The action row at the bottom of a dialog. Render it anywhere in the dialog's content and it is
|
||||
* placed below the scrolling body, outside its padding. `inline` drops the divider for small
|
||||
* dialogs whose body never scrolls.
|
||||
*/
|
||||
export function DialogFooter({
|
||||
actions,
|
||||
leftSlot,
|
||||
inline,
|
||||
}: {
|
||||
actions: DialogFooterAction[];
|
||||
leftSlot?: ReactNode;
|
||||
inline?: boolean;
|
||||
}) {
|
||||
const slot = useContext(DialogFooterSlot);
|
||||
const footer = (
|
||||
<footer
|
||||
className={classNames(
|
||||
"px-6 flex items-center gap-3",
|
||||
inline ? "pt-1 pb-4" : "py-3 border-t border-border-subtle",
|
||||
)}
|
||||
>
|
||||
{leftSlot != null && <div className="mr-auto min-w-0">{leftSlot}</div>}
|
||||
<HStack space={2} justifyContent="end" className="ml-auto shrink-0">
|
||||
{actions.map((action, i) => (
|
||||
<Button
|
||||
// Labels change with state ("Cloning...", "Apply 3 Changes"), so they can't be keys
|
||||
key={i}
|
||||
type={action.form != null ? "submit" : "button"}
|
||||
form={action.form}
|
||||
color={action.color ?? "secondary"}
|
||||
variant={action.variant ?? (action.color == null ? "border" : "solid")}
|
||||
disabled={action.disabled}
|
||||
isLoading={action.isLoading}
|
||||
autoFocus={action.autoFocus}
|
||||
leftSlot={action.leftSlot}
|
||||
rightSlot={action.rightSlot}
|
||||
onClick={action.onClick}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</HStack>
|
||||
</footer>
|
||||
);
|
||||
return slot == null ? footer : createPortal(footer, slot);
|
||||
}
|
||||
export type { DialogFooterAction, DialogProps } from "@yaakapp-internal/ui";
|
||||
export { Dialog, DialogFooter } from "@yaakapp-internal/ui";
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,78 +0,0 @@
|
||||
@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"
|
||||
@@ -1,42 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
// 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!OY!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
|
||||
})
|
||||
@@ -1,43 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
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}`;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
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,
|
||||
});
|
||||
@@ -1,308 +0,0 @@
|
||||
// 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,15 +1,8 @@
|
||||
import type { Completion, CompletionContext } from "@codemirror/autocomplete";
|
||||
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
|
||||
import type { CompletionContext } from "@codemirror/autocomplete";
|
||||
import type { GenericCompletionConfig } from "@yaakapp-internal/ui";
|
||||
import { defaultBoost } from "./twig/completion";
|
||||
|
||||
export type GenericCompletion = GenericCompletionOption & {
|
||||
apply?: Completion["apply"];
|
||||
};
|
||||
|
||||
export interface GenericCompletionConfig {
|
||||
minMatch?: number;
|
||||
options: GenericCompletion[];
|
||||
}
|
||||
export type { GenericCompletion, GenericCompletionConfig } from "@yaakapp-internal/ui";
|
||||
|
||||
/**
|
||||
* Complete options, always matching until the start of the line
|
||||
|
||||
@@ -1,312 +1,2 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
export type { EventDetailAction } from "@yaakapp-internal/ui";
|
||||
export { EventDetailHeader, EventViewer } from "@yaakapp-internal/ui";
|
||||
@@ -1,38 +1 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
export { EventViewerRow } from "@yaakapp-internal/ui";
|
||||
@@ -1,45 +1 @@
|
||||
import { HStack } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import type { HotkeyAction } from "../../hooks/useHotKey";
|
||||
import { useFormattedHotkey } from "../../hooks/useHotKey";
|
||||
|
||||
interface Props {
|
||||
action: HotkeyAction | null;
|
||||
className?: string;
|
||||
variant?: "text" | "with-bg";
|
||||
}
|
||||
|
||||
export function Hotkey({ action, className, variant }: Props) {
|
||||
const labelParts = useFormattedHotkey(action);
|
||||
if (labelParts === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <HotkeyRaw labelParts={labelParts} className={className} variant={variant} />;
|
||||
}
|
||||
|
||||
interface HotkeyRawProps {
|
||||
labelParts: string[];
|
||||
className?: string;
|
||||
variant?: "text" | "with-bg";
|
||||
}
|
||||
|
||||
export function HotkeyRaw({ labelParts, className, variant }: HotkeyRawProps) {
|
||||
return (
|
||||
<HStack
|
||||
className={classNames(
|
||||
className,
|
||||
variant === "with-bg" &&
|
||||
"rounded-sm bg-surface-highlight px-1 border border-border text-text-subtle",
|
||||
variant === "text" && "text-text-subtlest",
|
||||
)}
|
||||
>
|
||||
{labelParts.map((char, index) => (
|
||||
// oxlint-disable-next-line react/no-array-index-key
|
||||
<div key={index} className="min-w-[1em] text-center">
|
||||
{char}
|
||||
</div>
|
||||
))}
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
export { Hotkey, HotkeyRaw } from "@yaakapp-internal/ui";
|
||||
@@ -1,31 +1 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
export { HotkeyList } from "@yaakapp-internal/ui";
|
||||
@@ -3,16 +3,43 @@ import { platform } from "@yaakapp-internal/platform";
|
||||
import classNames from "classnames";
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { appInfo } from "../../lib/appInfo";
|
||||
import { Icon } from "@yaakapp-internal/ui";
|
||||
import { ExternalLink, isYaakUrl } 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",
|
||||
@@ -20,37 +47,6 @@ 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}
|
||||
|
||||
@@ -1,970 +1,8 @@
|
||||
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<
|
||||
export type {
|
||||
EditablePair,
|
||||
Pair,
|
||||
PairEditorHandle,
|
||||
PairEditorProps,
|
||||
| "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>
|
||||
);
|
||||
}
|
||||
PairWithId,
|
||||
} from "@yaakapp-internal/ui";
|
||||
export { PairEditor, PairEditorRow } from "@yaakapp-internal/ui";
|
||||
@@ -1,10 +1 @@
|
||||
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() };
|
||||
}
|
||||
export { ensurePairId } from "@yaakapp-internal/ui";
|
||||
@@ -1,38 +1 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
export { PairOrBulkEditor } from "@yaakapp-internal/ui";
|
||||
@@ -1,14 +1 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
export { PillButton } from "@yaakapp-internal/ui";
|
||||
@@ -1,242 +1,2 @@
|
||||
import { HStack, useStateWithDeps, Label } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import type { FocusEvent, InputHTMLAttributes, ReactNode } from "react";
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useRandomKey } from "../../hooks/useRandomKey";
|
||||
import { IconButton } from "./IconButton";
|
||||
import type { InputProps } from "./Input";
|
||||
import { generateId } from "@yaakapp-internal/lib/generateId";
|
||||
|
||||
export type PlainInputProps = Omit<
|
||||
InputProps,
|
||||
| "wrapLines"
|
||||
| "onKeyDown"
|
||||
| "type"
|
||||
| "stateKey"
|
||||
| "autocompleteVariables"
|
||||
| "autocompleteFunctions"
|
||||
| "autocomplete"
|
||||
| "extraExtensions"
|
||||
| "forcedEnvironmentId"
|
||||
> &
|
||||
Pick<InputHTMLAttributes<HTMLInputElement>, "inputMode" | "onKeyDownCapture" | "step"> & {
|
||||
onFocusRaw?: InputHTMLAttributes<HTMLInputElement>["onFocus"];
|
||||
type?: "text" | "password" | "number";
|
||||
hideObscureToggle?: boolean;
|
||||
labelRightSlot?: ReactNode;
|
||||
};
|
||||
|
||||
export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(function PlainInput(
|
||||
{
|
||||
autoFocus,
|
||||
autoSelect,
|
||||
className,
|
||||
containerClassName,
|
||||
defaultValue,
|
||||
disabled,
|
||||
forceUpdateKey: forceUpdateKeyFromAbove,
|
||||
help,
|
||||
hideLabel,
|
||||
hideObscureToggle,
|
||||
label,
|
||||
labelClassName,
|
||||
labelPosition = "top",
|
||||
labelRightSlot,
|
||||
inputMode,
|
||||
leftSlot,
|
||||
name,
|
||||
onBlur,
|
||||
onChange,
|
||||
onFocus,
|
||||
onFocusRaw,
|
||||
onKeyDownCapture,
|
||||
onPaste,
|
||||
placeholder,
|
||||
required,
|
||||
rightSlot,
|
||||
size = "md",
|
||||
step,
|
||||
tint,
|
||||
type = "text",
|
||||
validate,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
// Track a local key for updates. If the default value is changed when the input is not in focus,
|
||||
// regenerate this to force the field to update.
|
||||
const [focusedUpdateKey, regenerateFocusedUpdateKey] = useRandomKey();
|
||||
const forceUpdateKey = `${forceUpdateKeyFromAbove}::${focusedUpdateKey}`;
|
||||
|
||||
const [obscured, setObscured] = useStateWithDeps(type === "password", [type]);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [hasChanged, setHasChanged] = useStateWithDeps<boolean>(false, [forceUpdateKey]);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useImperativeHandle<{ focus: () => void } | null, { focus: () => void } | null>(
|
||||
ref,
|
||||
() => inputRef.current,
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(
|
||||
(e: FocusEvent<HTMLInputElement>) => {
|
||||
onFocusRaw?.(e);
|
||||
setFocused(true);
|
||||
if (autoSelect) {
|
||||
inputRef.current?.select();
|
||||
textareaRef.current?.select();
|
||||
}
|
||||
onFocus?.();
|
||||
},
|
||||
[autoSelect, onFocus, onFocusRaw],
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
setFocused(false);
|
||||
onBlur?.();
|
||||
}, [onBlur]);
|
||||
|
||||
// Force input to update when receiving change and not in focus
|
||||
useLayoutEffect(() => {
|
||||
const isFocused = document.activeElement === inputRef.current;
|
||||
if (defaultValue != null && !isFocused) {
|
||||
regenerateFocusedUpdateKey();
|
||||
}
|
||||
}, [regenerateFocusedUpdateKey, defaultValue]);
|
||||
|
||||
const id = useRef(`input-${generateId()}`);
|
||||
const commonClassName = classNames(
|
||||
className,
|
||||
"bg-transparent! min-w-0 w-full focus:outline-hidden placeholder:text-placeholder",
|
||||
"px-2 text-xs font-mono cursor-text",
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(value: string) => {
|
||||
onChange?.(value);
|
||||
setHasChanged(true);
|
||||
const isValid = (value: string) => {
|
||||
if (required && !validateRequire(value)) return false;
|
||||
if (typeof validate === "boolean") return validate;
|
||||
if (typeof validate === "function" && !validate(value)) return false;
|
||||
return true;
|
||||
};
|
||||
inputRef.current?.setCustomValidity(isValid(value) ? "" : "Invalid value");
|
||||
},
|
||||
[onChange, required, setHasChanged, validate],
|
||||
);
|
||||
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={classNames(
|
||||
"w-full",
|
||||
"pointer-events-auto", // Just in case we're placing in disabled parent
|
||||
labelPosition === "left" && "flex items-center gap-2",
|
||||
labelPosition === "top" && "flex-row gap-0.5",
|
||||
)}
|
||||
>
|
||||
<Label
|
||||
htmlFor={id.current}
|
||||
className={labelClassName}
|
||||
visuallyHidden={hideLabel}
|
||||
required={required}
|
||||
help={help}
|
||||
rightSlot={labelRightSlot}
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
<HStack
|
||||
alignItems="stretch"
|
||||
className={classNames(
|
||||
containerClassName,
|
||||
"x-theme-input",
|
||||
"relative w-full rounded-md text",
|
||||
"border",
|
||||
"overflow-hidden",
|
||||
focused && !disabled ? "border-border-focus" : "border-border-subtle",
|
||||
disabled && "border-dotted",
|
||||
hasChanged && "has-invalid:border-danger", // For built-in HTML validation
|
||||
size === "md" && "min-h-md",
|
||||
size === "sm" && "min-h-sm",
|
||||
size === "xs" && "min-h-xs",
|
||||
size === "2xs" && "min-h-2xs",
|
||||
)}
|
||||
>
|
||||
{tint != null && (
|
||||
<div
|
||||
aria-hidden
|
||||
className={classNames(
|
||||
"absolute inset-0 opacity-5 pointer-events-none",
|
||||
tint === "info" && "bg-info",
|
||||
tint === "warning" && "bg-warning",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{leftSlot}
|
||||
<HStack
|
||||
className={classNames(
|
||||
"w-full min-w-0",
|
||||
leftSlot ? "pl-0.5 -ml-2" : null,
|
||||
rightSlot ? "pr-0.5 -mr-2" : null,
|
||||
)}
|
||||
>
|
||||
<input
|
||||
id={id.current}
|
||||
ref={inputRef}
|
||||
key={forceUpdateKey}
|
||||
type={type === "password" && !obscured ? "text" : type}
|
||||
name={name}
|
||||
// oxlint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus={autoFocus}
|
||||
defaultValue={defaultValue ?? undefined}
|
||||
disabled={disabled}
|
||||
autoComplete="off"
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
writingsuggestions="false"
|
||||
inputMode={inputMode}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
onPaste={(e) => onPaste?.(e.clipboardData.getData("Text"))}
|
||||
className={classNames(commonClassName, "h-full disabled:opacity-disabled")}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
required={required}
|
||||
step={step}
|
||||
placeholder={placeholder}
|
||||
onKeyDownCapture={onKeyDownCapture}
|
||||
/>
|
||||
</HStack>
|
||||
{type === "password" && !hideObscureToggle && (
|
||||
<IconButton
|
||||
title={
|
||||
obscured
|
||||
? `Show ${typeof label === "string" ? label : "field"}`
|
||||
: `Obscure ${typeof label === "string" ? label : "field"}`
|
||||
}
|
||||
size="xs"
|
||||
className="mr-0.5 group/obscure h-auto! my-0.5"
|
||||
iconClassName="group-hover/obscure:text"
|
||||
iconSize="sm"
|
||||
icon={obscured ? "eye" : "eye_closed"}
|
||||
onClick={() => setObscured((o) => !o)}
|
||||
/>
|
||||
)}
|
||||
{rightSlot}
|
||||
</HStack>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function validateRequire(v: string) {
|
||||
return v.length > 0;
|
||||
}
|
||||
export type { PlainInputProps } from "@yaakapp-internal/ui";
|
||||
export { PlainInput } from "@yaakapp-internal/ui";
|
||||
@@ -1,69 +1,2 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
export type { RadioDropdownItem, RadioDropdownProps } from "@yaakapp-internal/ui";
|
||||
export { RadioDropdown } from "@yaakapp-internal/ui";
|
||||
@@ -1,148 +1,2 @@
|
||||
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",
|
||||
};
|
||||
export type { SelectProps } from "@yaakapp-internal/ui";
|
||||
export { Select } from "@yaakapp-internal/ui";
|
||||
@@ -1,77 +1,2 @@
|
||||
import type { Color } from "@yaakapp-internal/plugins";
|
||||
import type { IconProps } from "@yaakapp-internal/ui";
|
||||
import { IconButton } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* A single control attached to a labelled separator, rendered between the label
|
||||
* and the rule.
|
||||
*
|
||||
* Declared rather than passed as a node so the separator keeps ownership of the
|
||||
* things that are easy to get wrong by hand: matching the label's colour, and
|
||||
* staying out of the rule's way when the label is long.
|
||||
*/
|
||||
export interface SeparatorAction {
|
||||
icon: IconProps["icon"];
|
||||
/** Tooltip and accessible name. Required — the control is icon-only. */
|
||||
title: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
orientation?: "horizontal" | "vertical";
|
||||
dashed?: boolean;
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
color?: Color;
|
||||
action?: SeparatorAction;
|
||||
}
|
||||
|
||||
export function Separator({
|
||||
color,
|
||||
className,
|
||||
dashed,
|
||||
orientation = "horizontal",
|
||||
children,
|
||||
action,
|
||||
}: Props) {
|
||||
return (
|
||||
<div role="presentation" className={classNames(className, "flex items-center w-full")}>
|
||||
{children && (
|
||||
<div className="text-sm text-text-subtlest mr-2 whitespace-nowrap">{children}</div>
|
||||
)}
|
||||
{action && (
|
||||
<IconButton
|
||||
size="2xs"
|
||||
iconSize="xs"
|
||||
className="shrink-0 mr-2 -ml-1"
|
||||
// Forced, because the button itself sets `text-text` at full strength.
|
||||
iconClassName="text-text-subtlest!"
|
||||
icon={action.icon}
|
||||
title={action.title}
|
||||
onClick={action.onClick}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={classNames(
|
||||
"opacity-60",
|
||||
// Keep a stub of the line visible no matter how long the label is —
|
||||
// `w-full` alone gets squeezed to nothing by a wide label.
|
||||
orientation === "horizontal" && "min-w-8",
|
||||
color == null && "border-border",
|
||||
color === "primary" && "border-primary",
|
||||
color === "secondary" && "border-secondary",
|
||||
color === "success" && "border-success",
|
||||
color === "notice" && "border-notice",
|
||||
color === "warning" && "border-warning",
|
||||
color === "danger" && "border-danger",
|
||||
color === "info" && "border-info",
|
||||
dashed && "border-dashed",
|
||||
orientation === "horizontal" && "w-full h-0 border-t",
|
||||
orientation === "vertical" && "h-full w-0 border-l",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export type { SeparatorAction } from "@yaakapp-internal/ui";
|
||||
export { Separator } from "@yaakapp-internal/ui";
|
||||
@@ -1,175 +1,36 @@
|
||||
import type { AnyModel } from "@yaakapp-internal/models";
|
||||
import { patchModel } from "@yaakapp-internal/models";
|
||||
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 {
|
||||
SettingRow,
|
||||
type SettingRowBaseProps,
|
||||
SettingRowBoolean,
|
||||
SettingRowNumber,
|
||||
SettingRowSelect,
|
||||
SettingRowText,
|
||||
SettingSelectControl,
|
||||
} from "@yaakapp-internal/ui";
|
||||
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" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -187,50 +48,6 @@ 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,
|
||||
@@ -249,47 +66,6 @@ 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,
|
||||
@@ -353,74 +129,6 @@ 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>,
|
||||
@@ -464,51 +172,3 @@ 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>
|
||||
);
|
||||
}
|
||||
@@ -1,99 +1,28 @@
|
||||
import {
|
||||
Icon,
|
||||
type StoredValue,
|
||||
TabContent as BaseTabContent,
|
||||
type TabContentProps,
|
||||
type TabItem as BaseTabItem,
|
||||
type TabItem,
|
||||
Tabs as BaseTabs,
|
||||
type TabsProps as BaseTabsProps,
|
||||
type TabsProps,
|
||||
type TabsRef,
|
||||
type TabsStorage,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import type { ReactNode } from "react";
|
||||
import { forwardRef, memo, useMemo } from "react";
|
||||
import { useKeyValue } from "../../../hooks/useKeyValue";
|
||||
import { forwardRef, memo } from "react";
|
||||
import { ErrorBoundary } from "../../ErrorBoundary";
|
||||
import { Button } from "../Button";
|
||||
import type { RadioDropdownProps } from "../RadioDropdown";
|
||||
import { RadioDropdown } from "../RadioDropdown";
|
||||
|
||||
export type { TabsRef };
|
||||
export type { TabItem, TabsRef };
|
||||
|
||||
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} />;
|
||||
});
|
||||
// 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 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,148 +1,2 @@
|
||||
import type { ShowToastRequest } from "@yaakapp-internal/plugins";
|
||||
import { Icon, type IconProps, VStack } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import * as m from "motion/react-m";
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useKey } from "react-use";
|
||||
import { IconButton } from "./IconButton";
|
||||
|
||||
export interface ToastProps {
|
||||
children: ReactNode;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
className?: string;
|
||||
timeout: number | null;
|
||||
action?: (args: { hide: () => void }) => ReactNode;
|
||||
icon?: ShowToastRequest["icon"] | null;
|
||||
color?: ShowToastRequest["color"];
|
||||
// Grow with the content (up to the viewport) instead of scrolling internally
|
||||
// past the default max height
|
||||
dynamicHeight?: boolean;
|
||||
// Hide the close button, for toasts that render their own dismiss action.
|
||||
// Escape still closes the toast
|
||||
hideDismiss?: boolean;
|
||||
}
|
||||
|
||||
const ICONS: Record<NonNullable<ToastProps["color"] | "custom">, IconProps["icon"] | null> = {
|
||||
custom: null,
|
||||
danger: "alert_triangle",
|
||||
info: "info",
|
||||
notice: "alert_triangle",
|
||||
primary: "info",
|
||||
secondary: "info",
|
||||
success: "check_circle",
|
||||
warning: "alert_triangle",
|
||||
};
|
||||
|
||||
export function Toast({
|
||||
children,
|
||||
open,
|
||||
onClose,
|
||||
timeout,
|
||||
action,
|
||||
icon,
|
||||
color,
|
||||
dynamicHeight,
|
||||
hideDismiss,
|
||||
}: ToastProps) {
|
||||
const onCloseRef = useRef(onClose);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [autoHideCanceled, setAutoHideCanceled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
onCloseRef.current = onClose;
|
||||
}, [onClose]);
|
||||
|
||||
const cancelAutoHide = useCallback(() => {
|
||||
if (timeoutRef.current == null) return;
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
setAutoHideCanceled(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || timeout == null || autoHideCanceled) return;
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
timeoutRef.current = null;
|
||||
onCloseRef.current();
|
||||
}, timeout);
|
||||
|
||||
return () => {
|
||||
if (timeoutRef.current == null) return;
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
};
|
||||
}, [autoHideCanceled, open, timeout]);
|
||||
|
||||
useKey(
|
||||
"Escape",
|
||||
() => {
|
||||
if (!open) return;
|
||||
onClose();
|
||||
},
|
||||
{},
|
||||
[open],
|
||||
);
|
||||
|
||||
const toastIcon = icon === null ? null : (icon ?? (color && color in ICONS && ICONS[color]));
|
||||
|
||||
return (
|
||||
<m.div
|
||||
initial={{ opacity: 0, right: "-10%" }}
|
||||
animate={{ opacity: 100, right: 0 }}
|
||||
exit={{ opacity: 0, right: "-100%" }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className={classNames("bg-surface m-2 rounded-lg")}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
`x-theme-toast x-theme-toast--${color}`,
|
||||
"pointer-events-auto overflow-hidden",
|
||||
"relative pointer-events-auto bg-surface text-text rounded-lg",
|
||||
"border border-border shadow-lg w-100",
|
||||
)}
|
||||
onFocusCapture={cancelAutoHide}
|
||||
onKeyDownCapture={cancelAutoHide}
|
||||
onPointerDownCapture={cancelAutoHide}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
"pl-3 py-3 flex items-start gap-2 w-full overflow-auto",
|
||||
hideDismiss ? "pr-3" : "pr-10",
|
||||
dynamicHeight ? "max-h-[80vh]" : "max-h-44",
|
||||
)}
|
||||
>
|
||||
{toastIcon && <Icon icon={toastIcon} color={color} className="mt-1 shrink-0" />}
|
||||
<VStack space={2} className="w-full min-w-0">
|
||||
<div className="select-auto">{children}</div>
|
||||
{action?.({ hide: onClose })}
|
||||
</VStack>
|
||||
</div>
|
||||
|
||||
{!hideDismiss && (
|
||||
<IconButton
|
||||
color={color}
|
||||
variant="border"
|
||||
className="opacity-60 border-0 absolute! top-2 right-2"
|
||||
title="Dismiss"
|
||||
icon="x"
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{timeout != null && !autoHideCanceled && (
|
||||
<div className="w-full absolute bottom-0 left-0 right-0">
|
||||
<m.div
|
||||
className="bg-surface-highlight h-[3px]"
|
||||
initial={{ width: "100%" }}
|
||||
animate={{ width: "0%", opacity: 0.2 }}
|
||||
transition={{ duration: timeout / 1000, ease: "linear" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
export type { ToastProps } from "@yaakapp-internal/ui";
|
||||
export { Toast } from "@yaakapp-internal/ui";
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||
import { extractSseValueAtPath, type ServerSentEvent } from "@yaakapp-internal/sse";
|
||||
import { HStack, Icon, InlineCode, SplitLayout, VStack, IconTooltip } from "@yaakapp-internal/ui";
|
||||
import {
|
||||
EventStreamViewer as SharedEventStreamViewer,
|
||||
HStack,
|
||||
Icon,
|
||||
InlineCode,
|
||||
SplitLayout,
|
||||
type StreamEvent,
|
||||
VStack,
|
||||
IconTooltip,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { Fragment, useMemo, useState } from "react";
|
||||
@@ -20,8 +29,7 @@ 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, EventViewer } from "../core/EventViewer";
|
||||
import { EventViewerRow } from "../core/EventViewerRow";
|
||||
import { EventDetailHeader } from "../core/EventViewer";
|
||||
import { IconButton } from "../core/IconButton";
|
||||
import { Input } from "../core/Input";
|
||||
import { Select } from "../core/Select";
|
||||
@@ -62,6 +70,7 @@ 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 =
|
||||
@@ -105,10 +114,7 @@ function ActualEventStreamViewer({ response }: Props) {
|
||||
),
|
||||
},
|
||||
],
|
||||
[
|
||||
applyToDetailsSetting,
|
||||
filterEventPreviewsSetting,
|
||||
],
|
||||
[applyToDetailsSetting, filterEventPreviewsSetting],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -175,30 +181,21 @@ function ActualEventStreamViewer({ response }: Props) {
|
||||
resizeHandleClassName="hover:bg-surface-highlight active:bg-surface-highlight"
|
||||
firstSlot={({ style }) => (
|
||||
<div style={style} className="min-h-0">
|
||||
<EventViewer
|
||||
events={events.data ?? []}
|
||||
getEventKey={(_, index) => String(index)}
|
||||
<SharedEventStreamViewer
|
||||
events={streamEvents}
|
||||
error={events.error ? String(events.error) : null}
|
||||
splitLayoutStorageKey="sse_events"
|
||||
defaultRatio={0.4}
|
||||
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>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
getPreview={(event) =>
|
||||
getEventPreview(
|
||||
sseEventOf(events.data, event),
|
||||
summarySettings.resultKeyPath,
|
||||
filterEventPreviews,
|
||||
)
|
||||
}
|
||||
renderDetail={({ event, index, onClose }) => (
|
||||
<EventDetail
|
||||
event={event}
|
||||
event={sseEventOf(events.data, event)}
|
||||
index={index}
|
||||
applyJsonPath={applyToDetails}
|
||||
resultKeyPath={summarySettings.resultKeyPath}
|
||||
@@ -312,6 +309,23 @@ 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,
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
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,41 +1 @@
|
||||
import type { RefObject } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Get notified when a mouse click happens outside the target ref
|
||||
* @param ref The element to be notified when a mouse click happens outside it
|
||||
* @param onClickAway
|
||||
* @param ignored Optional outside element to ignore (useful for dropdown triggers)
|
||||
*/
|
||||
export function useClickOutside(
|
||||
ref: RefObject<HTMLElement | null>,
|
||||
onClickAway: (event: MouseEvent) => void,
|
||||
ignored?: RefObject<HTMLElement | null>,
|
||||
) {
|
||||
const savedCallback = useRef(onClickAway);
|
||||
|
||||
useEffect(() => {
|
||||
savedCallback.current = onClickAway;
|
||||
}, [onClickAway]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: MouseEvent) => {
|
||||
if (ref.current == null || !(event.target instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
const isIgnored = ignored?.current?.contains(event.target);
|
||||
const clickedOutside = !ref.current.contains(event.target);
|
||||
if (!isIgnored && clickedOutside) {
|
||||
savedCallback.current(event);
|
||||
}
|
||||
};
|
||||
// NOTE: We're using mousedown instead of click to handle some edge cases like when a context
|
||||
// menu is open with the ctrl key.
|
||||
document.addEventListener("mousedown", handler, { capture: true });
|
||||
document.addEventListener("contextmenu", handler, { capture: true });
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handler);
|
||||
document.removeEventListener("contextmenu", handler);
|
||||
};
|
||||
}, [ignored, ref]);
|
||||
}
|
||||
export { useClickOutside } from "@yaakapp-internal/ui";
|
||||
@@ -1,11 +1,26 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { debounce } from "@yaakapp-internal/lib";
|
||||
import { settingsAtom } from "@yaakapp-internal/models";
|
||||
import {
|
||||
formatHotkey,
|
||||
type HotkeyContextValue,
|
||||
type HotKeyOptions,
|
||||
useFormattedHotkey,
|
||||
useHotKey,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { atom, useAtomValue } from "jotai";
|
||||
import { useEffect } from "react";
|
||||
import { capitalize } from "../lib/capitalize";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { jotaiStore } from "../lib/jotai";
|
||||
|
||||
export type { HotKeyOptions };
|
||||
export { useFormattedHotkey, useHotKey };
|
||||
|
||||
declare module "@yaakapp-internal/ui" {
|
||||
interface HotkeyActionRegistry {
|
||||
action: HotkeyAction;
|
||||
}
|
||||
}
|
||||
|
||||
const HOLD_KEYS = ["Shift", "Control", "Command", "Alt", "Meta"];
|
||||
const SINGLE_WHITELIST = ["Delete", "Enter", "Backspace"];
|
||||
|
||||
@@ -188,12 +203,6 @@ export const hotkeyActions: HotkeyAction[] = (
|
||||
return hotkeyLabels[a].localeCompare(hotkeyLabels[b]);
|
||||
});
|
||||
|
||||
export type HotKeyOptions = {
|
||||
enable?: boolean | (() => boolean);
|
||||
priority?: number;
|
||||
allowDefault?: boolean;
|
||||
};
|
||||
|
||||
interface Callback {
|
||||
action: HotkeyAction;
|
||||
callback: (e: KeyboardEvent) => void;
|
||||
@@ -210,25 +219,35 @@ const clearCurrentKeysDebounced = debounce(() => {
|
||||
jotaiStore.set(currentKeysAtom, new Set([]));
|
||||
}, 5000);
|
||||
|
||||
export function useHotKey(
|
||||
action: HotkeyAction | null,
|
||||
function registerHotkey(
|
||||
action: HotkeyAction,
|
||||
callback: (e: KeyboardEvent) => void,
|
||||
options: HotKeyOptions = {},
|
||||
options: HotKeyOptions,
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (action == null) return;
|
||||
jotaiStore.set(callbacksAtom, (prev) => {
|
||||
const without = prev.filter((cb) => {
|
||||
const isTheSame = cb.action === action && cb.options.priority === options.priority;
|
||||
return !isTheSame;
|
||||
});
|
||||
const newCb: Callback = { action, callback, options };
|
||||
return [...without, newCb];
|
||||
jotaiStore.set(callbacksAtom, (prev) => {
|
||||
const without = prev.filter((cb) => {
|
||||
const isTheSame = cb.action === action && cb.options.priority === options.priority;
|
||||
return !isTheSame;
|
||||
});
|
||||
return () => {
|
||||
jotaiStore.set(callbacksAtom, (prev) => prev.filter((cb) => cb.callback !== callback));
|
||||
};
|
||||
}, [action, callback, options]);
|
||||
const newCb: Callback = { action, callback, options };
|
||||
return [...without, newCb];
|
||||
});
|
||||
return () => {
|
||||
jotaiStore.set(callbacksAtom, (prev) => prev.filter((cb) => cb.callback !== callback));
|
||||
};
|
||||
}
|
||||
|
||||
export function useHotkeyContextValue(): HotkeyContextValue {
|
||||
const hotkeys = useAtomValue(hotkeysAtom);
|
||||
return useMemo(
|
||||
() => ({
|
||||
os: platform.osType(),
|
||||
bindingFor: (action: HotkeyAction) => hotkeys[action] ?? null,
|
||||
labelFor: (action: HotkeyAction) => hotkeyLabels[action],
|
||||
register: registerHotkey,
|
||||
}),
|
||||
[hotkeys],
|
||||
);
|
||||
}
|
||||
|
||||
export function useSubscribeHotKeys() {
|
||||
@@ -330,66 +349,7 @@ export function getHotkeyScope(action: HotkeyAction): string {
|
||||
}
|
||||
|
||||
export function formatHotkeyString(trigger: string): string[] {
|
||||
const os = platform.osType();
|
||||
const parts = trigger.split("+");
|
||||
const labelParts: string[] = [];
|
||||
|
||||
for (const p of parts) {
|
||||
if (os === "macos") {
|
||||
if (p === "Meta") {
|
||||
labelParts.push("⌘");
|
||||
} else if (p === "Shift") {
|
||||
labelParts.push("⇧");
|
||||
} else if (p === "Control") {
|
||||
labelParts.push("⌃");
|
||||
} else if (p === "Alt") {
|
||||
labelParts.push("⌥");
|
||||
} else if (p === "Enter") {
|
||||
// U+21A9 has an emoji presentation, which Chromium's font fallback picks
|
||||
// (a blue glyph among monochrome ones). U+FE0E forces the text form.
|
||||
labelParts.push("↩︎");
|
||||
} else if (p === "Tab") {
|
||||
labelParts.push("⇥");
|
||||
} else if (p === "Backspace") {
|
||||
labelParts.push("⌫");
|
||||
} else if (p === "Delete") {
|
||||
labelParts.push("⌦");
|
||||
} else if (p === "Minus") {
|
||||
labelParts.push("-");
|
||||
} else if (p === "Plus") {
|
||||
labelParts.push("+");
|
||||
} else if (p === "Equal") {
|
||||
labelParts.push("=");
|
||||
} else if (p === "Space") {
|
||||
labelParts.push("Space");
|
||||
} else {
|
||||
labelParts.push(capitalize(p));
|
||||
}
|
||||
} else {
|
||||
if (p === "Control") {
|
||||
labelParts.push("Ctrl");
|
||||
} else if (p === "Space") {
|
||||
labelParts.push("Space");
|
||||
} else {
|
||||
labelParts.push(capitalize(p));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (os === "macos") {
|
||||
return labelParts;
|
||||
}
|
||||
return [labelParts.join("+")];
|
||||
}
|
||||
|
||||
export function useFormattedHotkey(action: HotkeyAction | null): string[] | null {
|
||||
const hotkeys = useAtomValue(hotkeysAtom);
|
||||
const trigger = action != null ? (hotkeys[action]?.[0] ?? null) : null;
|
||||
if (trigger == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return formatHotkeyString(trigger);
|
||||
return formatHotkey(trigger, platform.osType());
|
||||
}
|
||||
|
||||
function compareKeys(keysA: string[], keysB: string[]) {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
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,5 +1,7 @@
|
||||
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) {
|
||||
@@ -27,6 +29,6 @@ export async function getRequestBodyText(response: HttpResponse | null) {
|
||||
}
|
||||
|
||||
const body = new Uint8Array(data);
|
||||
const bodyText = new TextDecoder("utf-8", { fatal: false }).decode(body);
|
||||
const bodyText = decodeBodyText(body, getContentTypeFromHeaders(response.requestHeaders));
|
||||
return { body, bodyText };
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||
import type { Plugin } from "@yaakapp-internal/models";
|
||||
import { pluginsAtom } from "@yaakapp-internal/models";
|
||||
import type { PluginMetadata } from "@yaakapp-internal/plugins";
|
||||
@@ -24,6 +24,22 @@ export function usePluginInfo(id: string | null) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Every plugin with its metadata, once loaded; the ones still loading are left out. */
|
||||
export function usePluginInfos() {
|
||||
const plugins = useAtomValue(pluginsAtom);
|
||||
return useQueries({
|
||||
queries: plugins.map((plugin) => ({
|
||||
queryKey: pluginInfoKey(plugin.id, plugin),
|
||||
queryFn: () => rpc<PluginMetadata>("cmd_plugin_info", { id: plugin.id }),
|
||||
})),
|
||||
combine: (results) =>
|
||||
results.flatMap((result, i) => {
|
||||
const plugin = plugins[i];
|
||||
return result.data == null || plugin == null ? [] : [{ plugin, info: result.data }];
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function invalidateAllPluginInfo() {
|
||||
queryClient.invalidateQueries({ queryKey: ["plugin_info"] }).catch(console.error);
|
||||
}
|
||||
@@ -1,8 +1 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { generateId } from "@yaakapp-internal/lib/generateId";
|
||||
|
||||
export function useRandomKey(initialValue?: string) {
|
||||
const [value, setValue] = useState<string>(initialValue ?? generateId());
|
||||
const regenerate = useCallback(() => setValue(generateId()), []);
|
||||
return [value, regenerate] as const;
|
||||
}
|
||||
export { useRandomKey } from "@yaakapp-internal/ui";
|
||||
@@ -1,4 +1,9 @@
|
||||
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 },
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
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 };
|
||||
}
|
||||
@@ -1,7 +1,2 @@
|
||||
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;
|
||||
}
|
||||
// From its own entry rather than the package index, so tests that mock the index keep it
|
||||
export { useToggle } from "@yaakapp-internal/ui/useToggle";
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { uninstallPlugin } from "@yaakapp-internal/plugins";
|
||||
import { InlineCode } from "@yaakapp-internal/ui";
|
||||
import { showConfirmDelete } from "../lib/confirm";
|
||||
import { minPromiseMillis } from "../lib/minPromiseMillis";
|
||||
|
||||
/** Confirms, then uninstalls. Null until there is a plugin to uninstall. */
|
||||
export function usePromptUninstallPlugin(pluginId: string | null, name: string) {
|
||||
const mut = useMutation({
|
||||
mutationKey: ["uninstall_plugin", pluginId],
|
||||
mutationFn: async () => {
|
||||
if (pluginId == null) return;
|
||||
|
||||
const confirmed = await showConfirmDelete({
|
||||
id: `uninstall-plugin-${pluginId}`,
|
||||
title: "Uninstall Plugin",
|
||||
confirmText: "Uninstall",
|
||||
description: (
|
||||
<>
|
||||
Permanently uninstall <InlineCode>{name}</InlineCode>?
|
||||
</>
|
||||
),
|
||||
});
|
||||
if (confirmed) {
|
||||
await minPromiseMillis(uninstallPlugin(pluginId), 700);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return pluginId == null ? null : mut;
|
||||
}
|
||||
@@ -1,15 +1,20 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { AGENT_CLIENTS, appSetup, cliSetup } from "./agentSetup";
|
||||
|
||||
const URL = "http://127.0.0.1:64343/mcp";
|
||||
const TOKEN = "abc123";
|
||||
import { AGENT_CLIENTS, cliSetup, skillInstallCommand } from "./agentSetup";
|
||||
|
||||
describe("cliSetup", () => {
|
||||
test("uses each client's own add command", () => {
|
||||
expect(cliSetup("claude_code").snippet).toBe("claude mcp add yaak -- yaak mcp");
|
||||
expect(cliSetup("claude_code").snippet).toBe("claude mcp add -s user yaak -- yaak mcp");
|
||||
expect(cliSetup("codex").snippet).toBe("codex mcp add yaak -- yaak mcp");
|
||||
});
|
||||
|
||||
test("names a file only when the snippet goes in one", () => {
|
||||
expect(cliSetup("claude_code").where).toBeNull();
|
||||
expect(cliSetup("codex").where).toBeNull();
|
||||
expect(cliSetup("cursor").where).toBe("Add to ~/.cursor/mcp.json.");
|
||||
expect(cliSetup("vscode").where).toContain("mcp.json");
|
||||
expect(cliSetup("other").where).not.toBeNull();
|
||||
});
|
||||
|
||||
test("writes the config shape each client reads", () => {
|
||||
expect(JSON.parse(cliSetup("cursor").snippet)).toEqual({
|
||||
mcpServers: { yaak: { command: "yaak", args: ["mcp"] } },
|
||||
@@ -21,42 +26,27 @@ describe("cliSetup", () => {
|
||||
mcpServers: { yaak: { command: "yaak", args: ["mcp"] } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("appSetup", () => {
|
||||
test("passes the token as a bearer header", () => {
|
||||
expect(appSetup("claude_code", URL, TOKEN).snippet).toBe(
|
||||
`claude mcp add --transport http yaak ${URL} --header "Authorization: Bearer ${TOKEN}"`,
|
||||
);
|
||||
expect(appSetup("codex", URL, TOKEN).snippet).toBe(
|
||||
`[mcp_servers.yaak]\nurl = "${URL}"\nhttp_headers = { "Authorization" = "Bearer ${TOKEN}" }`,
|
||||
);
|
||||
expect(JSON.parse(appSetup("cursor", URL, TOKEN).snippet)).toEqual({
|
||||
mcpServers: { yaak: { url: URL, headers: { Authorization: `Bearer ${TOKEN}` } } },
|
||||
});
|
||||
expect(JSON.parse(appSetup("vscode", URL, TOKEN).snippet)).toEqual({
|
||||
servers: { yaak: { type: "http", url: URL, headers: { Authorization: `Bearer ${TOKEN}` } } },
|
||||
});
|
||||
expect(JSON.parse(appSetup("other", URL, TOKEN).snippet)).toEqual({
|
||||
mcpServers: {
|
||||
yaak: {
|
||||
command: "npx",
|
||||
args: ["-y", "mcp-remote", URL, "--header", `Authorization: Bearer ${TOKEN}`],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps argument lists on one line", () => {
|
||||
expect(appSetup("other", URL, TOKEN).snippet).toContain(
|
||||
`"args": ["-y", "mcp-remote", "${URL}", "--header", "Authorization: Bearer ${TOKEN}"]`,
|
||||
);
|
||||
expect(cliSetup("cursor").snippet).toContain(`"args": ["mcp"]`);
|
||||
});
|
||||
|
||||
test("covers every listed client", () => {
|
||||
for (const { value } of AGENT_CLIENTS) {
|
||||
expect(cliSetup(value).snippet).not.toBe("");
|
||||
expect(appSetup(value, URL, TOKEN).snippet).toContain(TOKEN);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("skillInstallCommand", () => {
|
||||
test("targets the agents the installer knows by name", () => {
|
||||
expect(skillInstallCommand("claude_code")).toBe("yaak agent install --agent claude-code");
|
||||
expect(skillInstallCommand("codex")).toBe("yaak agent install --agent codex");
|
||||
expect(skillInstallCommand("cursor")).toBe("yaak agent install --agent cursor");
|
||||
});
|
||||
|
||||
test("installs everywhere for agents it cannot target", () => {
|
||||
expect(skillInstallCommand("vscode")).toBe("yaak agent install");
|
||||
expect(skillInstallCommand("other")).toBe("yaak agent install");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
export type AgentClient = "claude_code" | "codex" | "cursor" | "vscode" | "other";
|
||||
import type { AgentClient } from "@yaakapp-internal/rpc-schema";
|
||||
|
||||
export type { AgentClient };
|
||||
|
||||
export const AGENT_CLIENTS: { label: string; value: AgentClient }[] = [
|
||||
{ label: "Claude Code", value: "claude_code" },
|
||||
@@ -9,21 +11,34 @@ export const AGENT_CLIENTS: { label: string; value: AgentClient }[] = [
|
||||
];
|
||||
|
||||
export interface AgentSetup {
|
||||
/** Where the snippet goes, as a sentence */
|
||||
where: string;
|
||||
/** The file the snippet goes in, as a sentence, when it is a file rather than a command */
|
||||
where: string | null;
|
||||
snippet: string;
|
||||
language: "shell" | "json" | "text";
|
||||
}
|
||||
|
||||
const RUN_IN_TERMINAL = "Run in a terminal.";
|
||||
/** `yaak agent install`, targeted at `client` when the installer knows it by name. */
|
||||
export function skillInstallCommand(client: AgentClient): string {
|
||||
switch (client) {
|
||||
case "claude_code":
|
||||
return "yaak agent install --agent claude-code";
|
||||
case "codex":
|
||||
return "yaak agent install --agent codex";
|
||||
case "cursor":
|
||||
return "yaak agent install --agent cursor";
|
||||
case "vscode":
|
||||
case "other":
|
||||
return "yaak agent install";
|
||||
}
|
||||
}
|
||||
|
||||
/** Setup for `yaak mcp`, which the client launches over stdio. */
|
||||
export function cliSetup(client: AgentClient): AgentSetup {
|
||||
switch (client) {
|
||||
case "claude_code":
|
||||
return { where: RUN_IN_TERMINAL, snippet: "claude mcp add yaak -- yaak mcp", language: "shell" };
|
||||
return { where: null, snippet: "claude mcp add -s user yaak -- yaak mcp", language: "shell" };
|
||||
case "codex":
|
||||
return { where: RUN_IN_TERMINAL, snippet: "codex mcp add yaak -- yaak mcp", language: "shell" };
|
||||
return { where: null, snippet: "codex mcp add yaak -- yaak mcp", language: "shell" };
|
||||
case "cursor":
|
||||
return {
|
||||
where: "Add to ~/.cursor/mcp.json.",
|
||||
@@ -45,56 +60,6 @@ export function cliSetup(client: AgentClient): AgentSetup {
|
||||
}
|
||||
}
|
||||
|
||||
/** Setup for the app's MCP server at `url`, which needs `token` as a bearer token. */
|
||||
export function appSetup(client: AgentClient, url: string, token: string): AgentSetup {
|
||||
const authorization = `Bearer ${token}`;
|
||||
switch (client) {
|
||||
case "claude_code":
|
||||
return {
|
||||
where: RUN_IN_TERMINAL,
|
||||
snippet: `claude mcp add --transport http yaak ${url} --header "Authorization: ${authorization}"`,
|
||||
language: "shell",
|
||||
};
|
||||
case "codex":
|
||||
return {
|
||||
where: "Add to ~/.codex/config.toml.",
|
||||
snippet: [
|
||||
"[mcp_servers.yaak]",
|
||||
`url = ${JSON.stringify(url)}`,
|
||||
`http_headers = { "Authorization" = ${JSON.stringify(authorization)} }`,
|
||||
].join("\n"),
|
||||
language: "text",
|
||||
};
|
||||
case "cursor":
|
||||
return {
|
||||
where: "Add to ~/.cursor/mcp.json.",
|
||||
snippet: json({ mcpServers: { yaak: { url, headers: { Authorization: authorization } } } }),
|
||||
language: "json",
|
||||
};
|
||||
case "vscode":
|
||||
return {
|
||||
where: "Add to your user mcp.json (MCP: Open User Configuration).",
|
||||
snippet: json({
|
||||
servers: { yaak: { type: "http", url, headers: { Authorization: authorization } } },
|
||||
}),
|
||||
language: "json",
|
||||
};
|
||||
case "other":
|
||||
return {
|
||||
where: "For clients that read an mcpServers config, through the mcp-remote bridge.",
|
||||
snippet: json({
|
||||
mcpServers: {
|
||||
yaak: {
|
||||
command: "npx",
|
||||
args: ["-y", "mcp-remote", url, "--header", `Authorization: ${authorization}`],
|
||||
},
|
||||
},
|
||||
}),
|
||||
language: "json",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Pretty JSON with arrays of plain values kept on one line. */
|
||||
function json(value: unknown): string {
|
||||
return JSON.stringify(value, null, 2).replace(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import deepEqual from "@gilbarbara/deep-equal";
|
||||
import type { UpdateInfo } from "@yaakapp-internal/tauri-client";
|
||||
import type { UpdateInfo } from "@yaakapp-internal/tauri-updater";
|
||||
import type { Atom } from "jotai";
|
||||
import { atom } from "jotai";
|
||||
import { selectAtom } from "jotai/utils";
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
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" },
|
||||
|
||||
@@ -1,44 +1 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
export { getMimeTypeFromContentType, isJSON, isProbablyTextContentType } from "@yaakapp-internal/ui/mimeType";
|
||||
@@ -1,22 +1 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* Get the text content from a ReactNode
|
||||
* https://stackoverflow.com/questions/50428910/get-text-content-from-node-in-react
|
||||
*/
|
||||
export function getNodeText(node: ReactNode): string {
|
||||
if (typeof node === "string" || typeof node === "number") {
|
||||
return String(node);
|
||||
}
|
||||
|
||||
if (Array.isArray(node)) {
|
||||
return node.map(getNodeText).join("");
|
||||
}
|
||||
|
||||
if (typeof node === "object" && node) {
|
||||
// oxlint-disable-next-line no-explicit-any
|
||||
return getNodeText((node as any).props.children);
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
export { getNodeText } from "@yaakapp-internal/ui";
|
||||
@@ -0,0 +1,83 @@
|
||||
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: [] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
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 };
|
||||
}
|
||||
@@ -6,16 +6,19 @@ import type {
|
||||
ShowToastRequest,
|
||||
} from "@yaakapp-internal/plugins";
|
||||
import { updateAllPlugins } from "@yaakapp-internal/plugins";
|
||||
import type {
|
||||
PluginUpdateNotification,
|
||||
UpdateInfo,
|
||||
UpdateResponse,
|
||||
YaakNotification,
|
||||
} from "@yaakapp-internal/tauri-client";
|
||||
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import type { PluginUpdateNotification, YaakNotification } from "@yaakapp-internal/tauri-client";
|
||||
import type { UpdateInfo, UpdateResponse, UpdateState } from "@yaakapp-internal/tauri-updater";
|
||||
import {
|
||||
ButtonInfiniteLoading,
|
||||
HStack,
|
||||
Icon,
|
||||
showUpdateAvailableToast as showUpdateAvailableToastIn,
|
||||
showUpdateInstalledToast as showUpdateInstalledToastIn,
|
||||
showUpdateStateToast as showUpdateStateToastIn,
|
||||
VStack,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { openSettings } from "../commands/openSettings";
|
||||
import { Button } from "../components/core/Button";
|
||||
import { ButtonInfiniteLoading } from "../components/core/ButtonInfiniteLoading";
|
||||
|
||||
// Listen for toasts
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
@@ -136,6 +139,11 @@ export function initGlobalListeners() {
|
||||
void showUpdateAvailableToast(payload);
|
||||
});
|
||||
|
||||
// A check asked for while an update is already in hand says where it stands
|
||||
platform.listen<UpdateState>("update_state", (state) => {
|
||||
showUpdateStateToastIn(jotaiStore, { appName: "Yaak", state });
|
||||
});
|
||||
|
||||
platform.listen<YaakNotification>("notification", (payload) => {
|
||||
console.log("Got notification event", payload);
|
||||
showNotificationToast(payload);
|
||||
@@ -149,131 +157,35 @@ export function initGlobalListeners() {
|
||||
}
|
||||
|
||||
function showUpdateInstalledToast(version: string) {
|
||||
const UPDATE_TOAST_ID = "update-info";
|
||||
|
||||
showToast({
|
||||
id: UPDATE_TOAST_ID,
|
||||
color: "primary",
|
||||
timeout: null,
|
||||
message: (
|
||||
<VStack>
|
||||
<h2 className="font-semibold">Yaak {version} was installed</h2>
|
||||
<p className="text-text-subtle text-sm">Start using the new version now?</p>
|
||||
</VStack>
|
||||
),
|
||||
action: ({ hide }) => (
|
||||
<ButtonInfiniteLoading
|
||||
size="xs"
|
||||
className="mr-auto min-w-20"
|
||||
color="primary"
|
||||
loadingChildren="Restarting..."
|
||||
onClick={() => {
|
||||
hide();
|
||||
setTimeout(() => rpc("cmd_restart", {}), 200);
|
||||
}}
|
||||
>
|
||||
Relaunch Yaak
|
||||
</ButtonInfiniteLoading>
|
||||
),
|
||||
showUpdateInstalledToastIn(jotaiStore, {
|
||||
appName: "Yaak",
|
||||
version,
|
||||
onRelaunch: () => rpc("cmd_restart", {}),
|
||||
});
|
||||
}
|
||||
|
||||
async function showUpdateAvailableToast(updateInfo: UpdateInfo) {
|
||||
const UPDATE_TOAST_ID = "update-info";
|
||||
const { version, replyEventId, downloaded, install } = updateInfo;
|
||||
|
||||
jotaiStore.set(updateAvailableAtom, { version, downloaded, install });
|
||||
|
||||
const whatsNewButton = (
|
||||
<Button
|
||||
size="xs"
|
||||
color="info"
|
||||
variant="border"
|
||||
rightSlot={<Icon icon="external_link" />}
|
||||
onClick={async () => {
|
||||
await platform.openUrl(`https://yaak.app/changelog/${version}`);
|
||||
}}
|
||||
>
|
||||
What's New
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (install !== "integrated") {
|
||||
// Nothing to reply to here; the backend only told us so we can say how to update
|
||||
const flatpak = install === "flatpak";
|
||||
showToast({
|
||||
id: UPDATE_TOAST_ID,
|
||||
color: "info",
|
||||
timeout: null,
|
||||
message: (
|
||||
<VStack>
|
||||
<h2 className="font-semibold">Yaak {version} is available</h2>
|
||||
<p className="text-text-subtle text-sm">
|
||||
{flatpak ? (
|
||||
<>
|
||||
Update with <InlineCode>flatpak update</InlineCode> or your software center.
|
||||
</>
|
||||
) : (
|
||||
"Download the new version to upgrade."
|
||||
)}
|
||||
</p>
|
||||
</VStack>
|
||||
),
|
||||
action: () => (
|
||||
<HStack space={1.5}>
|
||||
{!flatpak && (
|
||||
<Button
|
||||
size="xs"
|
||||
color="info"
|
||||
rightSlot={<Icon icon="external_link" />}
|
||||
onClick={async () => {
|
||||
await platform.openUrl("https://yaak.app/download");
|
||||
}}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
)}
|
||||
{whatsNewButton}
|
||||
</HStack>
|
||||
),
|
||||
});
|
||||
return;
|
||||
// Nothing to reply to otherwise; the backend only told us so we can say how to update
|
||||
if (install === "integrated") {
|
||||
// Acknowledge the event, so we don't time out and try the fallback update logic
|
||||
await platform.emit(replyEventId, { type: "ack" } satisfies UpdateResponse);
|
||||
}
|
||||
|
||||
// Acknowledge the event, so we don't time out and try the fallback update logic
|
||||
await platform.emit(replyEventId, { type: "ack" } satisfies UpdateResponse);
|
||||
|
||||
showToast({
|
||||
id: UPDATE_TOAST_ID,
|
||||
color: "info",
|
||||
timeout: null,
|
||||
message: (
|
||||
<VStack>
|
||||
<h2 className="font-semibold">Yaak {version} is available</h2>
|
||||
<p className="text-text-subtle text-sm">
|
||||
{downloaded ? "Do you want to install" : "Download and install"} the update?
|
||||
</p>
|
||||
</VStack>
|
||||
),
|
||||
action: () => (
|
||||
<HStack space={1.5}>
|
||||
<ButtonInfiniteLoading
|
||||
size="xs"
|
||||
color="info"
|
||||
className="min-w-40"
|
||||
loadingChildren={downloaded ? "Installing..." : "Downloading..."}
|
||||
onClick={async () => {
|
||||
await platform.emit(replyEventId, {
|
||||
type: "action",
|
||||
action: "install",
|
||||
} satisfies UpdateResponse);
|
||||
}}
|
||||
>
|
||||
{downloaded ? "Install Now" : "Download and Install"}
|
||||
</ButtonInfiniteLoading>
|
||||
{whatsNewButton}
|
||||
</HStack>
|
||||
),
|
||||
showUpdateAvailableToastIn(jotaiStore, {
|
||||
appName: "Yaak",
|
||||
update: updateInfo,
|
||||
whatsNewUrl: `https://yaak.app/changelog/${version}`,
|
||||
downloadUrl: "https://yaak.app/download",
|
||||
onOpenUrl: (url) => platform.openUrl(url),
|
||||
onInstall: () =>
|
||||
platform.emit(replyEventId, {
|
||||
type: "action",
|
||||
action: "install",
|
||||
} satisfies UpdateResponse),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -5,16 +5,17 @@ import type {
|
||||
HttpResponseEvent,
|
||||
HttpResponseHeader,
|
||||
} from "@yaakapp-internal/models";
|
||||
import { getMimeTypeFromContentType } from "./contentType";
|
||||
|
||||
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 {
|
||||
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 function cookieDomain(cookie: Cookie): string {
|
||||
if (cookie.domain === "NotPresent" || cookie.domain === "Empty") {
|
||||
@@ -46,14 +47,6 @@ 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,43 +1,9 @@
|
||||
import type { AnyModel } from "@yaakapp-internal/models";
|
||||
import { foldersAtom } from "@yaakapp-internal/models";
|
||||
import { resolvedModelName } from "@yaakapp-internal/ui/resolvedModelName";
|
||||
import { jotaiStore } from "./jotai";
|
||||
import { replaceTemplateTags } from "./templateTags";
|
||||
|
||||
export function resolvedModelName(r: AnyModel | null): string {
|
||||
if (r == null) return "";
|
||||
|
||||
if (!("url" in r) || r.model === "plugin") {
|
||||
return "name" in r ? r.name : "";
|
||||
}
|
||||
|
||||
// Return name if it has one
|
||||
if ("name" in r && r.name) {
|
||||
return r.name;
|
||||
}
|
||||
|
||||
// Replace variable syntax with variable name
|
||||
const withoutVariables = replaceTemplateTags(r.url, (m) => m.inner.trim());
|
||||
if (withoutVariables.trim() === "") {
|
||||
return r.model === "http_request"
|
||||
? r.bodyType && r.bodyType === "graphql"
|
||||
? "GraphQL Request"
|
||||
: "HTTP Request"
|
||||
: r.model === "websocket_request"
|
||||
? "WebSocket Request"
|
||||
: "gRPC Request";
|
||||
}
|
||||
|
||||
// GRPC gets nice short names
|
||||
if (r.model === "grpc_request" && r.service != null && r.method != null) {
|
||||
const shortService = r.service.split(".").pop();
|
||||
return `${shortService}/${r.method}`;
|
||||
}
|
||||
|
||||
// Strip unnecessary protocol
|
||||
const withoutProto = withoutVariables.replace(/^(http|https|ws|wss):\/\//, "");
|
||||
|
||||
return withoutProto;
|
||||
}
|
||||
export { resolvedModelName };
|
||||
|
||||
export function resolvedModelNameWithFolders(model: AnyModel | null): string {
|
||||
return resolvedModelNameWithFoldersArray(model).join(" / ");
|
||||
|
||||
@@ -3,7 +3,9 @@ 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
|
||||
@@ -74,5 +76,6 @@ export async function getResponseBodyBytes(
|
||||
|
||||
async function getResponseBodyDecoded(response: HttpResponse): Promise<string | null> {
|
||||
const bytes = await getResponseBodyBytes(response);
|
||||
return bytes == null ? null : new TextDecoder("utf-8").decode(bytes);
|
||||
if (bytes == null) return null;
|
||||
return decodeBodyText(bytes, getContentTypeFromHeaders(response.headers));
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import type { TemplateFunction } from "@yaakapp-internal/plugins";
|
||||
import type { FnArg, Tokens } from "@yaakapp-internal/templates";
|
||||
import { beforeAll, describe, expect, test, vi } from "vite-plus/test";
|
||||
import {
|
||||
collectEditableArgumentValues,
|
||||
preserveUnchangedArgumentExpressions,
|
||||
} from "./templateFunctionArguments";
|
||||
|
||||
// Use the shipped WASM parser without the browser-only `?init` loader.
|
||||
let parse: (text: string) => Tokens;
|
||||
beforeAll(async () => {
|
||||
const base = new URL("../../../crates/yaak-templates/pkg/", import.meta.url);
|
||||
const bindings = await import(new URL("yaak_templates_bg.js", base).href);
|
||||
const { instance } = await WebAssembly.instantiate(
|
||||
await readFile(new URL("yaak_templates_bg.wasm", base)),
|
||||
{ "./yaak_templates_bg.js": bindings },
|
||||
);
|
||||
bindings.__wbg_set_wasm(instance.exports);
|
||||
(instance.exports.__wbindgen_start as () => void)();
|
||||
parse = bindings.parse_template;
|
||||
});
|
||||
|
||||
const definition: TemplateFunction = {
|
||||
name: "timestamp.iso8601",
|
||||
args: [{ type: "text", name: "date", optional: true }],
|
||||
};
|
||||
|
||||
function args(tokens: Tokens): FnArg[] {
|
||||
const first = tokens.tokens[0];
|
||||
if (first?.type !== "tag" || first.val.type !== "fn") throw new Error("Expected a function");
|
||||
return first.val.args;
|
||||
}
|
||||
|
||||
function stringArg(name: string, text: string): FnArg {
|
||||
return { name, value: { type: "str", text } };
|
||||
}
|
||||
|
||||
// Printing is an existing async RPC. Assert the exact expression handed to it and return
|
||||
// its expected text, including real parser round-tripping of quoted/escaped fixtures.
|
||||
function printer(text: string) {
|
||||
return vi.fn(async (tokens: Tokens) => {
|
||||
expect(tokens.tokens).toEqual(parse(text).tokens.filter((t) => t.type !== "eof"));
|
||||
return text;
|
||||
});
|
||||
}
|
||||
|
||||
describe("editable template function arguments", () => {
|
||||
test.each([
|
||||
[
|
||||
"reported call",
|
||||
"${[timestamp.iso8601(date=timestamp.offset(expression='-1h')) ]}",
|
||||
"${[ timestamp.offset(expression='-1h') ]}",
|
||||
],
|
||||
["variable", "${[ timestamp.iso8601(date=date_var) ]}", "${[ date_var ]}"],
|
||||
["dotted variable", "${[ timestamp.iso8601(date=dates.previous) ]}", "${[ dates.previous ]}"],
|
||||
[
|
||||
"nested escaping",
|
||||
String.raw`${"${["} timestamp.iso8601(date=outer(value=inner(text='it\'s \\ nested'))) ]}`,
|
||||
String.raw`${"${["} outer(value=inner(text='it\'s \\ nested')) ]}`,
|
||||
],
|
||||
])("loads and preserves %s on unchanged Save", async (_, source, display) => {
|
||||
const initialTokens = parse(source);
|
||||
const snapshot = structuredClone(initialTokens);
|
||||
const print = printer(display);
|
||||
const initial = await collectEditableArgumentValues(initialTokens, definition, print);
|
||||
expect(initial).toEqual({ date: display });
|
||||
expect(print).toHaveBeenCalledOnce();
|
||||
|
||||
const saved = preserveUnchangedArgumentExpressions(
|
||||
[stringArg("date", display)],
|
||||
initialTokens,
|
||||
initial,
|
||||
{ ...initial },
|
||||
);
|
||||
expect(saved).toEqual(args(initialTokens));
|
||||
expect(initialTokens).toEqual(snapshot);
|
||||
});
|
||||
|
||||
test.each([
|
||||
String.raw`${"${["} timestamp.iso8601(date='${"${["} timestamp.offset(expression=\'-1h\') ]}') ]}`,
|
||||
"${[ timestamp.iso8601(date='${[ date_var ]}') ]}",
|
||||
"${[ timestamp.iso8601(date='${[ dates.previous ]}') ]}",
|
||||
"${[ timestamp.iso8601(date='2026-10-27T11:00:00.000Z') ]}",
|
||||
])("leaves existing quoted arguments unchanged: %s", async (source) => {
|
||||
const initialTokens = parse(source);
|
||||
const print = vi.fn<(_: Tokens) => Promise<string>>();
|
||||
const initial = await collectEditableArgumentValues(initialTokens, definition, print);
|
||||
const value = args(initialTokens)[0]?.value;
|
||||
if (value?.type !== "str") throw new Error("Expected a string fixture");
|
||||
expect(initial).toEqual({ date: value.text });
|
||||
expect(print).not.toHaveBeenCalled();
|
||||
expect(
|
||||
preserveUnchangedArgumentExpressions(
|
||||
[stringArg("date", value.text)],
|
||||
initialTokens,
|
||||
initial,
|
||||
initial,
|
||||
),
|
||||
).toEqual(args(initialTokens));
|
||||
});
|
||||
|
||||
test("editing a sibling preserves the original expression", async () => {
|
||||
const initialTokens = parse(
|
||||
"${[ timestamp.format(date=timestamp.offset(expression='-1h'), format='HH:mm') ]}",
|
||||
);
|
||||
const initial = await collectEditableArgumentValues(
|
||||
initialTokens,
|
||||
{
|
||||
name: "timestamp.format",
|
||||
args: [...definition.args, { type: "text", name: "format" }],
|
||||
},
|
||||
printer("${[ timestamp.offset(expression='-1h') ]}"),
|
||||
);
|
||||
const values: Record<string, string | boolean> = { ...initial, format: "yyyy-MM-dd" };
|
||||
const saved = preserveUnchangedArgumentExpressions(
|
||||
[stringArg("date", String(values.date)), stringArg("format", String(values.format))],
|
||||
initialTokens,
|
||||
initial,
|
||||
values,
|
||||
);
|
||||
expect(saved).toEqual([args(initialTokens)[0], stringArg("format", "yyyy-MM-dd")]);
|
||||
});
|
||||
|
||||
test.each(["${[ timestamp.offset(expression='-2h') ]}", "2026-10-26", ""])(
|
||||
"intentional edits use the form's quoted-string convention: %s",
|
||||
async (edited) => {
|
||||
const initialTokens = parse("${[ timestamp.iso8601(date=date_var) ]}");
|
||||
const initial = await collectEditableArgumentValues(
|
||||
initialTokens,
|
||||
definition,
|
||||
printer("${[ date_var ]}"),
|
||||
);
|
||||
const changed = [stringArg("date", edited)];
|
||||
expect(
|
||||
preserveUnchangedArgumentExpressions(changed, initialTokens, initial, { date: edited }),
|
||||
).toEqual(changed);
|
||||
// Returning the input to its original text restores the original expression type.
|
||||
expect(
|
||||
preserveUnchangedArgumentExpressions(
|
||||
[stringArg("date", String(initial.date))],
|
||||
initialTokens,
|
||||
initial,
|
||||
{ ...initial },
|
||||
),
|
||||
).toEqual(args(initialTokens));
|
||||
},
|
||||
);
|
||||
|
||||
test("does not resurrect removed or explicitly cleared expression arguments", async () => {
|
||||
const initialTokens = parse("${[ timestamp.iso8601(date=date_var) ]}");
|
||||
const initial = await collectEditableArgumentValues(
|
||||
initialTokens,
|
||||
definition,
|
||||
printer("${[ date_var ]}"),
|
||||
);
|
||||
expect(preserveUnchangedArgumentExpressions([], initialTokens, initial, {})).toEqual([]);
|
||||
const nullArg: FnArg = { name: "date", value: { type: "null" } };
|
||||
expect(
|
||||
preserveUnchangedArgumentExpressions([nullArg], initialTokens, initial, { date: "__NULL__" }),
|
||||
).toEqual([nullArg]);
|
||||
// DynamicForm clears some inputs by leaving a key with undefined; the dialog turns it into ''.
|
||||
expect(
|
||||
preserveUnchangedArgumentExpressions([stringArg("date", "")], initialTokens, initial, {}),
|
||||
).toEqual([stringArg("date", "")]);
|
||||
expect(
|
||||
preserveUnchangedArgumentExpressions([stringArg("date", "")], initialTokens, {}, {}),
|
||||
).toEqual([stringArg("date", "")]);
|
||||
});
|
||||
|
||||
test("keeps absence, defaults, explicit empty strings, and false distinct", async () => {
|
||||
const fn: TemplateFunction = {
|
||||
name: "test",
|
||||
args: [
|
||||
{ type: "text", name: "absent" },
|
||||
{ type: "text", name: "defaulted", defaultValue: "fallback" },
|
||||
{ type: "text", name: "empty", defaultValue: "fallback" },
|
||||
{ type: "checkbox", name: "flag", defaultValue: "true" },
|
||||
{ type: "checkbox", name: "defaultFlag", defaultValue: "false" },
|
||||
],
|
||||
};
|
||||
const initialTokens = parse("${[ test(empty='', flag=false) ]}");
|
||||
const print = vi.fn<(_: Tokens) => Promise<string>>();
|
||||
const initial = await collectEditableArgumentValues(initialTokens, fn, print);
|
||||
expect(initial).toEqual({
|
||||
defaulted: "fallback",
|
||||
empty: "",
|
||||
flag: false,
|
||||
defaultFlag: "false",
|
||||
});
|
||||
expect(print).not.toHaveBeenCalled();
|
||||
const formArgs: FnArg[] = [
|
||||
stringArg("defaulted", "fallback"),
|
||||
stringArg("empty", ""),
|
||||
{ name: "flag", value: { type: "bool", value: false } },
|
||||
stringArg("defaultFlag", "false"),
|
||||
];
|
||||
expect(preserveUnchangedArgumentExpressions(formArgs, initialTokens, initial, initial)).toEqual(
|
||||
formArgs,
|
||||
);
|
||||
});
|
||||
|
||||
test("an expression overrides defaults, including inputs in a group", async () => {
|
||||
const initialTokens = parse("${[ test(date=dates.previous) ]}");
|
||||
const initial = await collectEditableArgumentValues(
|
||||
initialTokens,
|
||||
{
|
||||
name: "test",
|
||||
args: [
|
||||
{ type: "h_stack", inputs: [{ type: "text", name: "date", defaultValue: "fallback" }] },
|
||||
],
|
||||
},
|
||||
printer("${[ dates.previous ]}"),
|
||||
);
|
||||
expect(initial).toEqual({ date: "${[ dates.previous ]}" });
|
||||
});
|
||||
|
||||
test("explicit null keeps the existing default and omission behavior", async () => {
|
||||
const initialTokens = parse("${[ test(defaulted=null, omitted=null) ]}");
|
||||
const print = vi.fn<(_: Tokens) => Promise<string>>();
|
||||
const initial = await collectEditableArgumentValues(
|
||||
initialTokens,
|
||||
{
|
||||
name: "test",
|
||||
args: [
|
||||
{ type: "text", name: "defaulted", defaultValue: "fallback" },
|
||||
{ type: "text", name: "omitted" },
|
||||
],
|
||||
},
|
||||
print,
|
||||
);
|
||||
expect(initial).toEqual({ defaulted: "fallback" });
|
||||
expect(print).not.toHaveBeenCalled();
|
||||
expect(
|
||||
preserveUnchangedArgumentExpressions(
|
||||
[stringArg("defaulted", "fallback")],
|
||||
initialTokens,
|
||||
initial,
|
||||
initial,
|
||||
),
|
||||
).toEqual([stringArg("defaulted", "fallback")]);
|
||||
});
|
||||
|
||||
test("keeps a deeply nested expression's AST without adding string wrappers", async () => {
|
||||
// This shape reaches the native renderer's depth limit. A quoted wrapper around
|
||||
// the date expression would exceed it, even though shallow calls render identically.
|
||||
const expression =
|
||||
"timestamp.iso8601(date=".repeat(44) + "'2026-10-26T10:20:30.000Z'" + ")".repeat(44);
|
||||
const initialTokens = parse(`\${[ timestamp.iso8601(date=${expression}) ]}`);
|
||||
const initial = await collectEditableArgumentValues(
|
||||
initialTokens,
|
||||
definition,
|
||||
printer(`\${[ ${expression} ]}`),
|
||||
);
|
||||
const saved = preserveUnchangedArgumentExpressions(
|
||||
[stringArg("date", String(initial.date))],
|
||||
initialTokens,
|
||||
initial,
|
||||
initial,
|
||||
);
|
||||
expect(saved).toEqual(args(initialTokens));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { TemplateFunction } from "@yaakapp-internal/plugins";
|
||||
import type { FnArg, Tokens } from "@yaakapp-internal/templates";
|
||||
import { collectArgumentValues } from "../components/core/Editor/twig/util";
|
||||
|
||||
type ArgumentValues = Record<string, string | boolean>;
|
||||
|
||||
function functionArgs(tokens: Tokens): FnArg[] {
|
||||
const first = tokens.tokens[0];
|
||||
return first?.type === "tag" && first.val.type === "fn" ? first.val.args : [];
|
||||
}
|
||||
|
||||
/** Adapt expressions to the template text that the form's editors understand. */
|
||||
export async function collectEditableArgumentValues(
|
||||
tokens: Tokens,
|
||||
templateFunction: TemplateFunction,
|
||||
tokensToString: (tokens: Tokens) => Promise<string>,
|
||||
): Promise<ArgumentValues> {
|
||||
const first = tokens.tokens[0];
|
||||
if (first?.type !== "tag" || first.val.type !== "fn") {
|
||||
return collectArgumentValues(tokens, templateFunction);
|
||||
}
|
||||
|
||||
const args = await Promise.all(
|
||||
first.val.args.map(async (arg): Promise<FnArg> => {
|
||||
if (arg.value.type !== "fn" && arg.value.type !== "var") return arg;
|
||||
const text = await tokensToString({ tokens: [{ type: "tag", val: arg.value }] });
|
||||
return { ...arg, value: { type: "str", text } };
|
||||
}),
|
||||
);
|
||||
|
||||
return collectArgumentValues(
|
||||
{ tokens: [{ ...first, val: { ...first.val, args } }] },
|
||||
templateFunction,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Displaying an expression as template text must not change its stored type: a string
|
||||
* wrapper adds rendering depth. Only deliberately changed inputs use the form's new values.
|
||||
*/
|
||||
export function preserveUnchangedArgumentExpressions(
|
||||
args: FnArg[],
|
||||
initialTokens: Tokens,
|
||||
initialValues: ArgumentValues,
|
||||
values: ArgumentValues,
|
||||
): FnArg[] {
|
||||
const initialArgs = functionArgs(initialTokens);
|
||||
return args.map((arg) => {
|
||||
const initial = initialArgs.find((a) => a.name === arg.name);
|
||||
if (
|
||||
(initial?.value.type === "fn" || initial?.value.type === "var") &&
|
||||
Object.hasOwn(initialValues, arg.name) &&
|
||||
values[arg.name] === initialValues[arg.name]
|
||||
) {
|
||||
return initial;
|
||||
}
|
||||
return arg;
|
||||
});
|
||||
}
|
||||
@@ -1,137 +1,2 @@
|
||||
/*
|
||||
* Finding template tags in text.
|
||||
*
|
||||
* A tag closes at `]}`, but a quoted argument is allowed to contain `]}` of its own — the
|
||||
* printer in `crates/yaak-templates` emits `${[ fn(a='x]}y') ]}` verbatim — so the scan has to
|
||||
* step over quoted strings. It mirrors the Lezer grammar in
|
||||
* `components/core/Editor/twig/twig.grammar`: a terminated `'…'` (where `\` escapes whatever
|
||||
* follows it) is consumed whole, and an unterminated quote is just another character, so a
|
||||
* half-typed tag still closes at the next `]}` instead of swallowing the rest of the document.
|
||||
*
|
||||
* This was a regex until CodeQL flagged it (js/polynomial-redos): matching an unterminated
|
||||
* quote needed a negative lookahead that rescanned to the end of the input at every unpaired
|
||||
* quote, which is quadratic on text an attacker picks. The scan below is linear instead — see
|
||||
* the notes on `closingQuoteTable` and on the dead-end set in `findTemplateTags` for why.
|
||||
*/
|
||||
|
||||
const QUOTE = 0x27; // '
|
||||
const BACKSLASH = 0x5c; // \
|
||||
const CLOSE_BRACKET = 0x5d; // ]
|
||||
const CLOSE_BRACE = 0x7d; // }
|
||||
|
||||
/** The `${[`…`]}` bounds of one tag, and what sits between them. */
|
||||
export interface TemplateTagMatch {
|
||||
/** Index of the `$` that opens the tag. */
|
||||
start: number;
|
||||
/** Index just past the closing `]}`. */
|
||||
end: number;
|
||||
/** Everything between `${[` and `]}`, not trimmed. */
|
||||
inner: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* For every index `i`, the index of the `'` that closes a string whose body starts at `i`, or
|
||||
* `-1` when nothing does.
|
||||
*
|
||||
* Built right to left in a single pass, which is the trick that replaces the regex lookahead:
|
||||
* whether a quote opens a terminated string depends only on the text after it, so the answer
|
||||
* can be computed once for every position instead of re-derived at each quote. Skipping a
|
||||
* string of any length then costs one lookup.
|
||||
*/
|
||||
function closingQuoteTable(text: string): Int32Array {
|
||||
// Two extra slots so a `\` in the last position can read past the end and find `-1`
|
||||
const table = new Int32Array(text.length + 2).fill(-1);
|
||||
for (let i = text.length - 1; i >= 0; i--) {
|
||||
const c = text.charCodeAt(i);
|
||||
// A backslash escapes whatever follows it, including a quote
|
||||
const next = c === BACKSLASH ? table[i + 2] : table[i + 1];
|
||||
table[i] = c === QUOTE ? i : (next ?? -1);
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds every template tag in `text`, in order and without overlaps.
|
||||
*
|
||||
* A `${[` with no `]}` after it isn't a tag, and neither is a `${[` nested inside a tag body —
|
||||
* both are ordinary content, same as the regex this replaced treated them.
|
||||
*/
|
||||
export function findTemplateTags(text: string): TemplateTagMatch[] {
|
||||
const matches: TemplateTagMatch[] = [];
|
||||
|
||||
// Both built only once a scan actually needs them, so short values allocate nothing
|
||||
let closingQuotes: Int32Array | null = null;
|
||||
|
||||
/*
|
||||
* Body positions a previous scan already ran off the end of the text from. Where a scan ends
|
||||
* up depends only on where it starts, so a scan that walks into one of these would repeat
|
||||
* that same walk and fail the same way, and can stop right there.
|
||||
*
|
||||
* That bound is what keeps the whole pass linear: every position enters this set at most
|
||||
* once, and every step of every scan either visits a position for the first time or ends
|
||||
* that scan.
|
||||
*/
|
||||
let deadEnds: Set<number> | null = null;
|
||||
|
||||
let searchFrom = 0;
|
||||
while (searchFrom < text.length) {
|
||||
const start = text.indexOf("${[", searchFrom);
|
||||
if (start < 0) break;
|
||||
|
||||
const visited: number[] = [];
|
||||
let end = -1;
|
||||
let i = start + 3;
|
||||
while (i < text.length) {
|
||||
if (deadEnds?.has(i)) break;
|
||||
visited.push(i);
|
||||
|
||||
const c = text.charCodeAt(i);
|
||||
if (c === QUOTE) {
|
||||
closingQuotes ??= closingQuoteTable(text);
|
||||
const close = closingQuotes[i + 1] ?? -1;
|
||||
// An unterminated quote is an ordinary character, so only step over a closed string
|
||||
i = close < 0 ? i + 1 : close + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c === CLOSE_BRACKET && text.charCodeAt(i + 1) === CLOSE_BRACE) {
|
||||
end = i + 2;
|
||||
break;
|
||||
}
|
||||
|
||||
i += 1;
|
||||
}
|
||||
|
||||
if (end < 0) {
|
||||
// Not a tag. Resume past the opener: a real tag may start inside a string this scan
|
||||
// stepped over.
|
||||
deadEnds ??= new Set();
|
||||
for (const position of visited) deadEnds.add(position);
|
||||
searchFrom = start + 3;
|
||||
continue;
|
||||
}
|
||||
|
||||
matches.push({ start, end, inner: text.slice(start + 3, end - 2) });
|
||||
searchFrom = end;
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/** Rewrites every template tag in `text` with whatever `replace` returns for it. */
|
||||
export function replaceTemplateTags(
|
||||
text: string,
|
||||
replace: (match: TemplateTagMatch) => string,
|
||||
): string {
|
||||
const matches = findTemplateTags(text);
|
||||
if (matches.length === 0) return text;
|
||||
|
||||
let result = "";
|
||||
let copiedTo = 0;
|
||||
for (const match of matches) {
|
||||
result += text.slice(copiedTo, match.start) + replace(match);
|
||||
copiedTo = match.end;
|
||||
}
|
||||
|
||||
return result + text.slice(copiedTo);
|
||||
}
|
||||
export type { TemplateTagMatch } from "@yaakapp-internal/ui/templateTags";
|
||||
export { findTemplateTags, replaceTemplateTags } from "@yaakapp-internal/ui/templateTags";
|
||||
@@ -1,50 +1,25 @@
|
||||
import { atom } from "jotai";
|
||||
import type { ToastInstance } from "../components/Toasts";
|
||||
import { generateId } from "@yaakapp-internal/lib/generateId";
|
||||
// The toast store alone, without the components, which tests in a plain runtime can't load
|
||||
import {
|
||||
hideToast as hideToastIn,
|
||||
hideToastById as hideToastByIdIn,
|
||||
type ShowToastArgs,
|
||||
showToast as showToastIn,
|
||||
type ToastInstance,
|
||||
} from "@yaakapp-internal/ui/toast";
|
||||
import { jotaiStore } from "./jotai";
|
||||
|
||||
export const toastsAtom = atom<ToastInstance[]>([]);
|
||||
export { toastsAtom } from "@yaakapp-internal/ui/toast";
|
||||
|
||||
export function showToast({
|
||||
id,
|
||||
timeout = 5000,
|
||||
...props
|
||||
}: Omit<ToastInstance, "id" | "timeout" | "uniqueKey"> & {
|
||||
id?: ToastInstance["id"];
|
||||
timeout?: ToastInstance["timeout"];
|
||||
}) {
|
||||
id = id ?? generateId();
|
||||
const uniqueKey = generateId();
|
||||
|
||||
const toasts = jotaiStore.get(toastsAtom);
|
||||
const toastWithSameId = toasts.find((t) => t.id === id);
|
||||
|
||||
let delay = 0;
|
||||
if (toastWithSameId) {
|
||||
hideToast(toastWithSameId);
|
||||
// Allow enough time for old toast to animate out
|
||||
delay = 200;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
const newToast: ToastInstance = { id, uniqueKey, timeout, ...props };
|
||||
jotaiStore.set(toastsAtom, (prev) => [...prev, newToast]);
|
||||
}, delay);
|
||||
|
||||
return id;
|
||||
export function showToast(args: ShowToastArgs) {
|
||||
return showToastIn(jotaiStore, args);
|
||||
}
|
||||
|
||||
export function hideToastById(id: string) {
|
||||
const toast = jotaiStore.get(toastsAtom).find((t) => t.id === id);
|
||||
if (toast) hideToast(toast);
|
||||
hideToastByIdIn(jotaiStore, id);
|
||||
}
|
||||
|
||||
export function hideToast(toHide: ToastInstance) {
|
||||
jotaiStore.set(toastsAtom, (all) => {
|
||||
const t = all.find((t) => t.uniqueKey === toHide.uniqueKey);
|
||||
t?.onClose?.();
|
||||
return all.filter((t) => t.uniqueKey !== toHide.uniqueKey);
|
||||
});
|
||||
hideToastIn(jotaiStore, toHide);
|
||||
}
|
||||
|
||||
export function showErrorToast<T>({
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"@codemirror/lang-xml": "^6.1.0",
|
||||
"@codemirror/lang-yaml": "^6.1.2",
|
||||
"@codemirror/language": "^6.13.1",
|
||||
"@codemirror/merge": "^6.11.2",
|
||||
"@codemirror/merge": "^6.12.3",
|
||||
"@codemirror/search": "^6.5.11",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@gilbarbara/deep-equal": "^0.4.1",
|
||||
@@ -27,17 +27,17 @@
|
||||
"@replit/codemirror-vim": "^6.3.0",
|
||||
"@replit/codemirror-vscode-keymap": "^6.0.2",
|
||||
"@shopify/lang-jsonc": "^1.0.1",
|
||||
"@tanstack/react-query": "^5.104.2",
|
||||
"@tanstack/react-router": "^1.170.42",
|
||||
"@tanstack/react-virtual": "^3.14.17",
|
||||
"@tauri-apps/api": "^2.12.3",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.4.2",
|
||||
"@tauri-apps/plugin-dialog": "^2.8.2",
|
||||
"@tauri-apps/plugin-fs": "^2.7.0",
|
||||
"@tanstack/react-query": "^5.105.0",
|
||||
"@tanstack/react-router": "^1.170.46",
|
||||
"@tanstack/react-virtual": "^3.14.24",
|
||||
"@tauri-apps/api": "^2.12.4",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.5.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.8.4",
|
||||
"@tauri-apps/plugin-fs": "^2.7.1",
|
||||
"@tauri-apps/plugin-log": "^2.10.1",
|
||||
"@tauri-apps/plugin-opener": "^2.7.1",
|
||||
"@tauri-apps/plugin-opener": "^2.7.2",
|
||||
"@tauri-apps/plugin-os": "^2.4.0",
|
||||
"@tauri-apps/plugin-shell": "^2.5.0",
|
||||
"@tauri-apps/plugin-shell": "^2.6.0",
|
||||
"buffer": "^6.0.3",
|
||||
"classnames": "^2.5.1",
|
||||
"cm6-graphql": "^0.2.1",
|
||||
@@ -48,21 +48,20 @@
|
||||
"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",
|
||||
"js-md5": "^0.9.2",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"lucide-react": "^1.55.0",
|
||||
"mime": "^4.1.1",
|
||||
"motion": "^14.0.0",
|
||||
"lucide-react": "^1.64.0",
|
||||
"mime": "^4.1.2",
|
||||
"motion": "^14.8.0",
|
||||
"nanoid": "^6.0.2",
|
||||
"papaparse": "^5.4.1",
|
||||
"parse-color": "^1.0.0",
|
||||
"react": "^19.2.0",
|
||||
"react": "^19.3.1",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-pdf": "^11.0.0",
|
||||
"react-syntax-highlighter": "^16.1.2",
|
||||
@@ -78,8 +77,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lezer/generator": "^1.8.1",
|
||||
"@rolldown/plugin-babel": "^0.2.3",
|
||||
"@tanstack/router-plugin": "^1.168.42",
|
||||
"@rolldown/plugin-babel": "^0.3.0",
|
||||
"@tanstack/router-plugin": "^1.168.48",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"@types/node": "^22.20.5",
|
||||
"@types/papaparse": "^5.3.16",
|
||||
@@ -93,10 +92,11 @@
|
||||
"@yaakapp-internal/theme": "^1.0.0",
|
||||
"@yaakapp-internal/ui": "^1.0.0",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"rollup": "^4.64.3",
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.3.3",
|
||||
"happy-dom": "^21.0.4",
|
||||
"rollup": "^4.66.0",
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@^1.4.0",
|
||||
"vite-plugin-static-copy": "^4.1.1",
|
||||
"vite-plugin-wasm": "^3.5.0",
|
||||
"vite-plus": "^0.3.3"
|
||||
"vite-plus": "^1.4.0"
|
||||
}
|
||||
}
|
||||
Loaded 100 of 566 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user