mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
Compare commits
67
Commits
No files matched your search
@@ -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
+119
@@ -4156,6 +4156,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"openssl-sys",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
@@ -6480,6 +6481,70 @@ dependencies = [
|
||||
"prost",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "protoc-bin-vendored"
|
||||
version = "3.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8760a25b6ff9c620324822737e468478fa092234190d2e449760344354896ed9"
|
||||
dependencies = [
|
||||
"protoc-bin-vendored-linux-aarch_64",
|
||||
"protoc-bin-vendored-linux-ppcle_64",
|
||||
"protoc-bin-vendored-linux-s390_64",
|
||||
"protoc-bin-vendored-linux-x86_32",
|
||||
"protoc-bin-vendored-linux-x86_64",
|
||||
"protoc-bin-vendored-macos-aarch_64",
|
||||
"protoc-bin-vendored-macos-x86_64",
|
||||
"protoc-bin-vendored-win32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "protoc-bin-vendored-linux-aarch_64"
|
||||
version = "3.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73fa2624782ca04cd44f51554566717377acd240e4c0016d757dd74fccc9324f"
|
||||
|
||||
[[package]]
|
||||
name = "protoc-bin-vendored-linux-ppcle_64"
|
||||
version = "3.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2417e9817fa237dab803ad4dda7357a111656e242959cc6b8f9a1a583367d42"
|
||||
|
||||
[[package]]
|
||||
name = "protoc-bin-vendored-linux-s390_64"
|
||||
version = "3.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d189c34636356a46a7ed3188233dc8a88c431278cc54d4a19b096a2d270e985"
|
||||
|
||||
[[package]]
|
||||
name = "protoc-bin-vendored-linux-x86_32"
|
||||
version = "3.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "171e39f1e846e5f322ced1ac3b8d4cd3a3833ca24b6e5d58b3632574fe6204fa"
|
||||
|
||||
[[package]]
|
||||
name = "protoc-bin-vendored-linux-x86_64"
|
||||
version = "3.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "873cdcc097593432086661aa432b8078f1cd87bfb02847c332e98ae2c119e966"
|
||||
|
||||
[[package]]
|
||||
name = "protoc-bin-vendored-macos-aarch_64"
|
||||
version = "3.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eeb72df001783b8297847fe8f5f874ee400fd742c843d60583e8c23d96977c7f"
|
||||
|
||||
[[package]]
|
||||
name = "protoc-bin-vendored-macos-x86_64"
|
||||
version = "3.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b04652167eca899dda05f32f5481adeaf25c623a98ce2fc146a001cc59a2add7"
|
||||
|
||||
[[package]]
|
||||
name = "protoc-bin-vendored-win32"
|
||||
version = "3.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "263a3f48f01e7309e857138bd47f785585b4a005e8e56c6d2824ce91195999c3"
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "1.2.3"
|
||||
@@ -11442,6 +11507,7 @@ dependencies = [
|
||||
"yaak-tauri-utils",
|
||||
"yaak-templates",
|
||||
"yaak-tls",
|
||||
"yaak-updater",
|
||||
"yaak-window",
|
||||
"yaak-ws",
|
||||
]
|
||||
@@ -11457,9 +11523,12 @@ dependencies = [
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-os",
|
||||
"tauri-plugin-updater",
|
||||
"tokio",
|
||||
"yaak-mac-window",
|
||||
"yaak-proxy-lib",
|
||||
"yaak-rpc",
|
||||
"yaak-updater",
|
||||
"yaak-window",
|
||||
]
|
||||
|
||||
@@ -11512,6 +11581,7 @@ dependencies = [
|
||||
name = "yaak-commands"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"rusqlite",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.21",
|
||||
@@ -11548,9 +11618,11 @@ dependencies = [
|
||||
"base32",
|
||||
"base64 0.23.1",
|
||||
"chacha20poly1305",
|
||||
"dbus-secret-service",
|
||||
"keyring",
|
||||
"log 0.4.34",
|
||||
"serde",
|
||||
"tempfile",
|
||||
"thiserror 2.0.21",
|
||||
"yaak-models",
|
||||
]
|
||||
@@ -11564,6 +11636,7 @@ dependencies = [
|
||||
"include_dir",
|
||||
"log 0.4.34",
|
||||
"nanoid",
|
||||
"openssl-sys",
|
||||
"r2d2",
|
||||
"r2d2_sqlite",
|
||||
"rusqlite",
|
||||
@@ -11571,6 +11644,7 @@ dependencies = [
|
||||
"sea-query-rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.21",
|
||||
"ts-rs",
|
||||
"uuid",
|
||||
@@ -11813,12 +11887,17 @@ dependencies = [
|
||||
name = "yaak-proxy"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"block2 0.6.1",
|
||||
"bytes",
|
||||
"dispatch2",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"libc",
|
||||
"log 0.4.34",
|
||||
"pem",
|
||||
"rcgen",
|
||||
@@ -11830,6 +11909,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-tungstenite",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -11852,8 +11932,10 @@ dependencies = [
|
||||
"base64 0.23.1",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"core-foundation 0.9.4",
|
||||
"encoding_rs",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
@@ -11865,29 +11947,49 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
"objc2-service-management",
|
||||
"plist",
|
||||
"protoc-bin-vendored",
|
||||
"r2d2",
|
||||
"r2d2_sqlite",
|
||||
"reqwest",
|
||||
"rusqlite",
|
||||
"rustls",
|
||||
"sea-query",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"system-configuration",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tonic",
|
||||
"tonic-reflection",
|
||||
"ts-rs",
|
||||
"url",
|
||||
"yaak-common",
|
||||
"yaak-crypto",
|
||||
"yaak-database",
|
||||
"yaak-filter",
|
||||
"yaak-grpc",
|
||||
"yaak-http",
|
||||
"yaak-lifecycle",
|
||||
"yaak-models",
|
||||
"yaak-proxy",
|
||||
"yaak-proxy-helper",
|
||||
"yaak-query",
|
||||
"yaak-rpc",
|
||||
"yaak-templates",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaak-query"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde-wasm-bindgen",
|
||||
"serde_json",
|
||||
"ts-rs",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaak-rpc"
|
||||
version = "0.0.0"
|
||||
@@ -11995,6 +12097,23 @@ dependencies = [
|
||||
"yasna 0.6.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaak-updater"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"log 0.4.34",
|
||||
"md5",
|
||||
"nanoid",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-updater",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
"ts-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaak-wasm"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -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",
|
||||
@@ -43,6 +44,7 @@ members = [
|
||||
"crates-tauri/yaak-proxy-helper",
|
||||
"crates-tauri/yaak-system-appearance",
|
||||
"crates-tauri/yaak-tauri-utils",
|
||||
"crates-tauri/yaak-updater",
|
||||
"crates-tauri/yaak-window",
|
||||
]
|
||||
|
||||
@@ -96,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" }
|
||||
@@ -114,6 +117,7 @@ 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,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,105 +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_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,7 +1,7 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import type { AgentSetupStatus, AppMetaData } from "@yaakapp-internal/rpc-schema";
|
||||
import { Banner, Heading, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import { Banner, Icon, InlineCode, SegmentedControl, SettingsPage } from "@yaakapp-internal/ui";
|
||||
import { useEffect } from "react";
|
||||
import { useKeyValue } from "../../hooks/useKeyValue";
|
||||
import { usePluginInfos } from "../../hooks/usePluginInfo";
|
||||
@@ -14,13 +14,7 @@ import {
|
||||
import { appInfo } from "../../lib/appInfo";
|
||||
import { rpc } from "../../lib/rpc";
|
||||
import { Link } from "../core/Link";
|
||||
import { SegmentedControl } from "../core/SegmentedControl";
|
||||
import {
|
||||
SettingCommand,
|
||||
SettingSnippet,
|
||||
SettingsList,
|
||||
SettingsSection,
|
||||
} from "../core/SettingRow";
|
||||
import { SettingCommand, SettingSnippet, SettingsList, SettingsSection } from "../core/SettingRow";
|
||||
import { DeprecatedPluginBanner } from "./SettingsPlugins";
|
||||
|
||||
const CLI_INSTALL_COMMAND = "npm install -g @yaakapp/cli";
|
||||
@@ -57,13 +51,10 @@ export function SettingsAgents() {
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<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">
|
||||
@@ -135,6 +126,6 @@ export function SettingsAgents() {
|
||||
/>
|
||||
</SettingsSection>
|
||||
</SettingsList>
|
||||
</VStack>
|
||||
</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} />;
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,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,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,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,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,260 +1,36 @@
|
||||
import type { AnyModel } from "@yaakapp-internal/models";
|
||||
import { patchModel } from "@yaakapp-internal/models";
|
||||
import { Icon } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import type { ReactNode } from "react";
|
||||
import { CopyIconButton } from "../CopyIconButton";
|
||||
import { Checkbox } from "./Checkbox";
|
||||
import { IconButton, type IconButtonProps } from "./IconButton";
|
||||
import { PlainInput } from "./PlainInput";
|
||||
import type { RadioDropdownItem } from "./RadioDropdown";
|
||||
import { Select } from "./Select";
|
||||
import {
|
||||
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" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A command or config to copy, set as a block: its title, an optional line
|
||||
* saying which file it goes in, then the snippet at full width with the copy
|
||||
* button on it. One-line shell commands and multi-line JSON get the same block.
|
||||
*/
|
||||
export function SettingCommand({
|
||||
className,
|
||||
description,
|
||||
disabled,
|
||||
done,
|
||||
language,
|
||||
snippet,
|
||||
title,
|
||||
}: {
|
||||
description?: ReactNode;
|
||||
disabled?: boolean;
|
||||
/** A short word shown with a check beside the title once the step is detected as done */
|
||||
done?: ReactNode;
|
||||
language: SettingSnippetLanguage;
|
||||
snippet: string;
|
||||
title: ReactNode;
|
||||
} & Pick<SettingRowBaseProps, "className">) {
|
||||
return (
|
||||
<div
|
||||
aria-disabled={disabled || undefined}
|
||||
className={classNames(
|
||||
className,
|
||||
"border-b border-border-subtle py-4",
|
||||
disabled && "opacity-disabled",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2 text text-text">
|
||||
<span>{title}</span>
|
||||
{done != null && done !== false && (
|
||||
<span className="inline-flex items-center gap-1 text-sm text-success">
|
||||
<Icon icon="check" size="sm" />
|
||||
{done}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{description != null && (
|
||||
<div className="mt-1 max-w-2xl text-sm text-text-subtle">{description}</div>
|
||||
)}
|
||||
<SettingSnippet className="mt-3" language={language} snippet={snippet} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type SettingSnippetLanguage = "shell" | "json" | "text";
|
||||
|
||||
/** The snippet half of a `SettingCommand`, for places with their own title, like a banner. */
|
||||
export function SettingSnippet({
|
||||
className,
|
||||
language,
|
||||
snippet,
|
||||
}: {
|
||||
className?: string;
|
||||
language: SettingSnippetLanguage;
|
||||
snippet: string;
|
||||
}) {
|
||||
const prompt = language === "shell";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
"flex items-start gap-2 rounded-md border border-border-subtle bg-surface-highlight/40 py-1.5 pr-1.5 pl-3",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-h-sm min-w-0 flex-1 items-center font-mono text-editor text-text">
|
||||
<span className="cursor-text select-text whitespace-pre-wrap [overflow-wrap:anywhere]">
|
||||
{prompt && (
|
||||
<span aria-hidden className="mr-2 select-none text-text-subtlest">
|
||||
$
|
||||
</span>
|
||||
)}
|
||||
{snippet}
|
||||
</span>
|
||||
</div>
|
||||
<CopyIconButton size="sm" variant="border" title="Copy" text={snippet} className="shrink-0" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type SettingValueAction = {
|
||||
icon: IconButtonProps["icon"];
|
||||
onClick: () => void;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export function SettingRowBoolean({
|
||||
checked,
|
||||
checkboxSize = "md",
|
||||
onChange,
|
||||
title,
|
||||
...props
|
||||
}: {
|
||||
checked: boolean;
|
||||
checkboxSize?: "sm" | "md";
|
||||
onChange: (checked: boolean) => void;
|
||||
} & SettingRowBaseProps) {
|
||||
return (
|
||||
<SettingRow title={title} {...props}>
|
||||
<Checkbox
|
||||
hideLabel
|
||||
size={checkboxSize}
|
||||
checked={checked}
|
||||
disabled={props.disabled}
|
||||
title={title}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</SettingRow>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModelSettingRowBoolean<M extends AnyModel, K extends ModelKeyOfValue<M, boolean>>({
|
||||
model,
|
||||
modelKey,
|
||||
@@ -272,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,
|
||||
@@ -334,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,
|
||||
@@ -438,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>,
|
||||
@@ -549,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,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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -243,6 +243,7 @@ export function useHotkeyContextValue(): HotkeyContextValue {
|
||||
() => ({
|
||||
os: platform.osType(),
|
||||
bindingFor: (action: HotkeyAction) => hotkeys[action] ?? null,
|
||||
labelFor: (action: HotkeyAction) => hotkeyLabels[action],
|
||||
register: registerHotkey,
|
||||
}),
|
||||
[hotkeys],
|
||||
|
||||
@@ -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,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";
|
||||
@@ -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";
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6,14 +6,16 @@ import type {
|
||||
HttpResponseHeader,
|
||||
} from "@yaakapp-internal/models";
|
||||
|
||||
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") {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import classNames from "classnames";
|
||||
import { Provider as JotaiProvider } from "jotai";
|
||||
import { LazyMotion, MotionConfig } from "motion/react";
|
||||
import { lazy, type ReactNode, Suspense } from "react";
|
||||
import { ClientEditorHost } from "../components/ClientEditorHost";
|
||||
import { GlobalHooks } from "../components/GlobalHooks";
|
||||
import RouteError from "../components/RouteError";
|
||||
import { useHotkeyContextValue } from "../hooks/useHotKey";
|
||||
@@ -30,17 +31,19 @@ function RouteComponent() {
|
||||
<JotaiProvider store={jotaiStore}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Hotkeys>
|
||||
<LazyMotion strict features={motionFeatures}>
|
||||
<MotionConfig transition={{ duration: 0.1 }}>
|
||||
<Suspense>
|
||||
<Toasts />
|
||||
<Dialogs />
|
||||
<ContextMenus />
|
||||
</Suspense>
|
||||
<Layout />
|
||||
<GlobalHooks />
|
||||
</MotionConfig>
|
||||
</LazyMotion>
|
||||
<ClientEditorHost>
|
||||
<LazyMotion strict features={motionFeatures}>
|
||||
<MotionConfig transition={{ duration: 0.1 }}>
|
||||
<Suspense>
|
||||
<Toasts />
|
||||
<Dialogs />
|
||||
<ContextMenus />
|
||||
</Suspense>
|
||||
<Layout />
|
||||
<GlobalHooks />
|
||||
</MotionConfig>
|
||||
</LazyMotion>
|
||||
</ClientEditorHost>
|
||||
</Hotkeys>
|
||||
</QueryClientProvider>
|
||||
</JotaiProvider>
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { startCompletion } from "@codemirror/autocomplete";
|
||||
import { Compartment, EditorState, type Extension } from "@codemirror/state";
|
||||
import { EditorView, ViewPlugin } from "@codemirror/view";
|
||||
import type { Schema } from "@yaakapp-internal/query";
|
||||
import {
|
||||
Dialog,
|
||||
DialogFooter,
|
||||
Input,
|
||||
type InputHandle,
|
||||
LazyCodeEditor,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { queryFilter } from "@yaakapp-internal/ui/editor";
|
||||
import { atom, useAtom, useSetAtom } from "jotai";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRpcQuery } from "../hooks/useRpcQuery";
|
||||
import {
|
||||
addCustomColumn,
|
||||
customPath,
|
||||
customPathProblem,
|
||||
fieldReferenceChanges,
|
||||
} from "../lib/columns";
|
||||
import { loadSeenValues } from "../lib/searchBox";
|
||||
import { columnPrefsAtom } from "../lib/store";
|
||||
|
||||
/** The Add Column dialog is open */
|
||||
export const addColumnDialogAtom = atom(false);
|
||||
|
||||
const FORM_ID = "add-column";
|
||||
|
||||
/** Keeps the field a reference to a field, `@header.x-cache`, whatever was typed or pasted */
|
||||
const asFieldReference = EditorState.transactionFilter.of((tr) => {
|
||||
if (!tr.docChanged) return tr;
|
||||
const changes = fieldReferenceChanges(tr.newDoc.toString());
|
||||
return changes.length === 0 ? tr : [tr, { changes, sequential: true }];
|
||||
});
|
||||
|
||||
/** Opens completion on the fields while the field is focused and empty */
|
||||
const completeWhenEmpty = [
|
||||
ViewPlugin.define((view) => {
|
||||
if (view.hasFocus && view.state.doc.length === 0) {
|
||||
queueMicrotask(() => startCompletion(view));
|
||||
}
|
||||
return {};
|
||||
}),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.focusChanged && update.view.hasFocus && update.state.doc.length === 0) {
|
||||
startCompletion(update.view);
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
/**
|
||||
* Adds a column for a header or query parameter, named by a reference to its field as the search
|
||||
* names it, `@header.x-request-id` or `@query.page`, completed from the fields and the names seen
|
||||
*/
|
||||
export function AddColumnDialog() {
|
||||
const [open, setOpen] = useAtom(addColumnDialogAtom);
|
||||
if (!open) return null;
|
||||
return <AddColumnForm onClose={() => setOpen(false)} />;
|
||||
}
|
||||
|
||||
function AddColumnForm({ onClose }: { onClose: () => void }) {
|
||||
const [text, setText] = useState("");
|
||||
const setPrefs = useSetAtom(columnPrefsAtom);
|
||||
const handleRef = useRef<InputHandle | null>(null);
|
||||
const schema = useRpcQuery("query_schema", {}, { staleTime: Infinity }).data?.schema;
|
||||
// Only the fields a column can show: the maps of headers and query parameters. It goes in
|
||||
// once the schema is read, through a compartment, as the search box's does.
|
||||
const compartment = useRef(new Compartment());
|
||||
const filter = useMemo<Extension>(() => {
|
||||
if (schema == null) return [];
|
||||
const maps: Schema = { ...schema, fields: schema.fields.filter((f) => f.map) };
|
||||
return [
|
||||
queryFilter({ schema: maps, loadValues: loadSeenValues, fieldsOnly: true }),
|
||||
completeWhenEmpty,
|
||||
];
|
||||
}, [schema]);
|
||||
const extensions = useMemo(() => [asFieldReference, compartment.current.of(filter)], [filter]);
|
||||
useEffect(() => {
|
||||
handleRef.current?.dispatch({ effects: compartment.current.reconfigure(filter) });
|
||||
}, [filter]);
|
||||
const path = customPath(text);
|
||||
const problem = customPathProblem(text);
|
||||
|
||||
const submit = () => {
|
||||
if (path == null) return;
|
||||
setPrefs((p) => addCustomColumn(p, path));
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onClose={onClose} size="dynamic" title="Add Column" className="w-[26rem]">
|
||||
<form
|
||||
id={FORM_ID}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
renderEditor={(props) => <LazyCodeEditor {...props} />}
|
||||
setRef={(h) => {
|
||||
handleRef.current = h;
|
||||
}}
|
||||
label="Field"
|
||||
autoFocus
|
||||
className="font-mono text-xs"
|
||||
stateKey={null}
|
||||
language={null}
|
||||
placeholder="@header.x-request-id"
|
||||
defaultValue=""
|
||||
onChange={setText}
|
||||
validate={problem == null}
|
||||
extensions={extensions}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.defaultPrevented) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="h-5 pt-1 text-xs text-danger">{problem}</div>
|
||||
</form>
|
||||
<DialogFooter
|
||||
actions={[
|
||||
{ label: "Cancel", variant: "border", onClick: onClose },
|
||||
{ label: "Add", color: "primary", form: FORM_ID, disabled: path == null },
|
||||
]}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -24,8 +24,8 @@ function loadIcon(path: string): Promise<string | null> {
|
||||
return promise;
|
||||
}
|
||||
|
||||
/** An app's icon, read once per path for the whole session */
|
||||
export function AppIcon({ path, className }: { path: string; className?: string }) {
|
||||
/** An app's icon, read once per path for the whole session; `className` sets its size */
|
||||
export function AppIcon({ path, className = "size-4" }: { path: string; className?: string }) {
|
||||
const [url, setUrl] = useState(() => icons.get(path));
|
||||
useEffect(() => {
|
||||
if (icons.has(path)) {
|
||||
@@ -42,8 +42,13 @@ export function AppIcon({ path, className }: { path: string; className?: string
|
||||
}, [path]);
|
||||
|
||||
return url == null ? (
|
||||
<span aria-hidden className={classNames(className, "inline-block w-4 h-4 shrink-0")} />
|
||||
<span aria-hidden className={classNames(className, "inline-block shrink-0 aspect-square")} />
|
||||
) : (
|
||||
<img alt="" src={url} className={classNames(className, "w-4 h-4 shrink-0")} />
|
||||
// Square whatever squeezes it, beside text that truncates
|
||||
<img
|
||||
alt=""
|
||||
src={url}
|
||||
className={classNames(className, "shrink-0 aspect-square object-contain")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +1,106 @@
|
||||
import { Dropdown, type DropdownItem, IconButton } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useRpcMutation } from "../hooks/useRpcMutation";
|
||||
import { flowTotalAtom } from "../lib/store";
|
||||
import {
|
||||
Checkbox,
|
||||
Dialog,
|
||||
DialogFooter,
|
||||
IconButton,
|
||||
useFormattedHotkey,
|
||||
useHotKey,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { type FormEvent, useCallback, useId, useState } from "react";
|
||||
import { clearFlowsAction, confirmClearFlowsAtom } from "../lib/clearFlows";
|
||||
import { clearFlowsAtom, flowTotalAtom, lockedAtom } from "../lib/store";
|
||||
import { useShowToast } from "../lib/toast";
|
||||
|
||||
/** Deletes every flow, once confirmed in its menu, which its hotkey opens too */
|
||||
/**
|
||||
* Clears every flow, from its button or hotkey, once confirmed in a dialog unless "Don't ask
|
||||
* again" was checked there. The list empties at once; the flows are deleted in the background.
|
||||
* Disabled with none to clear, or while captured traffic is locked.
|
||||
*/
|
||||
export function ClearFlowsButton() {
|
||||
const total = useAtomValue(flowTotalAtom);
|
||||
const clear = useRpcMutation("clear_flows");
|
||||
const locked = useAtomValue(lockedAtom);
|
||||
const [confirm, setConfirm] = useAtom(confirmClearFlowsAtom);
|
||||
const clearFlows = useSetAtom(clearFlowsAtom);
|
||||
const showToast = useShowToast();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const action = clearFlowsAction({ total, locked: locked != null, confirm });
|
||||
const hotkey = useFormattedHotkey("flows_clear")?.join("");
|
||||
|
||||
const items: DropdownItem[] = [
|
||||
{
|
||||
type: "content",
|
||||
label: (
|
||||
<div className="px-1.5 py-1 max-w-[17rem]">
|
||||
<div className="text-sm text-text">
|
||||
{total === 1 ? "Clear 1 flow?" : `Clear all ${total} flows?`}
|
||||
</div>
|
||||
<div className="text-xs text-text-subtle mt-0.5">
|
||||
{clear.error != null ? (
|
||||
<span className="text-danger">{clear.error.message}</span>
|
||||
) : (
|
||||
"They are deleted from disk with their bodies, for good."
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Clear",
|
||||
icon: "trash",
|
||||
color: "danger",
|
||||
onSelect: () => clear.mutate({}),
|
||||
},
|
||||
];
|
||||
const clear = useCallback(() => {
|
||||
clearFlows().catch((err: unknown) => {
|
||||
const message = (err as { message?: unknown } | null)?.message;
|
||||
showToast({ message: typeof message === "string" ? message : String(err), color: "danger" });
|
||||
});
|
||||
}, [clearFlows, showToast]);
|
||||
|
||||
const start = useCallback(() => {
|
||||
if (action === "confirm") setConfirming(true);
|
||||
else if (action === "clear") clear();
|
||||
}, [action, clear]);
|
||||
|
||||
// Bound even when disabled, so the key never falls through to the backend's clear unasked
|
||||
useHotKey("flows_clear", start);
|
||||
|
||||
return (
|
||||
<Dropdown items={items} hotKeyAction="flows_clear">
|
||||
<>
|
||||
<IconButton
|
||||
className="pointer-events-auto"
|
||||
icon="trash"
|
||||
title="Clear flows"
|
||||
title={action === "disabled" || hotkey == null ? "Clear flows" : `Clear flows ${hotkey}`}
|
||||
tooltip
|
||||
size="sm"
|
||||
iconColor="secondary"
|
||||
disabled={action === "disabled"}
|
||||
onClick={start}
|
||||
/>
|
||||
</Dropdown>
|
||||
<Dialog open={confirming} onClose={() => setConfirming(false)} size="sm" title="Clear Flows">
|
||||
{confirming && (
|
||||
<ConfirmClear
|
||||
total={total}
|
||||
onCancel={() => setConfirming(false)}
|
||||
onClear={(dontAskAgain) => {
|
||||
setConfirming(false);
|
||||
if (dontAskAgain) setConfirm(false);
|
||||
clear();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfirmClear({
|
||||
total,
|
||||
onCancel,
|
||||
onClear,
|
||||
}: {
|
||||
total: number;
|
||||
onCancel: () => void;
|
||||
onClear: (dontAskAgain: boolean) => void;
|
||||
}) {
|
||||
const formId = useId();
|
||||
const [dontAskAgain, setDontAskAgain] = useState(false);
|
||||
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
onClear(dontAskAgain);
|
||||
};
|
||||
return (
|
||||
<form id={formId} className="flex flex-col gap-3" onSubmit={handleSubmit}>
|
||||
<p>
|
||||
{total === 1
|
||||
? "Permanently clear 1 flow?"
|
||||
: `Permanently clear ${total.toLocaleString()} flows?`}
|
||||
</p>
|
||||
<Checkbox checked={dontAskAgain} title="Don't ask again" onChange={setDontAskAgain} />
|
||||
<DialogFooter
|
||||
inline
|
||||
actions={[
|
||||
{ label: "Cancel", onClick: onCancel },
|
||||
{ label: "Clear", color: "danger", form: formId, autoFocus: true },
|
||||
]}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import { Prec } from "@codemirror/state";
|
||||
import { type EditorView, keymap } from "@codemirror/view";
|
||||
import type { DecryptListEntry, SeenTargets } from "@yaakapp-internal/proxy-lib";
|
||||
import {
|
||||
Button,
|
||||
CONTROL_TOOLTIP_DELAY,
|
||||
Icon,
|
||||
IconButton,
|
||||
Input,
|
||||
SettingRow,
|
||||
SettingRowBoolean,
|
||||
Tooltip,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { filterAutocomplete } from "@yaakapp-internal/ui/editor";
|
||||
import classNames from "classnames";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { canTrustHere, useDecrypt, useTrustCertificate, useTrusting } from "../hooks/useDecrypt";
|
||||
import { useDecryptList } from "../hooks/useDecryptList";
|
||||
import { useRpcQuery } from "../hooks/useRpcQuery";
|
||||
import {
|
||||
decryptFilterOptions,
|
||||
decryptsEverything,
|
||||
entryLabel,
|
||||
entrySubject,
|
||||
EVERYTHING,
|
||||
everythingEdit,
|
||||
normalizeEntry,
|
||||
} from "../lib/decrypt";
|
||||
import { revealLabel, revealPath } from "../lib/opener";
|
||||
import { getOsType } from "../lib/tauri";
|
||||
import { useShowToast } from "../lib/toast";
|
||||
|
||||
/**
|
||||
* Settings' Decryption section: the certificate, Decrypt everything as a switch over `*`, then
|
||||
* the person's own hosts and apps in order, each removable, an input that adds one (suggesting
|
||||
* `host:`, `app:` and `!`, then the hosts and apps seen), and the built-in exclusions folded
|
||||
* into one row. Every change goes through Decrypt, so the first that decrypts something trusts
|
||||
* the certificate first.
|
||||
*/
|
||||
export function DecryptionSettings() {
|
||||
const list = useDecryptList();
|
||||
const { apply } = useDecrypt();
|
||||
const [busy, setBusy] = useState(false);
|
||||
// Read when the input opens its suggestions, so new traffic shows without rebuilding it.
|
||||
const seen = useRpcQuery("list_seen", {}, { staleTime: 0, refetchOnMount: "always" });
|
||||
const seenRef = useRef<SeenTargets | undefined>(seen.data);
|
||||
seenRef.current = seen.data;
|
||||
const everything = decryptsEverything(list);
|
||||
const own = list.filter((e) => !e.builtIn && e.entry !== EVERYTHING);
|
||||
const builtIns = list.filter((e) => e.builtIn);
|
||||
|
||||
// One change at a time, each applied to the list as it is when it lands.
|
||||
const change = async (
|
||||
edit: { add: string[]; remove: string[] },
|
||||
message: string,
|
||||
sub?: string,
|
||||
) => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await apply({ edit, message, sub });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const remove = (entry: string) =>
|
||||
void change(
|
||||
{ add: [], remove: [entry] },
|
||||
entry.startsWith("!")
|
||||
? `Stopped excluding ${entrySubject(entry.slice(1))}`
|
||||
: `Stopped decrypting ${entrySubject(entry)}`,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CertificateRow />
|
||||
<SettingRowBoolean
|
||||
title="Decrypt everything"
|
||||
description="Excluded hosts and apps stay encrypted."
|
||||
checked={everything}
|
||||
disabled={busy}
|
||||
onChange={(on) =>
|
||||
void change(
|
||||
everythingEdit(on),
|
||||
on ? "Decrypting everything" : "Stopped decrypting everything",
|
||||
on ? "Excluded hosts and apps stay encrypted." : undefined,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className="py-4 grid gap-2">
|
||||
<div className="flex items-center text-text">
|
||||
Hosts and apps
|
||||
<span className="ml-auto text-sm text-text-subtlest">
|
||||
Changes apply to new connections.
|
||||
</span>
|
||||
</div>
|
||||
<EntryInput
|
||||
seenRef={seenRef}
|
||||
list={list}
|
||||
disabled={busy}
|
||||
onAdd={(entry) =>
|
||||
void change(
|
||||
{ add: [entry], remove: [] },
|
||||
entry.startsWith("!")
|
||||
? `Excluding ${entrySubject(entry.slice(1))}`
|
||||
: `Decrypting ${entrySubject(entry)}`,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className="rounded-md border border-border-subtle divide-y divide-border-subtle">
|
||||
{own.length === 0 && (
|
||||
<div className="px-3 py-2 text-sm text-text-subtle">No hosts or apps</div>
|
||||
)}
|
||||
{own.map((e) => (
|
||||
<EntryRow key={e.entry} entry={e} disabled={busy} onRemove={() => remove(e.entry)} />
|
||||
))}
|
||||
{builtIns.length > 0 && <BuiltIns entries={builtIns} disabled={busy} onRemove={remove} />}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The certificate: trusted, with Reveal for devices and tools with their own stores, or Trust */
|
||||
function CertificateRow() {
|
||||
const caStatus = useRpcQuery("ca_status", {});
|
||||
const trust = useTrustCertificate();
|
||||
const trusting = useTrusting();
|
||||
const showToast = useShowToast();
|
||||
const status = caStatus.data;
|
||||
if (status == null)
|
||||
return (
|
||||
<SettingRow title="Certificate" description="Yaak Proxy CA">
|
||||
{null}
|
||||
</SettingRow>
|
||||
);
|
||||
return (
|
||||
<SettingRow title="Certificate" description="Yaak Proxy CA">
|
||||
<div className="flex items-center gap-2">
|
||||
{status.trusted ? (
|
||||
<span className="flex items-center gap-1 text-sm text-success">
|
||||
<Icon icon="badge_check" size="sm" />
|
||||
Trusted
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-text-subtle">Not trusted</span>
|
||||
)}
|
||||
{!status.trusted && canTrustHere() ? (
|
||||
<Button size="2xs" color="primary" isLoading={trusting} onClick={() => trust.mutate({})}>
|
||||
{trusting ? "Waiting for macOS" : "Trust"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="2xs"
|
||||
variant="border"
|
||||
onClick={() =>
|
||||
void revealPath(status.certPath, (message) =>
|
||||
showToast({ id: "reveal-ca", color: "danger", message }),
|
||||
)
|
||||
}
|
||||
>
|
||||
{revealLabel(getOsType())}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</SettingRow>
|
||||
);
|
||||
}
|
||||
|
||||
/** The built-in exclusions, folded into one row that opens to each, with its kind */
|
||||
function BuiltIns({
|
||||
entries,
|
||||
disabled,
|
||||
onRemove,
|
||||
}: {
|
||||
entries: DecryptListEntry[];
|
||||
disabled: boolean;
|
||||
onRemove: (entry: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const kinds = new Set(entries.map((e) => e.kind));
|
||||
const summary = [kinds.has("apple") ? "Apple" : null, kinds.has("pinned") ? "pinned apps" : null]
|
||||
.filter((k) => k != null)
|
||||
.join(", ");
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-2 h-xs pl-3 pr-2 text-sm text-text-subtle hover:bg-surface-highlight/50"
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
<Icon icon={open ? "chevron_down" : "chevron_right"} size="sm" className="shrink-0" />
|
||||
{entries.length} built-in exclusions
|
||||
<span className="ml-auto text-xs text-text-subtlest">{summary}</span>
|
||||
</button>
|
||||
{open &&
|
||||
entries.map((e) => (
|
||||
<EntryRow
|
||||
key={e.entry}
|
||||
entry={e}
|
||||
disabled={disabled}
|
||||
onRemove={() => onRemove(e.entry)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EntryRow({
|
||||
entry,
|
||||
disabled,
|
||||
onRemove,
|
||||
}: {
|
||||
entry: DecryptListEntry;
|
||||
disabled: boolean;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const negated = entry.entry.startsWith("!");
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
"group/entry flex items-center gap-2 h-xs pl-3 pr-1 text-sm",
|
||||
entry.builtIn && "text-text-subtlest",
|
||||
)}
|
||||
>
|
||||
<Tooltip wrap content={negated ? "Excluded" : "Decrypted"} delay={CONTROL_TOOLTIP_DELAY}>
|
||||
<Icon
|
||||
icon={negated ? "lock" : "lock_open"}
|
||||
size="sm"
|
||||
className={classNames("shrink-0", entry.builtIn ? "opacity-60" : "text-text-subtle")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<span className={classNames("truncate", entry.entry !== "*" && "font-mono text-xs")}>
|
||||
{entryLabel(entry.entry)}
|
||||
</span>
|
||||
{entry.builtIn && entry.kind != null && (
|
||||
<span className="shrink-0 text-2xs rounded px-1 border border-border-subtle">
|
||||
{entry.kind === "apple" ? "Apple" : "Pinned"}
|
||||
</span>
|
||||
)}
|
||||
<IconButton
|
||||
size="xs"
|
||||
icon="x"
|
||||
iconColor="secondary"
|
||||
title={`Remove ${entryLabel(entry.entry)}`}
|
||||
tooltip
|
||||
disabled={disabled}
|
||||
className="ml-auto opacity-0 group-hover/entry:opacity-100 focus-visible:opacity-100"
|
||||
onClick={onRemove}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The input that adds an entry on Enter, once the suggestions are closed or none is picked */
|
||||
function EntryInput({
|
||||
seenRef,
|
||||
list,
|
||||
disabled,
|
||||
onAdd,
|
||||
}: {
|
||||
seenRef: React.RefObject<SeenTargets | undefined>;
|
||||
list: DecryptListEntry[];
|
||||
/** A change is being saved; Enter waits for it, keeping what was typed */
|
||||
disabled: boolean;
|
||||
onAdd: (entry: string) => void;
|
||||
}) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const onAddRef = useRef(onAdd);
|
||||
onAddRef.current = onAdd;
|
||||
const listRef = useRef(list);
|
||||
listRef.current = list;
|
||||
const disabledRef = useRef(disabled);
|
||||
disabledRef.current = disabled;
|
||||
|
||||
const extensions = useMemo(() => {
|
||||
const options = decryptFilterOptions(null);
|
||||
// The suggestions read the capture as they open, not as the input was made.
|
||||
for (const field of options.fields ?? []) {
|
||||
field.values = () => {
|
||||
const fresh = decryptFilterOptions(seenRef.current).fields ?? [];
|
||||
const values = fresh.find((f) => f.name === field.name)?.values;
|
||||
return Array.isArray(values) ? values : [];
|
||||
};
|
||||
}
|
||||
const add = (view: EditorView) => {
|
||||
const text = view.state.doc.toString();
|
||||
if (text.trim() === "") return false;
|
||||
if (disabledRef.current) return true;
|
||||
const entry = normalizeEntry(text);
|
||||
if (entry == null) {
|
||||
setError("Invalid host or app");
|
||||
return true;
|
||||
}
|
||||
setError(null);
|
||||
if (!listRef.current.some((e) => e.entry === entry)) onAddRef.current(entry);
|
||||
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: "" } });
|
||||
return true;
|
||||
};
|
||||
// Low, so Enter picks a suggestion while they are open.
|
||||
return [filterAutocomplete(options), Prec.low(keymap.of([{ key: "Enter", run: add }]))];
|
||||
}, [seenRef]);
|
||||
|
||||
return (
|
||||
<div className="grid gap-1">
|
||||
<Input
|
||||
label="Add entry"
|
||||
hideLabel
|
||||
size="sm"
|
||||
stateKey={null}
|
||||
language={null}
|
||||
placeholder="Add host or app"
|
||||
extensions={extensions}
|
||||
onChange={() => setError(null)}
|
||||
/>
|
||||
{error != null && <div className="text-xs text-danger">{error}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import { formatSize } from "@yaakapp-internal/lib/formatSize";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { Flow, PassthroughResponse } from "@yaakapp-internal/proxy-lib";
|
||||
import {
|
||||
Button,
|
||||
Dropdown,
|
||||
formatMillis,
|
||||
Icon,
|
||||
IconButton,
|
||||
type IconProps,
|
||||
InlineCode,
|
||||
Tooltip,
|
||||
CONTROL_TOOLTIP_DELAY,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { canTrustHere, useDecrypt, usePassthrough, useTrustCertificate } from "../hooks/useDecrypt";
|
||||
import { useDecryptList } from "../hooks/useDecryptList";
|
||||
import { useRpcMutation } from "../hooks/useRpcMutation";
|
||||
import { useRpcQuery } from "../hooks/useRpcQuery";
|
||||
import {
|
||||
type CardAction,
|
||||
connectionOf,
|
||||
encryptedCard,
|
||||
isOpen,
|
||||
isRejection,
|
||||
type Line,
|
||||
} from "../lib/encryptedCard";
|
||||
import { useFlowActions } from "../lib/flowActions";
|
||||
import { utcTimestamp } from "../lib/flowViews";
|
||||
import { useShowToast } from "../lib/toast";
|
||||
import { AppIcon } from "./AppIcon";
|
||||
import { useCopyEnvironment } from "./ProxyStatus";
|
||||
|
||||
/**
|
||||
* The card for HTTPS the proxy did not decrypt, or a client's refused handshake: the flow card's
|
||||
* header lines, with ENCRYPTED or REJECTED where the status goes, then why in one line and the
|
||||
* changes that would decrypt the next connection like it, each showing the entry it writes.
|
||||
*/
|
||||
export function EncryptedCard({ flow }: { flow: Flow }) {
|
||||
const list = useDecryptList();
|
||||
const caStatus = useRpcQuery("ca_status", {});
|
||||
const passthrough = usePassthrough();
|
||||
const rejection = isRejection(flow);
|
||||
// A tunnel passed through after a rejection names the client that refused.
|
||||
const rejectedById = flow.tlsRejected == null ? flow.tunnelRejectedBy : null;
|
||||
const rejectedBy = useRpcQuery(
|
||||
"get_flows",
|
||||
{ ids: rejectedById == null ? [] : [rejectedById] },
|
||||
{ enabled: rejectedById != null },
|
||||
).data?.[0];
|
||||
const model = encryptedCard({
|
||||
flow,
|
||||
list,
|
||||
trusted: caStatus.data?.trusted ?? null,
|
||||
canTrust: canTrustHere(),
|
||||
passthrough,
|
||||
rejectedBy,
|
||||
});
|
||||
const { apply, trusting } = useDecrypt();
|
||||
const trust = useTrustCertificate();
|
||||
const copyEnvironment = useCopyEnvironment();
|
||||
const showToast = useShowToast();
|
||||
const fail = (err: Error) =>
|
||||
showToast({ id: "encrypted-card", color: "danger", message: err.message });
|
||||
const queryClient = useQueryClient();
|
||||
// Read from the answer too: an engine the CLI owns tells this app nothing of the change.
|
||||
const forget = useRpcMutation("forget_passthrough", {
|
||||
onError: fail,
|
||||
onSuccess: ({ addresses }) =>
|
||||
queryClient.setQueryData<PassthroughResponse>(["list_passthrough", {}], { addresses }),
|
||||
});
|
||||
const reconnect = useRpcMutation("reconnect_tunnels", { onError: fail });
|
||||
const connection = connectionOf(flow);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
|
||||
const run = async (action: CardAction) => {
|
||||
switch (action.kind) {
|
||||
case "decrypt":
|
||||
case "stop":
|
||||
if (action.edit == null) return;
|
||||
setBusy(action.label);
|
||||
await apply({
|
||||
edit: action.edit,
|
||||
message: action.toast?.message ?? action.label,
|
||||
sub: action.toast?.sub,
|
||||
});
|
||||
setBusy(null);
|
||||
return;
|
||||
case "reconnect":
|
||||
reconnect.mutate({ flowId: flow.id });
|
||||
return;
|
||||
case "trust":
|
||||
trust.mutate({});
|
||||
return;
|
||||
case "try_again":
|
||||
forget.mutate({ address: connection.address });
|
||||
return;
|
||||
case "copy_environment":
|
||||
await copyEnvironment();
|
||||
forget.mutate({ address: connection.address });
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full grid grid-rows-[auto_auto_minmax(0,1fr)] min-h-0">
|
||||
<div
|
||||
className={classNames(
|
||||
"flex items-center gap-2 px-3 min-h-[calc(var(--height-sm)+0.25rem)]",
|
||||
"font-mono text-sm whitespace-nowrap overflow-x-auto hide-scrollbars",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
icon={rejection ? "shield_off" : "lock"}
|
||||
size="sm"
|
||||
className={rejection ? "text-warning" : "text-text-subtlest"}
|
||||
/>
|
||||
<span className="text-text select-text cursor-text">
|
||||
{connection.host.includes(":") ? `[${connection.host}]` : connection.host}
|
||||
<span className="text-text-subtlest">:{connection.port}</span>
|
||||
</span>
|
||||
</div>
|
||||
<StatusLine flow={flow} rejected={flow.tlsRejected != null} />
|
||||
<div className="mx-3 border-t border-border-subtle overflow-auto">
|
||||
<div className="mx-auto max-w-[27rem] py-10 grid gap-2">
|
||||
<div className="text-sm text-text mb-1">
|
||||
<LineText line={model.why} />
|
||||
<Tooltip wrap content={model.tooltip ?? ABOUT} delay={CONTROL_TOOLTIP_DELAY}>
|
||||
<IconButton
|
||||
size="2xs"
|
||||
icon="info"
|
||||
iconColor="secondary"
|
||||
title="About decryption"
|
||||
className="ml-1 align-middle inline-flex!"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{model.actions.map((action) => (
|
||||
<ActionButton
|
||||
key={action.label}
|
||||
action={
|
||||
busy === action.label && trusting
|
||||
? { ...action, label: "Waiting for macOS" }
|
||||
: action
|
||||
}
|
||||
flow={flow}
|
||||
loading={
|
||||
busy === action.label ||
|
||||
(action.kind === "trust" && trust.isPending) ||
|
||||
(action.kind === "try_again" && forget.isPending) ||
|
||||
(action.kind === "reconnect" && reconnect.isPending)
|
||||
}
|
||||
disabled={busy != null && busy !== action.label}
|
||||
onClick={() => void run(action)}
|
||||
/>
|
||||
))}
|
||||
{model.foot.length > 0 && (
|
||||
<div className="text-xs text-text-subtlest mt-0.5">{model.foot.join(" ")}</div>
|
||||
)}
|
||||
{flow.error != null && flow.tlsRejected == null && (
|
||||
<div className="text-xs text-danger mt-1">{flow.error}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** What the info button beside the why line says */
|
||||
const ABOUT =
|
||||
"HTTPS is encrypted between the app and the server. Yaak Proxy shows requests only for hosts and apps you decrypt, from their next connection on.";
|
||||
|
||||
/** A why or fact line, its hosts, apps and entries shown as code */
|
||||
function LineText({ line }: { line: Line }) {
|
||||
return (
|
||||
<>
|
||||
{line.map((part, i) =>
|
||||
typeof part === "string" ? (
|
||||
// oxlint-disable-next-line react/no-array-index-key -- the parts of one fixed line
|
||||
<span key={i}>{part}</span>
|
||||
) : "bold" in part ? (
|
||||
// oxlint-disable-next-line react/no-array-index-key -- the parts of one fixed line
|
||||
<span key={i} className="font-semibold">
|
||||
{part.bold}
|
||||
</span>
|
||||
) : (
|
||||
// oxlint-disable-next-line react/no-array-index-key -- the parts of one fixed line
|
||||
<InlineCode key={i}>{part.code}</InlineCode>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const ICONS: Record<Exclude<CardAction["icon"], "app">, IconProps["icon"]> = {
|
||||
host: "globe",
|
||||
everything: "layers",
|
||||
lock: "lock",
|
||||
lock_open: "lock_open",
|
||||
refresh: "refresh",
|
||||
shield: "shield",
|
||||
terminal: "square_terminal",
|
||||
};
|
||||
|
||||
function ActionButton({
|
||||
action,
|
||||
flow,
|
||||
loading,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
action: CardAction;
|
||||
flow: Flow;
|
||||
loading: boolean;
|
||||
disabled: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const icon: ReactNode =
|
||||
action.icon === "app" ? (
|
||||
flow.clientPath == null ? (
|
||||
<Icon icon="box" size="sm" />
|
||||
) : (
|
||||
<AppIcon path={flow.clientPath} className="size-4 min-w-4" />
|
||||
)
|
||||
) : (
|
||||
<Icon icon={ICONS[action.icon]} size="sm" />
|
||||
);
|
||||
return (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="border"
|
||||
color={action.primary ? "primary" : "default"}
|
||||
justify="start"
|
||||
className={classNames("w-full", action.dashed && "border-dashed!")}
|
||||
isLoading={loading}
|
||||
disabled={disabled}
|
||||
leftSlot={loading ? undefined : icon}
|
||||
rightSlot={
|
||||
action.hint == null ? undefined : (
|
||||
<span
|
||||
className={classNames(
|
||||
"block ml-auto pl-3 font-mono text-xs text-text-subtlest truncate max-w-[11rem]",
|
||||
action.removes && "line-through",
|
||||
)}
|
||||
>
|
||||
{action.hint}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
onClick={onClick}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
/** ENCRYPTED or REJECTED, the duration, or how long it has been open, and the bytes each way */
|
||||
function StatusLine({ flow, rejected }: { flow: Flow; rejected: boolean }) {
|
||||
const flowActions = useFlowActions();
|
||||
const open = isOpen(flow);
|
||||
const openFor = useOpenFor(flow, open);
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
"grid grid-cols-[minmax(0,1fr)_auto] items-center pl-3",
|
||||
"min-h-[calc(var(--height-sm)+0.25rem)] font-mono text-sm text-text-subtle whitespace-nowrap",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 overflow-x-auto hide-scrollbars">
|
||||
<span className={rejected ? "text-warning" : undefined}>
|
||||
{rejected ? "REJECTED" : "ENCRYPTED"}
|
||||
</span>
|
||||
<span>•</span>
|
||||
{open ? (
|
||||
<span>open {openFor == null ? "" : formatMillis(openFor)}</span>
|
||||
) : (
|
||||
<>
|
||||
<span>{flow.elapsedMs == null ? "--" : formatMillis(flow.elapsedMs)}</span>
|
||||
<span>•</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Icon icon="arrow_up" size="xs" className="text-text-subtlest" />
|
||||
{formatSize(flow.requestSize)}
|
||||
<Icon icon="arrow_down" size="xs" className="text-text-subtlest ml-1" />
|
||||
{formatSize(flow.responseSize)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="m-0.5 pr-2.5">
|
||||
<Dropdown items={flowActions(flow).slice(2)}>
|
||||
<IconButton size="sm" icon="ellipsis" iconColor="secondary" title="Actions" tooltip />
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** How long an open tunnel has been open, ticking each second */
|
||||
function useOpenFor(flow: Flow, open: boolean): number | null {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const timer = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [open]);
|
||||
const started = Date.parse(utcTimestamp(flow.startedAt));
|
||||
return Number.isFinite(started) ? Math.max(0, now - started) : null;
|
||||
}
|
||||
@@ -1,22 +1,24 @@
|
||||
import { Button, Icon, LoadingIcon } from "@yaakapp-internal/ui";
|
||||
import { Button, Icon, IconTooltip, InlineCode, LoadingIcon } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useAtomValue } from "jotai";
|
||||
import type { ReactNode } from "react";
|
||||
import { useRpcMutation } from "../hooks/useRpcMutation";
|
||||
import { encryptingPercent } from "../lib/encrypting";
|
||||
import { proxyStateAtom } from "../lib/store";
|
||||
import { useCopyEnvironment, useProxySetup, useSystemProxySwitch } from "./ProxyStatus";
|
||||
|
||||
type StepState = "todo" | "next" | "wait" | "done" | "fail";
|
||||
|
||||
/**
|
||||
* The list's empty state until traffic arrives: trust the certificate, turn on the system proxy,
|
||||
* then see traffic. Not dismissable and nothing remembered; the first flow replaces it. Copy
|
||||
* environment beside it serves someone who wants one terminal proxied and nothing system-wide.
|
||||
* The list's empty state until traffic arrives: turn on the system proxy, then see traffic. The
|
||||
* certificate is trusted at the first Decrypt, not here, as nothing needs it before. Not
|
||||
* dismissable and nothing remembered; the first flow replaces it. Copy environment beside it
|
||||
* serves someone who wants one terminal proxied and nothing system-wide.
|
||||
*/
|
||||
export function FirstRun() {
|
||||
const proxyState = useAtomValue(proxyStateAtom);
|
||||
const setup = useProxySetup();
|
||||
const { caStatus, trust, systemProxy, setSystemProxy } = setup;
|
||||
const { systemProxy, setSystemProxy } = setup;
|
||||
const systemSwitch = useSystemProxySwitch(setup);
|
||||
const execute = useRpcMutation("execute_action");
|
||||
const retry = useRpcMutation("take_over");
|
||||
@@ -26,74 +28,41 @@ export function FirstRun() {
|
||||
// Changed only by the process that owns the engine, so not from here while the CLI does.
|
||||
const otherOwner = proxyState?.otherOwner != null;
|
||||
const recording = proxyState?.recording !== false;
|
||||
const trusted = caStatus.data?.trusted === true;
|
||||
const systemSupported = systemProxy.data?.supported !== false;
|
||||
const systemOn = systemProxy.data?.enabled === true;
|
||||
|
||||
const trustState: StepState = trusted
|
||||
? "done"
|
||||
: trust.isPending
|
||||
? "wait"
|
||||
: trust.error != null
|
||||
? "fail"
|
||||
: "next";
|
||||
const systemState: StepState = setSystemProxy.isPending
|
||||
? "wait"
|
||||
: systemOn
|
||||
? "done"
|
||||
: systemSwitch.failed
|
||||
? "fail"
|
||||
: trusted
|
||||
? "next"
|
||||
: "todo";
|
||||
const ready = trusted && (systemOn || !systemSupported);
|
||||
const trafficState: StepState = !running && !otherOwner ? "fail" : ready ? "wait" : "todo";
|
||||
: "next";
|
||||
const ready = systemOn || !systemSupported;
|
||||
const encrypting = !running && !otherOwner ? encryptingPercent(proxyState) : null;
|
||||
const trafficState: StepState =
|
||||
encrypting != null ? "wait" : !running && !otherOwner ? "fail" : ready ? "wait" : "todo";
|
||||
|
||||
return (
|
||||
<div className="grid place-items-center h-full px-10 pb-16 pt-6">
|
||||
<div className="w-full max-w-[40rem]">
|
||||
<div className="text-xl font-semibold mb-1.5">Set up Yaak Proxy</div>
|
||||
<div className="text-text-subtle text-sm leading-relaxed mb-4">
|
||||
Three steps, once. Afterwards every app on this Mac sends its traffic here, and HTTPS
|
||||
reads as plain text.
|
||||
{systemSupported
|
||||
? "Traffic from every app on this Mac will be recorded."
|
||||
: "Traffic appears here as apps send it."}
|
||||
</div>
|
||||
<div className="rounded-lg border border-border-subtle bg-surface divide-y divide-border-subtle">
|
||||
<Step
|
||||
n={1}
|
||||
state={trustState}
|
||||
title={trusted ? "Certificate trusted" : "Trust the certificate"}
|
||||
description={
|
||||
trusted
|
||||
? "Yaak Proxy CA, in your login keychain."
|
||||
: trustState === "wait"
|
||||
? "Waiting for your password in the macOS prompt."
|
||||
: trustState === "fail"
|
||||
? (trust.error?.message ?? "The certificate was not trusted.")
|
||||
: "So HTTPS reads as plain text. macOS asks for your login password once."
|
||||
}
|
||||
action={
|
||||
trustState === "next" || trustState === "fail" ? (
|
||||
<Button
|
||||
size="2xs"
|
||||
variant={trustState === "fail" ? "border" : "solid"}
|
||||
color={trustState === "fail" ? "default" : "primary"}
|
||||
onClick={() => trust.mutate({})}
|
||||
>
|
||||
{trustState === "fail" ? "Try again" : "Trust certificate"}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
{systemSupported && (
|
||||
<Step
|
||||
n={2}
|
||||
n={1}
|
||||
state={systemState}
|
||||
title={systemOn ? "System proxy on" : "Turn on the system proxy"}
|
||||
title={systemOn ? "System proxy overridden" : "Override system proxy"}
|
||||
description={
|
||||
otherOwner && !systemOn
|
||||
? proxyState?.otherOwner?.owner === "cli"
|
||||
? "The CLI owns the proxy. Take over to turn this on from here."
|
||||
: "Another Yaak Proxy window owns the engine. Quit it to use this one."
|
||||
? "Take over from the CLI to change this."
|
||||
: "Quit the other Yaak Proxy to use this one."
|
||||
: systemSwitch.description
|
||||
}
|
||||
action={
|
||||
@@ -104,24 +73,32 @@ export function FirstRun() {
|
||||
color={systemState === "fail" ? "default" : "primary"}
|
||||
onClick={() => setSystemProxy.mutate({ enabled: true })}
|
||||
>
|
||||
{systemState === "fail" ? "Try again" : "Turn on"}
|
||||
{systemState === "fail" ? "Retry" : "Enable"}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Step
|
||||
n={systemSupported ? 3 : 2}
|
||||
n={systemSupported ? 2 : 1}
|
||||
state={trafficState}
|
||||
title={trafficState === "wait" ? "Waiting for traffic" : "See traffic"}
|
||||
title={
|
||||
encrypting != null
|
||||
? `Encrypting recorded traffic, ${encrypting}`
|
||||
: trafficState === "wait"
|
||||
? "Waiting for traffic"
|
||||
: "See traffic"
|
||||
}
|
||||
description={
|
||||
trafficState === "fail"
|
||||
? (retry.error?.message ?? proxyState?.startError ?? "The proxy has not started.")
|
||||
: !recording
|
||||
? "Recording is paused, so traffic passes through without being stored."
|
||||
: trafficState === "wait"
|
||||
? "Open a website or send a request, and its flow appears here."
|
||||
: "Flows appear here as apps send requests."
|
||||
encrypting != null
|
||||
? "Recording will start once encryption finishes."
|
||||
: trafficState === "fail"
|
||||
? (retry.error?.message ?? proxyState?.startError ?? "The proxy failed to start.")
|
||||
: !recording
|
||||
? "Recording is paused."
|
||||
: trafficState === "wait"
|
||||
? "Open a website or send a request to begin."
|
||||
: "Flows appear here as apps send requests."
|
||||
}
|
||||
action={
|
||||
trafficState === "fail" ? (
|
||||
@@ -139,14 +116,14 @@ export function FirstRun() {
|
||||
color="primary"
|
||||
onClick={() => execute.mutate({ scope: "global", action: "recording_toggle" })}
|
||||
>
|
||||
Resume recording
|
||||
Resume Recording
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-4 text-sm text-text-subtle">
|
||||
<span>Only need one terminal or one app? Copy the environment instead.</span>
|
||||
<span>To proxy a single terminal instead, copy the environment.</span>
|
||||
<Button
|
||||
size="2xs"
|
||||
variant="border"
|
||||
@@ -154,9 +131,19 @@ export function FirstRun() {
|
||||
leftSlot={<Icon icon="square_terminal" size="sm" />}
|
||||
onClick={() => void copyEnvironment()}
|
||||
>
|
||||
Copy environment
|
||||
Copy Environment
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-2 text-sm text-text-subtle">
|
||||
Agents can connect through <InlineCode>yaak mcp</InlineCode>.
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-2 text-sm text-text-subtle">
|
||||
Recorded traffic is encrypted on disk.
|
||||
<IconTooltip
|
||||
iconSize="sm"
|
||||
content="The key is kept in the system keychain, so the system may ask to let Yaak Proxy use it."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, test, vi } from "vite-plus/test";
|
||||
import { flowPrettyView, shownViewMode } from "./FlowBodyViewer";
|
||||
|
||||
// The package's index reaches for the window as it loads; the view rule is all this needs.
|
||||
vi.mock("@yaakapp-internal/ui", async () => {
|
||||
const { prettyViewFor } = await vi.importActual<
|
||||
typeof import("../../../packages/ui/src/components/http/BodyViewModeSwitch")
|
||||
>("../../../packages/ui/src/components/http/BodyViewModeSwitch");
|
||||
return { prettyViewFor, LARGE_BODY_BYTES: 2_000_000 };
|
||||
});
|
||||
|
||||
const text = (mime: string, language: string | null, which: "request" | "response" = "response") =>
|
||||
flowPrettyView({
|
||||
which,
|
||||
mime,
|
||||
text: "x",
|
||||
binary: false,
|
||||
language: language as never,
|
||||
hasForm: false,
|
||||
});
|
||||
|
||||
describe("flowPrettyView", () => {
|
||||
test("names the formatted view per format, the same on both halves", () => {
|
||||
for (const which of ["request", "response"] as const) {
|
||||
expect(text("application/json", "json", which)).toBe("formatted");
|
||||
expect(text("text/csv", "text", which)).toBe("table");
|
||||
expect(text("image/svg+xml", "xml", which)).toBe("preview");
|
||||
}
|
||||
expect(text("text/plain", "text")).toBeNull();
|
||||
});
|
||||
|
||||
test("HTML previews as a response and formats as a request", () => {
|
||||
expect(text("text/html", "html", "response")).toBe("preview");
|
||||
expect(text("text/html", "html", "request")).toBe("formatted");
|
||||
});
|
||||
|
||||
test("multipart has parts whether or not it reads as text", () => {
|
||||
const base = { which: "request" as const, mime: "multipart/form-data", language: null };
|
||||
expect(flowPrettyView({ ...base, text: null, binary: false, hasForm: false })).toBe("parts");
|
||||
});
|
||||
|
||||
test("a URL-encoded form's fields are its formatted view, where the tab has them", () => {
|
||||
const base = {
|
||||
which: "request" as const,
|
||||
mime: "application/x-www-form-urlencoded",
|
||||
text: "a=1",
|
||||
binary: false,
|
||||
language: "text" as const,
|
||||
};
|
||||
expect(flowPrettyView({ ...base, hasForm: true })).toBe("form");
|
||||
expect(flowPrettyView({ ...base, hasForm: false })).toBeNull();
|
||||
});
|
||||
|
||||
test("images, binary and undecodable bodies have one view", () => {
|
||||
const base = { which: "response" as const, language: null, hasForm: false };
|
||||
expect(flowPrettyView({ ...base, mime: "image/png", text: null, binary: true })).toBeNull();
|
||||
expect(
|
||||
flowPrettyView({ ...base, mime: "application/json", text: null, binary: true }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
flowPrettyView({ ...base, mime: "application/octet-stream", text: null, binary: false }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("shownViewMode", () => {
|
||||
test("follows the preference", () => {
|
||||
expect(shownViewMode({ key: "a", large: false, preferred: "pretty", chosen: null })).toBe(
|
||||
"pretty",
|
||||
);
|
||||
expect(shownViewMode({ key: "a", large: false, preferred: "raw", chosen: null })).toBe("raw");
|
||||
});
|
||||
|
||||
test("a large body opens raw, and formats once asked, for that body alone", () => {
|
||||
expect(shownViewMode({ key: "a", large: true, preferred: "pretty", chosen: null })).toBe("raw");
|
||||
const chosen = { key: "a", mode: "pretty" as const };
|
||||
expect(shownViewMode({ key: "a", large: true, preferred: "pretty", chosen })).toBe("pretty");
|
||||
expect(shownViewMode({ key: "b", large: true, preferred: "pretty", chosen })).toBe("raw");
|
||||
});
|
||||
});
|
||||
@@ -2,27 +2,39 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { formatSize } from "@yaakapp-internal/lib/formatSize";
|
||||
import type { Flow, FlowBody } from "@yaakapp-internal/proxy-lib";
|
||||
import {
|
||||
ACTIONS_BAR_CONTAINER,
|
||||
ActionsBar,
|
||||
Banner,
|
||||
BodyViewModeSwitch,
|
||||
Button,
|
||||
CsvViewer,
|
||||
HexdumpViewer,
|
||||
Icon,
|
||||
IconTooltip,
|
||||
ImageViewer,
|
||||
LARGE_BODY_BYTES,
|
||||
languageFromContentType,
|
||||
LoadingIcon,
|
||||
MultipartViewer,
|
||||
type PrettyView,
|
||||
prettyViewFor,
|
||||
type ResponseViewMode,
|
||||
SvgViewer,
|
||||
TextViewer,
|
||||
WebPageViewer,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import type { CodeEditorProps } from "@yaakapp-internal/ui/editor";
|
||||
import classNames from "classnames";
|
||||
import { useAtom } from "jotai";
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { bodyViewModeAtom } from "../lib/atoms";
|
||||
import {
|
||||
bodyAsText,
|
||||
bodyImageType,
|
||||
filterText,
|
||||
findHeader,
|
||||
emptyBodyText,
|
||||
flowBodyQueryOptions,
|
||||
formatText,
|
||||
mimeEssence,
|
||||
@@ -32,15 +44,97 @@ import { useSaveFlowBody } from "../lib/flowActions";
|
||||
interface Props {
|
||||
flow: Flow;
|
||||
which: FlowBody;
|
||||
pretty: boolean;
|
||||
/** A URL-encoded body's fields, its formatted view, with the switch in the fields' own bar */
|
||||
form?: (actions: ReactNode) => ReactNode;
|
||||
/** Leaves saying the body was cut short to the tab, which says it its own way */
|
||||
hideCutNote?: boolean;
|
||||
}
|
||||
|
||||
export function useFlowBody(flow: Flow, which: FlowBody, enabled = true) {
|
||||
return useQuery({ ...flowBodyQueryOptions(flow, which), enabled });
|
||||
}
|
||||
|
||||
export function FlowBodyViewer({ flow, which, pretty }: Props) {
|
||||
/** A mode chosen for one large body, which holds for it alone */
|
||||
interface ChosenViewMode {
|
||||
key: string;
|
||||
mode: ResponseViewMode;
|
||||
}
|
||||
|
||||
/** The mode a body shows in: the preference, except raw for a large one until chosen for it */
|
||||
export function shownViewMode({
|
||||
key,
|
||||
large,
|
||||
preferred,
|
||||
chosen,
|
||||
}: {
|
||||
key: string;
|
||||
large: boolean;
|
||||
preferred: ResponseViewMode;
|
||||
chosen: ChosenViewMode | null;
|
||||
}): ResponseViewMode {
|
||||
if (chosen?.key === key) return chosen.mode;
|
||||
return large ? "raw" : preferred;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatted or raw, as last chosen, except that a body big enough to slow the card opens raw,
|
||||
* and choosing for it holds for that body alone
|
||||
*/
|
||||
export function useBodyViewMode(
|
||||
key: string,
|
||||
large: boolean,
|
||||
): [ResponseViewMode, (mode: ResponseViewMode) => void] {
|
||||
const [preferred, setPreferred] = useAtom(bodyViewModeAtom);
|
||||
const [chosen, setChosen] = useState<ChosenViewMode | null>(null);
|
||||
const mode = shownViewMode({ key, large, preferred, chosen });
|
||||
const setMode = useCallback(
|
||||
(next: ResponseViewMode) => {
|
||||
if (large) {
|
||||
setChosen({ key, mode: next });
|
||||
} else {
|
||||
setChosen(null);
|
||||
setPreferred(next);
|
||||
}
|
||||
},
|
||||
[key, large, setPreferred],
|
||||
);
|
||||
return [mode, setMode];
|
||||
}
|
||||
|
||||
/**
|
||||
* The formatted view a body has, or null when it has only the one: parts for multipart, the
|
||||
* fields for a URL-encoded form, or else what the shared rule gives its text. Images and binary
|
||||
* bodies have only their one view.
|
||||
*/
|
||||
export function flowPrettyView({
|
||||
which,
|
||||
mime,
|
||||
text,
|
||||
binary,
|
||||
language,
|
||||
hasForm,
|
||||
}: {
|
||||
which: FlowBody;
|
||||
mime: string;
|
||||
text: string | null;
|
||||
/** An image, or what failed to decode */
|
||||
binary: boolean;
|
||||
language: CodeEditorProps["language"] | null;
|
||||
hasForm: boolean;
|
||||
}): PrettyView | null {
|
||||
if (binary) return null;
|
||||
if (mime.startsWith("multipart/")) return "parts";
|
||||
if (text == null) return null;
|
||||
if (hasForm && mime === "application/x-www-form-urlencoded") return "form";
|
||||
return prettyViewFor({ mimeType: mime, language, previewHtml: which === "response" });
|
||||
}
|
||||
|
||||
export function FlowBodyViewer({ flow, which, form, hideCutNote = false }: Props) {
|
||||
const body = useFlowBody(flow, which);
|
||||
const [mode, setMode] = useBodyViewMode(
|
||||
`${flow.id}.${which}`,
|
||||
(body.data?.bytes.length ?? 0) > LARGE_BODY_BYTES,
|
||||
);
|
||||
|
||||
if (body.isPending) {
|
||||
return (
|
||||
@@ -53,9 +147,9 @@ export function FlowBodyViewer({ flow, which, pretty }: Props) {
|
||||
return <Banner color="danger">{body.error.message}</Banner>;
|
||||
}
|
||||
|
||||
const { bytes, truncated, storedSize, decodeError } = body.data;
|
||||
const { bytes, truncated, storedSize, decodeError, note } = body.data;
|
||||
if (bytes.length === 0 && decodeError == null) {
|
||||
return <EmptyState>{which === "request" ? "No request body" : "Empty"}</EmptyState>;
|
||||
return <EmptyState>{emptyBodyText(which, note)}</EmptyState>;
|
||||
}
|
||||
|
||||
const headers = which === "request" ? flow.requestHeaders : flow.responseHeaders;
|
||||
@@ -68,12 +162,28 @@ export function FlowBodyViewer({ flow, which, pretty }: Props) {
|
||||
const imageType = decodeError == null ? bodyImageType(bytes, truncated, contentType) : null;
|
||||
const text =
|
||||
decodeError == null && imageType == null ? bodyAsText(bytes, truncated, contentType) : null;
|
||||
const language = text == null ? null : languageFromContentType(contentType, text);
|
||||
const prettyView = flowPrettyView({
|
||||
which,
|
||||
mime,
|
||||
text,
|
||||
binary: decodeError != null || imageType != null,
|
||||
language,
|
||||
hasForm: form != null,
|
||||
});
|
||||
// The one view a body without a formatted one has is its raw one
|
||||
const pretty = prettyView != null && mode === "pretty";
|
||||
const modeSwitch =
|
||||
prettyView == null ? null : (
|
||||
<BodyViewModeSwitch pretty={prettyView} value={mode} onChange={setMode} />
|
||||
);
|
||||
|
||||
let viewer: ReactNode;
|
||||
let ownsBar = false;
|
||||
if (decodeError != null) {
|
||||
// What failed to decode is as it was on the wire, compressed, so never text.
|
||||
viewer = <BinaryBody flow={flow} which={which} bytes={bytes} />;
|
||||
} else if (mime.startsWith("multipart/") && pretty) {
|
||||
} else if (prettyView === "parts" && pretty) {
|
||||
const boundary = contentType?.match(/;\s*boundary="?([^";]+)"?/i)?.[1] ?? "unknown";
|
||||
viewer = (
|
||||
<MultipartViewer
|
||||
@@ -87,35 +197,62 @@ export function FlowBodyViewer({ flow, which, pretty }: Props) {
|
||||
viewer = <ImageBody bytes={bytes} mime={imageType} />;
|
||||
} else if (text == null) {
|
||||
viewer = <BinaryBody flow={flow} which={which} bytes={bytes} />;
|
||||
} else if (mime.startsWith("image/svg")) {
|
||||
viewer = <SvgViewer text={text} />;
|
||||
} else if (/csv|tab-separated/.test(mime) && pretty) {
|
||||
} else if (prettyView === "form" && pretty && form != null) {
|
||||
ownsBar = true;
|
||||
viewer = form(modeSwitch);
|
||||
} else if (prettyView === "table" && pretty) {
|
||||
viewer = <CsvViewer text={text} className="pb-2" />;
|
||||
} else if (prettyView === "preview" && pretty) {
|
||||
viewer = mime.startsWith("image/svg") ? (
|
||||
<SvgViewer text={text} />
|
||||
) : (
|
||||
<WebPageViewer html={text} baseUrl={flow.url} />
|
||||
);
|
||||
} else {
|
||||
const language = languageFromContentType(contentType, text);
|
||||
if (language === "html" && pretty && which === "response") {
|
||||
viewer = <WebPageViewer html={text} baseUrl={flow.url} />;
|
||||
} else {
|
||||
viewer = (
|
||||
<FlowTextViewer flow={flow} which={which} text={text} language={language} pretty={pretty} />
|
||||
);
|
||||
}
|
||||
ownsBar = true;
|
||||
viewer = (
|
||||
<FlowTextViewer
|
||||
flow={flow}
|
||||
which={which}
|
||||
text={text}
|
||||
language={language}
|
||||
pretty={pretty}
|
||||
modeSwitch={modeSwitch}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!truncated && decodeError == null) return viewer;
|
||||
// The text viewer and the form draw the switch in their own bar, beside their own actions; the
|
||||
// rest get a bar here. Each fills the tab content, so every bar has the same inset.
|
||||
if (modeSwitch != null && !ownsBar) {
|
||||
viewer = (
|
||||
<div className={classNames(ACTIONS_BAR_CONTAINER, "h-full w-full")}>
|
||||
{viewer}
|
||||
<ActionsBar>{modeSwitch}</ActionsBar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if ((!truncated || hideCutNote) && decodeError == null) return viewer;
|
||||
|
||||
return (
|
||||
<div className="h-full grid grid-rows-[auto_auto_minmax(0,1fr)]">
|
||||
{decodeError != null && (
|
||||
<Banner color="danger" className="text-sm mb-2">
|
||||
{decodeError}. Showing the body as it was on the wire.
|
||||
{decodeError}. The body is shown as it was received.
|
||||
</Banner>
|
||||
)}
|
||||
{truncated && (
|
||||
{truncated && !hideCutNote && (
|
||||
<Banner color="notice" className="text-sm mb-2">
|
||||
{wireTruncated || decodeError != null
|
||||
? `Showing the first ${formatSize(storedSize)} of ${formatSize(wireSize)}, the capture limit. The app received all of it.`
|
||||
: `Showing the first ${formatSize(bytes.length)} of the decoded body, the capture limit. The app received all of it.`}
|
||||
<span className="flex items-center gap-1.5">
|
||||
{wireTruncated || decodeError != null
|
||||
? `The first ${formatSize(storedSize)} of ${formatSize(wireSize)} is shown.`
|
||||
: `The first ${formatSize(bytes.length)} of the decoded body is shown.`}
|
||||
<IconTooltip
|
||||
iconSize="sm"
|
||||
content="The body was truncated at the capture limit. The app received all of it."
|
||||
/>
|
||||
</span>
|
||||
</Banner>
|
||||
)}
|
||||
<div className="min-h-0 row-start-3">{viewer}</div>
|
||||
@@ -167,7 +304,16 @@ function FlowTextViewer({
|
||||
text,
|
||||
language,
|
||||
pretty,
|
||||
}: Props & { text: string; language: CodeEditorProps["language"] }) {
|
||||
modeSwitch,
|
||||
}: {
|
||||
flow: Flow;
|
||||
which: FlowBody;
|
||||
text: string;
|
||||
language: CodeEditorProps["language"];
|
||||
pretty: boolean;
|
||||
/** Formatted or raw, in the viewer's bar after its filter; null for a body with one view */
|
||||
modeSwitch: ReactNode;
|
||||
}) {
|
||||
const runFilter = useCallback(
|
||||
(expression: string) => filterText(flow.id, which, expression, language),
|
||||
[flow.id, which, language],
|
||||
@@ -182,6 +328,8 @@ function FlowTextViewer({
|
||||
className="-mr-2 bg-surface"
|
||||
formatText={formatText}
|
||||
filterText={runFilter}
|
||||
ownActionsBar
|
||||
pinnedActions={modeSwitch}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
import { formatSize } from "@yaakapp-internal/lib/formatSize";
|
||||
import type { Flow, FlowBody } from "@yaakapp-internal/proxy-lib";
|
||||
import {
|
||||
CONTROL_TOOLTIP_DELAY,
|
||||
CountBadge,
|
||||
Dropdown,
|
||||
EmptyStateText,
|
||||
type ExtraPaneTab,
|
||||
FormUrlencodedEditor,
|
||||
type HttpBodyResult,
|
||||
HttpRequestPaneView,
|
||||
HttpResponsePaneView,
|
||||
Icon,
|
||||
IconButton,
|
||||
parseCookieHeaders,
|
||||
ResponseCookies,
|
||||
SplitLayout,
|
||||
type TabItem,
|
||||
Tooltip,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useAtomValue, useSetAtom, useStore } from "jotai";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { BODY_TYPE_FORM_URLENCODED } from "@yaakapp-internal/ui/httpTypes";
|
||||
import { placeTabsAtom } from "../lib/atoms";
|
||||
import { bodyTypeBadge, findHeader } from "../lib/body";
|
||||
import {
|
||||
browserTabMemory,
|
||||
defaultTabFor,
|
||||
type FlowPane,
|
||||
learnTab,
|
||||
REQUEST_TAB_AUTH,
|
||||
REQUEST_TAB_BODY,
|
||||
REQUEST_TAB_COOKIES,
|
||||
REQUEST_TAB_HEADERS,
|
||||
REQUEST_TAB_INFO,
|
||||
REQUEST_TAB_PARAMS,
|
||||
RESPONSE_TAB_BODY,
|
||||
RESPONSE_TAB_COOKIES,
|
||||
RESPONSE_TAB_HEADERS,
|
||||
RESPONSE_TAB_MESSAGES,
|
||||
} from "../lib/defaultTab";
|
||||
import { saveDialogFlowAtom, useFlowActions } from "../lib/flowActions";
|
||||
import {
|
||||
type FlowBodyData,
|
||||
flowBodyLoader,
|
||||
flowCookiesView,
|
||||
flowRequestToView,
|
||||
flowResponseToView,
|
||||
} from "../lib/flowViews";
|
||||
import { messagesTabLabel } from "../lib/messages";
|
||||
import { goToPlace } from "../lib/navigation";
|
||||
import { FlowBodyViewer, useFlowBody } from "./FlowBodyViewer";
|
||||
import { FlowInfo } from "./FlowInfo";
|
||||
import { FlowRequestBody } from "./FlowRequestBody";
|
||||
import { FlowMessages } from "./FlowMessages";
|
||||
import { ProxyEditorHost } from "./ProxyEditorHost";
|
||||
|
||||
/**
|
||||
* One card for a flow: the request half (its URL line and tabs), a hairline, and the response
|
||||
* half (its status line and tabs). The halves sit perpendicular to the list: stacked when the
|
||||
* list is beside the card, side by side when it is above.
|
||||
*/
|
||||
export function FlowCard({ flow, listBeside }: { flow: Flow; listBeside: boolean }) {
|
||||
return (
|
||||
<ProxyEditorHost>
|
||||
<SplitLayout
|
||||
storageKey={listBeside ? "proxy_card_stacked" : "proxy_card_side"}
|
||||
layout={listBeside ? "vertical" : "horizontal"}
|
||||
minHeightPx={120}
|
||||
minWidthPx={240}
|
||||
firstSlot={({ style }) => (
|
||||
<div style={style}>
|
||||
<FlowRequestHalf flow={flow} />
|
||||
</div>
|
||||
)}
|
||||
secondSlot={({ style, orientation }) => (
|
||||
<div style={style}>
|
||||
<FlowResponseHalf
|
||||
flow={flow}
|
||||
className={orientation === "vertical" ? "border-t" : "border-l"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</ProxyEditorHost>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The tab a half opens on, and learning from the ones chosen by hand. The tab chosen is part of
|
||||
* the window's place, so going back to it, or reloading, opens it again.
|
||||
*/
|
||||
function useShapeTab(pane: FlowPane, flow: Flow, available: string[]) {
|
||||
const store = useStore();
|
||||
const memory = browserTabMemory();
|
||||
const activeTab = useAtomValue(placeTabsAtom)[pane];
|
||||
// Read again for every flow, so a tab learned on one opens the next of its shape
|
||||
const defaultTab = defaultTabFor(pane, flow, available, memory);
|
||||
const onTabChange = useCallback(
|
||||
(tab: string) => {
|
||||
learnTab(pane, flow, tab, memory);
|
||||
goToPlace(store, "update", { tabs: { ...store.get(placeTabsAtom), [pane]: tab } });
|
||||
},
|
||||
[pane, flow, memory, store],
|
||||
);
|
||||
return { activeTab, defaultTab, onTabChange };
|
||||
}
|
||||
|
||||
/** A body read through the engine's body RPC, as the shared panes' loaders ask for it */
|
||||
function useLoadedBody(
|
||||
flow: Flow,
|
||||
which: FlowBody,
|
||||
enabled = true,
|
||||
): HttpBodyResult<FlowBodyData | null> {
|
||||
const size = which === "request" ? flow.requestSize : flow.responseSize;
|
||||
const load = enabled && size > 0;
|
||||
const body = useFlowBody(flow, which, load);
|
||||
if (!load) return { data: null, isLoading: false, error: null };
|
||||
return { data: body.data ?? undefined, isLoading: body.isLoading, error: body.error };
|
||||
}
|
||||
|
||||
function FlowRequestHalf({ flow }: { flow: Flow }) {
|
||||
const body = useLoadedBody(flow, "request");
|
||||
const request = useMemo(() => flowRequestToView(flow, body.data ?? null), [flow, body.data]);
|
||||
const loader = useMemo(
|
||||
() =>
|
||||
flowBodyLoader({
|
||||
useBody: () => useLoadedBody(flow, "request"),
|
||||
contentType: findHeader(flow.requestHeaders, "content-type"),
|
||||
}),
|
||||
[flow],
|
||||
);
|
||||
const cookies = useMemo(() => flowCookiesView(flow), [flow]);
|
||||
const sentCount = useMemo(
|
||||
() => parseCookieHeaders(cookies.cookieHeaders).sentCookies.length,
|
||||
[cookies],
|
||||
);
|
||||
const extraTabs = useMemo<ExtraPaneTab[]>(
|
||||
() => [
|
||||
{
|
||||
tab: {
|
||||
value: REQUEST_TAB_COOKIES,
|
||||
label: "Cookies",
|
||||
rightSlot: <CountBadge count={sentCount} />,
|
||||
},
|
||||
content: <ResponseCookies response={cookies} side="sent" />,
|
||||
},
|
||||
],
|
||||
[cookies, sentCount],
|
||||
);
|
||||
const leadingTabs = useMemo<ExtraPaneTab[]>(
|
||||
() => [{ tab: { value: REQUEST_TAB_INFO, label: "Info" }, content: <FlowInfo flow={flow} /> }],
|
||||
[flow],
|
||||
);
|
||||
const contentType = findHeader(flow.requestHeaders, "content-type");
|
||||
const badge = bodyTypeBadge(contentType, flow.requestSize, flow.graphqlOperation != null);
|
||||
const bodyTab = useMemo(() => bodyTabItem(REQUEST_TAB_BODY, badge), [badge]);
|
||||
const available = [
|
||||
REQUEST_TAB_INFO,
|
||||
REQUEST_TAB_BODY,
|
||||
REQUEST_TAB_PARAMS,
|
||||
REQUEST_TAB_HEADERS,
|
||||
...(request.auth == null ? [] : [REQUEST_TAB_AUTH]),
|
||||
REQUEST_TAB_COOKIES,
|
||||
];
|
||||
const { activeTab, defaultTab, onTabChange } = useShapeTab("request", flow, available);
|
||||
|
||||
const forceUpdateKey = `${flow.updatedAt}.${body.data == null ? "" : "loaded"}`;
|
||||
|
||||
return (
|
||||
<HttpRequestPaneView
|
||||
// Padded below as the response half's tabs are, so their content, and the actions bar
|
||||
// over it, sit the same distance from the card's edges
|
||||
className="px-3 pb-3"
|
||||
request={request}
|
||||
fullHeight
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
// Info joined as the first tab: an order dragged before it would put it last
|
||||
tabsStorageKey="proxy_request_tabs.2"
|
||||
bodyLoader={loader}
|
||||
bodyTab={bodyTab}
|
||||
leadingTabs={leadingTabs}
|
||||
extraTabs={extraTabs}
|
||||
defaultTab={defaultTab}
|
||||
activeTab={activeTab}
|
||||
onTabChange={onTabChange}
|
||||
urlLine={<UrlLine flow={flow} />}
|
||||
renderBody={() => (
|
||||
<FlowRequestBody
|
||||
body={body}
|
||||
// The response's viewer choice, with the form's fields as a URL-encoded body's
|
||||
// formatted view, the switch in one bar with their bulk-view toggle
|
||||
viewer={
|
||||
<FlowBodyViewer
|
||||
flow={flow}
|
||||
which="request"
|
||||
form={
|
||||
request.bodyType === BODY_TYPE_FORM_URLENCODED
|
||||
? (actions) => (
|
||||
<FormUrlencodedEditor
|
||||
readOnly
|
||||
requestId={request.id}
|
||||
form={request.body.form}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
actions={actions}
|
||||
/>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
hideCutNote
|
||||
/>
|
||||
}
|
||||
requestSize={flow.requestSize}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** A half's body tab: Body, with the body's type beside it as the Headers count is, if any */
|
||||
function bodyTabItem(value: string, badge: string | null): TabItem {
|
||||
return {
|
||||
value,
|
||||
label: "Body",
|
||||
rightSlot: badge == null ? null : <CountBadge count={badge} />,
|
||||
};
|
||||
}
|
||||
|
||||
/** The method and URL, at the status line's height so the two header lines align */
|
||||
function UrlLine({ flow }: { flow: Flow }) {
|
||||
return (
|
||||
// The whole URL on hover, when it runs past the card
|
||||
<Tooltip wrap onlyWhenTruncated content={flow.url} delay={CONTROL_TOOLTIP_DELAY}>
|
||||
<div
|
||||
className={classNames(
|
||||
"flex items-center gap-2 min-h-[calc(var(--height-sm)+0.25rem)] -mb-1.5",
|
||||
"font-mono text-sm whitespace-nowrap overflow-x-auto hide-scrollbars",
|
||||
)}
|
||||
>
|
||||
<span className="text-text-subtle shrink-0">{flow.method}</span>
|
||||
<span className="text-text select-text cursor-text">{flow.url}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function FlowResponseHalf({ flow, className }: { flow: Flow; className: string }) {
|
||||
// A stream's body is its messages, read by the Messages tab; reading it whole again on every
|
||||
// message would only hold copies of it
|
||||
const loadBody = flow.streamKind == null;
|
||||
const body = useLoadedBody(flow, "response", loadBody);
|
||||
const response = useMemo(() => flowResponseToView(flow, body.data ?? null), [flow, body.data]);
|
||||
const loader = useMemo(
|
||||
() =>
|
||||
flowBodyLoader({
|
||||
useBody: () => useLoadedBody(flow, "response", loadBody),
|
||||
contentType: findHeader(flow.responseHeaders, "content-type"),
|
||||
}),
|
||||
[flow, loadBody],
|
||||
);
|
||||
const openSave = useSetAtom(saveDialogFlowAtom);
|
||||
const flowActions = useFlowActions();
|
||||
const messagesLabel = messagesTabLabel(flow);
|
||||
|
||||
const badge = bodyTypeBadge(flow.contentType, flow.responseSize);
|
||||
const bodyTab = useMemo(() => bodyTabItem(RESPONSE_TAB_BODY, badge), [badge]);
|
||||
const extraTabs = useMemo<ExtraPaneTab[]>(
|
||||
() =>
|
||||
messagesLabel == null
|
||||
? []
|
||||
: [
|
||||
{
|
||||
tab: {
|
||||
value: RESPONSE_TAB_MESSAGES,
|
||||
label: messagesLabel,
|
||||
rightSlot: (
|
||||
<CountBadge
|
||||
count={flow.messageCount}
|
||||
color="info"
|
||||
showZero
|
||||
className="tabular-nums"
|
||||
/>
|
||||
),
|
||||
},
|
||||
content: <FlowMessages flow={flow} />,
|
||||
},
|
||||
],
|
||||
[flow, messagesLabel],
|
||||
);
|
||||
const available = [
|
||||
RESPONSE_TAB_BODY,
|
||||
RESPONSE_TAB_HEADERS,
|
||||
RESPONSE_TAB_COOKIES,
|
||||
...(messagesLabel == null ? [] : [RESPONSE_TAB_MESSAGES]),
|
||||
];
|
||||
const { activeTab, defaultTab, onTabChange } = useShapeTab("response", flow, available);
|
||||
const saved = flow.savedRequestId != null;
|
||||
|
||||
return (
|
||||
<HttpResponsePaneView
|
||||
bare
|
||||
requestShownApart
|
||||
className={classNames(className, "border-border-subtle")}
|
||||
response={response}
|
||||
body={loader}
|
||||
requestBody={null}
|
||||
emptyState={<EmptyStateText>Waiting for a response</EmptyStateText>}
|
||||
tabsStorageKey="proxy_response_tabs"
|
||||
activeTabKey={flow.id}
|
||||
defaultTab={defaultTab}
|
||||
activeTab={activeTab}
|
||||
onTabChange={onTabChange}
|
||||
extraTabs={extraTabs}
|
||||
bodyTab={bodyTab}
|
||||
renderBodyViewer={() => <FlowBodyViewer flow={flow} which="response" />}
|
||||
statusLineCenter={
|
||||
flow.responseTruncated ? (
|
||||
<span
|
||||
className={classNames(
|
||||
"justify-self-end flex items-center gap-1.5 h-5 px-2.5 ml-3 rounded-full",
|
||||
"border border-notice/50 text-notice font-sans text-xs",
|
||||
)}
|
||||
>
|
||||
<Icon icon="scissors" size="xs" />
|
||||
{body.data == null
|
||||
? "Body truncated"
|
||||
: `First ${formatSize(body.data.storedSize)} recorded`}
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
statusLineRight={
|
||||
<div className="flex items-center gap-0.5 m-0.5">
|
||||
<IconButton
|
||||
size="sm"
|
||||
icon="bookmark"
|
||||
iconColor={saved ? "primary" : "secondary"}
|
||||
title={saved ? "Saved to Yaak" : "Save to Yaak"}
|
||||
tooltip
|
||||
onClick={() => openSave(flow)}
|
||||
/>
|
||||
<Dropdown items={flowActions(flow).slice(2)}>
|
||||
<IconButton size="sm" icon="ellipsis" iconColor="secondary" title="Actions" tooltip />
|
||||
</Dropdown>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +1,23 @@
|
||||
import { formatSize } from "@yaakapp-internal/lib/formatSize";
|
||||
import type { Flow } from "@yaakapp-internal/proxy-lib";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
CountBadge,
|
||||
Dropdown,
|
||||
type DropdownItem,
|
||||
formatMillis,
|
||||
HStack,
|
||||
HttpStatusTagRaw,
|
||||
Icon,
|
||||
IconButton,
|
||||
SizeTag,
|
||||
type TabButtonRenderProps,
|
||||
type TabItem,
|
||||
TabContent,
|
||||
Tabs,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { useAtomValue } from "jotai";
|
||||
import type { CSSProperties } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { selectedFlowAtom } from "../lib/atoms";
|
||||
import { findHeader, mimeEssence } from "../lib/body";
|
||||
import { saveDialogFlowAtom, useFlowActions } from "../lib/flowActions";
|
||||
import { statusReason } from "../lib/statusReason";
|
||||
import { EmptyState, FlowBodyViewer, useFlowBody } from "./FlowBodyViewer";
|
||||
import { FlowHeaders, FlowQuery, queryParams } from "./FlowHeaders";
|
||||
|
||||
const TAB_RESPONSE = "response";
|
||||
const TAB_REQUEST = "request";
|
||||
const TAB_HEADERS = "headers";
|
||||
const TAB_QUERY = "query";
|
||||
import { isRejection } from "../lib/encryptedCard";
|
||||
import { EncryptedCard } from "./EncryptedCard";
|
||||
import { EmptyState } from "./FlowBodyViewer";
|
||||
import { FlowCard } from "./FlowCard";
|
||||
|
||||
/**
|
||||
* The detail pane: the selected flow's card, or what the pane holds before one is selected. The
|
||||
* split's gutter separates it from the list; side by side it sits a little under the header.
|
||||
*/
|
||||
export function FlowDetail({ style, sideBySide }: { style?: CSSProperties; sideBySide: boolean }) {
|
||||
const flow = useAtomValue(selectedFlowAtom);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
className={classNames("min-h-0 min-w-0 pb-2", sideBySide ? "pr-2 pt-2" : "px-2")}
|
||||
className={classNames("min-h-0 min-w-0 pb-2", sideBySide ? "pr-2 pt-3" : "px-2")}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
@@ -55,257 +33,19 @@ export function FlowDetail({ style, sideBySide }: { style?: CSSProperties; sideB
|
||||
<span>Pause or resume recording</span>
|
||||
<Keys keys={["⌘", "R"]} />
|
||||
<span>Clear flows</span>
|
||||
<Keys keys={["⌘", "K"]} />
|
||||
<Keys keys={["⌘", "⇧", "K"]} />
|
||||
</div>
|
||||
</EmptyState>
|
||||
) : flow.tunnelled || isRejection(flow) ? (
|
||||
<EncryptedCard key={flow.id} flow={flow} />
|
||||
) : (
|
||||
<FlowPane flow={flow} />
|
||||
<FlowCard flow={flow} listBeside={sideBySide} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FlowPane({ flow }: { flow: Flow }) {
|
||||
const [pretty, setPretty] = useState(true);
|
||||
const responseBody = useFlowBody(flow, "response", flow.responseTruncated);
|
||||
// Failed or cancelled before any response; after the headers, the body was cut short mid-way.
|
||||
const noResponse = (flow.error != null || flow.cancelled) && flow.status == null;
|
||||
const mime = mimeEssence(findHeader(flow.responseHeaders, "content-type"));
|
||||
const queryCount = useMemo(() => queryParams(flow.url).length, [flow.url]);
|
||||
const openSave = useSetAtom(saveDialogFlowAtom);
|
||||
const flowActions = useFlowActions();
|
||||
|
||||
const tabs = useMemo<TabItem[]>(
|
||||
() => [
|
||||
{
|
||||
value: TAB_RESPONSE,
|
||||
renderButton: ({ buttonProps, isActive }) => (
|
||||
<ViewModeButton
|
||||
buttonProps={buttonProps}
|
||||
isActive={isActive}
|
||||
pretty={pretty}
|
||||
onChangePretty={setPretty}
|
||||
prettyLabel={mime === "text/html" ? "Preview" : "Response"}
|
||||
allowRaw={!mime?.startsWith("image/")}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: TAB_REQUEST,
|
||||
label: "Request",
|
||||
rightSlot: flow.requestSize > 0 ? <CountBadge count={true} /> : null,
|
||||
},
|
||||
{
|
||||
value: TAB_HEADERS,
|
||||
label: "Headers",
|
||||
rightSlot: (
|
||||
<CountBadge
|
||||
count={flow.requestHeaders.length}
|
||||
count2={flow.responseHeaders.length}
|
||||
showZero
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: TAB_QUERY,
|
||||
label: "Query",
|
||||
rightSlot: <CountBadge count={queryCount} showZero />,
|
||||
},
|
||||
],
|
||||
[
|
||||
pretty,
|
||||
mime,
|
||||
flow.requestSize,
|
||||
flow.requestHeaders.length,
|
||||
flow.responseHeaders.length,
|
||||
queryCount,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1">
|
||||
<div
|
||||
className={classNames(
|
||||
"grid grid-cols-[auto_minmax(4rem,1fr)_auto] items-center h-sm -mb-1.5",
|
||||
"cursor-default select-none whitespace-nowrap w-full px-3",
|
||||
"overflow-x-auto font-mono text-sm text-text-subtle hide-scrollbars",
|
||||
)}
|
||||
>
|
||||
<HStack space={2} className="w-full shrink-0">
|
||||
{flow.cancelled && flow.status == null ? (
|
||||
<span>Cancelled</span>
|
||||
) : (
|
||||
<HttpStatusTagRaw
|
||||
showReason
|
||||
status={flow.status ?? 0}
|
||||
statusReason={flow.status == null ? null : statusReason(flow.status)}
|
||||
/>
|
||||
)}
|
||||
{flow.elapsedMs != null && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span
|
||||
title={
|
||||
flow.elapsedHeadersMs == null
|
||||
? undefined
|
||||
: `HEADERS: ${formatMillis(flow.elapsedHeadersMs)}\nTOTAL: ${formatMillis(flow.elapsedMs)}`
|
||||
}
|
||||
>
|
||||
{formatMillis(flow.elapsedMs)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{!noResponse && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<SizeTag contentLength={flow.responseSize} />
|
||||
</>
|
||||
)}
|
||||
</HStack>
|
||||
{flow.responseTruncated ? (
|
||||
<span
|
||||
className={classNames(
|
||||
"justify-self-end flex items-center gap-1.5 h-5 px-2.5 ml-3 rounded-full",
|
||||
"border border-notice/50 text-notice font-sans text-xs",
|
||||
)}
|
||||
>
|
||||
<Icon icon="scissors" size="xs" />
|
||||
{responseBody.data == null
|
||||
? "Body cut"
|
||||
: `First ${formatSize(responseBody.data.storedSize)} kept`}
|
||||
</span>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<HStack space={1} className="font-sans ml-3">
|
||||
<Button
|
||||
size="2xs"
|
||||
variant="border"
|
||||
color={flow.savedRequestId == null ? "default" : "primary"}
|
||||
title={flow.savedRequestId == null ? undefined : "Saved to Yaak. Click to save again."}
|
||||
leftSlot={<Icon icon="bookmark" size="xs" />}
|
||||
onClick={() => openSave(flow)}
|
||||
>
|
||||
{flow.savedRequestId == null ? "Save to Yaak" : "Saved"}
|
||||
</Button>
|
||||
<Dropdown items={flowActions(flow).slice(2)}>
|
||||
<IconButton size="xs" icon="ellipsis" iconColor="secondary" title="Copy and export" />
|
||||
</Dropdown>
|
||||
</HStack>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden flex flex-col min-h-0">
|
||||
{flow.error != null && (
|
||||
<Banner color="danger" className="mx-3 mt-1 shrink-0 font-mono text-sm">
|
||||
{flow.error}
|
||||
</Banner>
|
||||
)}
|
||||
{flow.cancelled && (
|
||||
<Banner color="secondary" className="mx-3 mt-1 shrink-0 text-sm">
|
||||
{flow.status == null
|
||||
? "The app cancelled the request before a response came back."
|
||||
: "The app cancelled the request before the response finished."}
|
||||
</Banner>
|
||||
)}
|
||||
<Tabs
|
||||
tabs={tabs}
|
||||
label="Flow"
|
||||
className="ml-3 mr-3 mb-3 min-h-0 flex-1"
|
||||
tabListClassName="mt-0.5 -mb-1.5"
|
||||
>
|
||||
<TabContent value={TAB_RESPONSE}>
|
||||
{noResponse ? (
|
||||
<EmptyState>
|
||||
<div className="text-text-subtle mb-1">
|
||||
{flow.cancelled ? "Cancelled" : "No response"}
|
||||
</div>
|
||||
<div className="text-xs">
|
||||
{flow.cancelled
|
||||
? "The app cancelled the request before a response came back."
|
||||
: "The request never got a response."}{" "}
|
||||
The Request tab has what the app sent.
|
||||
</div>
|
||||
</EmptyState>
|
||||
) : (
|
||||
<FlowBodyViewer flow={flow} which="response" pretty={pretty} />
|
||||
)}
|
||||
</TabContent>
|
||||
<TabContent value={TAB_REQUEST}>
|
||||
<FlowBodyViewer flow={flow} which="request" pretty />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_HEADERS}>
|
||||
<FlowHeaders flow={flow} />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_QUERY}>
|
||||
<FlowQuery flow={flow} />
|
||||
</TabContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ViewModeButtonProps {
|
||||
buttonProps: TabButtonRenderProps["buttonProps"];
|
||||
isActive: boolean;
|
||||
pretty: boolean;
|
||||
onChangePretty: (pretty: boolean) => void;
|
||||
prettyLabel: string;
|
||||
allowRaw: boolean;
|
||||
}
|
||||
|
||||
/** The client's Response tab: a click on the active tab opens its view menu */
|
||||
function ViewModeButton({
|
||||
buttonProps,
|
||||
isActive,
|
||||
pretty,
|
||||
onChangePretty,
|
||||
prettyLabel,
|
||||
allowRaw,
|
||||
}: ViewModeButtonProps) {
|
||||
const showPretty = pretty || !allowRaw;
|
||||
const items: DropdownItem[] = [
|
||||
{
|
||||
label: prettyLabel,
|
||||
leftSlot: <CheckMark visible={showPretty} />,
|
||||
onSelect: () => onChangePretty(true),
|
||||
},
|
||||
{
|
||||
label: `${prettyLabel} (Raw)`,
|
||||
hidden: !allowRaw,
|
||||
leftSlot: <CheckMark visible={!showPretty} />,
|
||||
onSelect: () => onChangePretty(false),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Dropdown items={items}>
|
||||
<Button
|
||||
{...buttonProps}
|
||||
onClick={(e) => {
|
||||
if (isActive) return;
|
||||
e.preventDefault();
|
||||
buttonProps.onClick?.(e);
|
||||
}}
|
||||
rightSlot={
|
||||
<Icon
|
||||
size="sm"
|
||||
icon="chevron_down"
|
||||
className={classNames("ml-1", isActive ? "text-text-subtle" : "text-text-subtlest")}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{showPretty ? prettyLabel : "Raw"}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckMark({ visible }: { visible: boolean }) {
|
||||
return <Icon icon={visible ? "check" : "empty"} size="sm" />;
|
||||
}
|
||||
|
||||
function Keys({ keys }: { keys: string[] }) {
|
||||
return (
|
||||
<span className="flex gap-1 justify-end">
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import { formatSize } from "@yaakapp-internal/lib/formatSize";
|
||||
import type { Flow } from "@yaakapp-internal/proxy-lib";
|
||||
import {
|
||||
DetailsBanner,
|
||||
formatMillis,
|
||||
HeadersTable,
|
||||
KeyValueRow,
|
||||
KeyValueRows,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { useMemo } from "react";
|
||||
import { EmptyState } from "./FlowBodyViewer";
|
||||
|
||||
const startedFormat = new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
fractionalSecondDigits: 3,
|
||||
timeZoneName: "short",
|
||||
});
|
||||
|
||||
const bytesFormat = new Intl.NumberFormat("en-US");
|
||||
|
||||
export function FlowHeaders({ flow }: { flow: Flow }) {
|
||||
const versions =
|
||||
flow.upstreamHttpVersion == null
|
||||
? `${flow.clientHttpVersion} from the app`
|
||||
: `${flow.clientHttpVersion} from the app, ${flow.upstreamHttpVersion} to the server`;
|
||||
|
||||
return (
|
||||
<div className="overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5">
|
||||
<DetailsBanner defaultOpen storageKey="flow.info" summary={<h2>Info</h2>}>
|
||||
<KeyValueRows>
|
||||
<KeyValueRow label="Started">
|
||||
{startedFormat.format(new Date(`${flow.startedAt}Z`))}
|
||||
</KeyValueRow>
|
||||
<KeyValueRow label="URL">{flow.url}</KeyValueRow>
|
||||
<KeyValueRow label="Remote Address">{flow.remoteAddr ?? <Missing />}</KeyValueRow>
|
||||
<KeyValueRow label="Version">{versions}</KeyValueRow>
|
||||
<KeyValueRow label="Timing">
|
||||
{flow.elapsedMs == null ? (
|
||||
<Missing />
|
||||
) : flow.elapsedHeadersMs == null ? (
|
||||
`${formatMillis(flow.elapsedMs)} in all`
|
||||
) : (
|
||||
`${formatMillis(flow.elapsedMs)} in all, headers after ${formatMillis(flow.elapsedHeadersMs)}`
|
||||
)}
|
||||
</KeyValueRow>
|
||||
<KeyValueRow label="Size">
|
||||
<span>
|
||||
{formatSize(flow.requestSize)} sent, {formatSize(flow.responseSize)} received{" "}
|
||||
<span className="text-text-subtlest">
|
||||
({bytesFormat.format(flow.responseSize)} B)
|
||||
</span>
|
||||
</span>
|
||||
</KeyValueRow>
|
||||
</KeyValueRows>
|
||||
</DetailsBanner>
|
||||
<HeadersTable
|
||||
title="Request Headers"
|
||||
headers={flow.requestHeaders}
|
||||
labelColor="primary"
|
||||
storageKey="flow.request_headers"
|
||||
/>
|
||||
<HeadersTable
|
||||
defaultOpen
|
||||
title="Response Headers"
|
||||
headers={flow.responseHeaders}
|
||||
labelColor="info"
|
||||
storageKey="flow.response_headers"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FlowQuery({ flow }: { flow: Flow }) {
|
||||
const params = useMemo(() => queryParams(flow.url), [flow.url]);
|
||||
|
||||
if (params.length === 0) {
|
||||
return <EmptyState>No query parameters</EmptyState>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-auto h-full pb-4">
|
||||
<KeyValueRows>
|
||||
{params.map(([name, value], i) => (
|
||||
// oxlint-disable-next-line react/no-array-index-key -- a name can repeat
|
||||
<KeyValueRow key={i} label={name} labelColor="primary">
|
||||
{value}
|
||||
</KeyValueRow>
|
||||
))}
|
||||
</KeyValueRows>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function queryParams(url: string): [string, string][] {
|
||||
try {
|
||||
return [...new URL(url).searchParams.entries()];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function Missing() {
|
||||
return <span className="text-text-subtlest">--</span>;
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { formatSize } from "@yaakapp-internal/lib/formatSize";
|
||||
import type { Flow } from "@yaakapp-internal/proxy-lib";
|
||||
import {
|
||||
CONTROL_TOOLTIP_DELAY,
|
||||
DetailsBanner,
|
||||
formatMillis,
|
||||
Icon,
|
||||
type IconProps,
|
||||
KeyValueRow,
|
||||
KeyValueRows,
|
||||
Tooltip,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { format } from "date-fns";
|
||||
import { Fragment, type HTMLAttributes, type ReactElement, type ReactNode, useMemo } from "react";
|
||||
import { utcTimestamp } from "../lib/flowViews";
|
||||
import { flowPhases, type PhaseKind } from "../lib/phases";
|
||||
import { folderOf } from "../lib/search";
|
||||
import { AppIcon } from "./AppIcon";
|
||||
|
||||
/**
|
||||
* Where a flow came from and how it travelled: in a proxy you didn't send the request, so the
|
||||
* connection on each leg, the app and its process, timing, sizes and the flow's record are part
|
||||
* of the answer. Then the timeline: each phase of the exchange and how long it took.
|
||||
*/
|
||||
export function FlowInfo({ flow }: { flow: Flow }) {
|
||||
const folder = folderOf(flow);
|
||||
const clientPort = portOf(flow.clientAddr);
|
||||
|
||||
return (
|
||||
<div className="overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5">
|
||||
<Section title="Connection">
|
||||
{[
|
||||
row("remote", "Remote Address", flow.remoteAddr, { copy: true }),
|
||||
row("client_version", "Client Version", flow.clientHttpVersion),
|
||||
row("server_version", "Server Version", flow.upstreamHttpVersion),
|
||||
row("tls", "TLS", flow.upstreamTlsVersion),
|
||||
row("cipher", "Cipher", flow.upstreamTlsCipher),
|
||||
row("client_port", "Client Port", clientPort),
|
||||
]}
|
||||
</Section>
|
||||
<Section title="App">
|
||||
{[
|
||||
row(
|
||||
"app",
|
||||
"Name",
|
||||
flow.clientName == null ? null : (
|
||||
<span className="flex items-center gap-1.5">
|
||||
{flow.clientPath != null && <AppIcon path={flow.clientPath} className="size-5" />}
|
||||
{flow.clientName}
|
||||
{folder != null && <span className="text-text-subtlest">{folder}</span>}
|
||||
</span>
|
||||
),
|
||||
),
|
||||
flow.clientBundleId != null
|
||||
? row("bundle", "Bundle ID", flow.clientBundleId, { copy: true })
|
||||
: row("path", "Path", flow.clientPath, { copy: true }),
|
||||
row("pid", "PID", flow.clientPid == null ? null : `${flow.clientPid}`),
|
||||
row("cwd", "Directory", flow.clientCwd, { copy: true }),
|
||||
]}
|
||||
</Section>
|
||||
<Section title="Timing">
|
||||
{[
|
||||
row(
|
||||
"started",
|
||||
"Started",
|
||||
format(utcTimestamp(flow.startedAt), "MMM d, yyyy, h:mm:ss.SSS a"),
|
||||
),
|
||||
row("headers", "Latency", ms(flow.elapsedHeadersMs)),
|
||||
row("total", "Time", ms(flow.elapsedMs)),
|
||||
]}
|
||||
</Section>
|
||||
<Section title="Size">
|
||||
{[
|
||||
row("request_size", "Request", formatSize(flow.requestSize)),
|
||||
row("response_size", "Response", formatSize(flow.responseSize)),
|
||||
]}
|
||||
</Section>
|
||||
<Section title="Flow">
|
||||
{[
|
||||
row("number", "Number", `#${flow.number}`, { copy: true }),
|
||||
row("id", "ID", flow.id, { copy: true }),
|
||||
row("saved", "Yaak Request ID", flow.savedRequestId, { copy: true }),
|
||||
]}
|
||||
</Section>
|
||||
<Timeline flow={flow} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const PHASE_ICONS: Record<PhaseKind, { icon: IconProps["icon"]; color: IconProps["color"] }> = {
|
||||
start: { icon: "send_horizontal", color: "secondary" },
|
||||
step: { icon: "arrow_right", color: "secondary" },
|
||||
end: { icon: "check", color: "success" },
|
||||
error: { icon: "alert_triangle", color: "danger" },
|
||||
cancelled: { icon: "circle_off", color: "secondary" },
|
||||
};
|
||||
|
||||
/** The flow's phases, each with how long it took and when, from the start */
|
||||
function Timeline({ flow }: { flow: Flow }) {
|
||||
const phases = useMemo(() => flowPhases(flow), [flow]);
|
||||
if (phases.length === 0) return null;
|
||||
return (
|
||||
<DetailsBanner defaultOpen summary={<h2>Timeline</h2>}>
|
||||
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto_auto] gap-x-3 gap-y-1.5 items-center text-sm py-1">
|
||||
{phases.map((p) => {
|
||||
const { icon, color } = PHASE_ICONS[p.kind];
|
||||
return (
|
||||
<Fragment key={p.key}>
|
||||
<Icon icon={icon} color={color} size="sm" />
|
||||
{p.detail == null ? (
|
||||
<span className="truncate">{p.label}</span>
|
||||
) : (
|
||||
<Tooltip wrap content={p.detail} delay={CONTROL_TOOLTIP_DELAY}>
|
||||
<span className="truncate">
|
||||
{p.label}
|
||||
<span className="text-text-subtlest">{` ${p.detail}`}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
<span className="font-mono text-xs tabular-nums text-right">
|
||||
{p.kind === "start" ? "" : `+${formatMillis(p.tookMs)}`}
|
||||
</span>
|
||||
<span className="font-mono text-xs tabular-nums text-right text-text-subtlest">
|
||||
{formatMillis(p.atMs)}
|
||||
</span>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DetailsBanner>
|
||||
);
|
||||
}
|
||||
|
||||
/** A row as `KeyValueRows` takes it */
|
||||
type Row = ReactElement<HTMLAttributes<HTMLTableColElement>>;
|
||||
|
||||
function Section({ title, children }: { title: string; children: (Row | null)[] }) {
|
||||
const rows = children.filter((r): r is Row => r != null);
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
<DetailsBanner defaultOpen summary={<h2>{title}</h2>}>
|
||||
<KeyValueRows>{rows}</KeyValueRows>
|
||||
</DetailsBanner>
|
||||
);
|
||||
}
|
||||
|
||||
/** A row, or nothing when there's no value for it */
|
||||
function row(
|
||||
key: string,
|
||||
label: string,
|
||||
value: ReactNode | null | undefined,
|
||||
opts: { copy?: boolean } = {},
|
||||
): Row | null {
|
||||
if (value == null || value === "") return null;
|
||||
return (
|
||||
<KeyValueRow
|
||||
key={key}
|
||||
labelColor="secondary"
|
||||
label={label}
|
||||
enableCopy={opts.copy}
|
||||
copy={copyText}
|
||||
>
|
||||
{value}
|
||||
</KeyValueRow>
|
||||
);
|
||||
}
|
||||
|
||||
function ms(value: number | null): string | null {
|
||||
return value == null ? null : formatMillis(value);
|
||||
}
|
||||
|
||||
/** The port of `127.0.0.1:54321` or `[::1]:54321` */
|
||||
export function portOf(addr: string | null | undefined): string | null {
|
||||
const port = addr?.match(/:(\d+)$/)?.[1];
|
||||
return port ?? null;
|
||||
}
|
||||
|
||||
function copyText(text: string) {
|
||||
navigator.clipboard.writeText(text).catch(console.error);
|
||||
}
|
||||
+1190
-176
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,120 @@
|
||||
import { EmptyStateText, Hotkey, Icon, InlineCode } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useAtomValue, useStore } from "jotai";
|
||||
import { useEffect, useState } from "react";
|
||||
import { appliedFilterAtom } from "../lib/atoms";
|
||||
import { applyFilter } from "../lib/navigation";
|
||||
import { rpc } from "../lib/rpc";
|
||||
import { activeQuery, widerQueries } from "../lib/query";
|
||||
import {
|
||||
hideEncryptedAtom,
|
||||
hideSystemAtom,
|
||||
pausedAtAtom,
|
||||
proxyStateAtom,
|
||||
searchWindowAtom,
|
||||
} from "../lib/store";
|
||||
|
||||
/** Nothing to list: no flows yet, recording paused, or a query nothing matches */
|
||||
export function FlowListEmpty() {
|
||||
const text = useAtomValue(appliedFilterAtom);
|
||||
const search = useAtomValue(searchWindowAtom);
|
||||
const proxyState = useAtomValue(proxyStateAtom);
|
||||
if (search?.loading) return null;
|
||||
if (text.trim() !== "") return <NoResults text={text} />;
|
||||
|
||||
const other = proxyState?.otherOwner ?? null;
|
||||
const port = proxyState?.port ?? other?.proxyPort ?? null;
|
||||
const running = proxyState?.state === "running" || other != null;
|
||||
const recording = proxyState?.recording !== false;
|
||||
return (
|
||||
<EmptyStateText wrapperClassName="p-1.5" className="not-italic flex-col gap-1 text-center px-6">
|
||||
<div className="text-text text-sm">{recording ? "No flows yet" : "Recording is paused"}</div>
|
||||
<div className="text-sm text-text-subtle max-w-[26rem]">
|
||||
{!running
|
||||
? "Retry from the status menu to start the proxy."
|
||||
: !recording
|
||||
? "Traffic passes through without being recorded."
|
||||
: `Traffic sent to port ${port ?? "--"} will be recorded.`}
|
||||
</div>
|
||||
</EmptyStateText>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The list's last row while recording is paused, where the next flow would appear: since when.
|
||||
*/
|
||||
export function PausedRow({ style }: { style?: React.CSSProperties }) {
|
||||
const pausedAt = useAtomValue(pausedAtAtom);
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
className={classNames(
|
||||
"absolute left-1.5 right-1.5 flex items-center gap-2 h-xs px-3 mt-1",
|
||||
"rounded-md border border-dashed border-border-subtle text-text-subtle text-sm",
|
||||
)}
|
||||
>
|
||||
<Icon icon="circle_pause" size="sm" className="text-text-subtlest" />
|
||||
<span className="truncate">
|
||||
{pausedAt == null ? "Recording is paused." : `Recording was paused at ${timeOf(pausedAt)}.`}
|
||||
</span>
|
||||
<Hotkey action="recording_toggle" variant="text" className="ml-auto" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A query nothing matches: wider ones that do, each the query with one term left out, as Yaak's
|
||||
* sidebar answers, with how many each would match.
|
||||
*/
|
||||
function NoResults({ text }: { text: string }) {
|
||||
const hideSystem = useAtomValue(hideSystemAtom);
|
||||
const hideEncrypted = useAtomValue(hideEncryptedAtom);
|
||||
const store = useStore();
|
||||
const setText = (wider: string) => applyFilter(store, wider);
|
||||
const [wider, setWider] = useState<{ text: string; count: number }[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let current = true;
|
||||
const candidates = widerQueries(text);
|
||||
void Promise.all(
|
||||
candidates.map(async (candidate) => {
|
||||
const query = activeQuery(candidate, hideSystem, hideEncrypted);
|
||||
const res = await rpc("list_flows", { ...query, limit: 0 });
|
||||
return { text: candidate, count: query == null ? res.total : (res.matching ?? res.total) };
|
||||
}),
|
||||
)
|
||||
.then((found) => {
|
||||
if (current) setWider(found.filter((f) => f.count > 0).slice(0, 3));
|
||||
})
|
||||
.catch(() => {
|
||||
if (current) setWider([]);
|
||||
});
|
||||
return () => {
|
||||
current = false;
|
||||
};
|
||||
}, [text, hideSystem, hideEncrypted]);
|
||||
|
||||
return (
|
||||
<EmptyStateText
|
||||
wrapperClassName="p-1.5 !h-auto"
|
||||
className="not-italic !h-auto py-3 px-4 text-sm text-text-subtle"
|
||||
>
|
||||
<div className="text-center leading-relaxed">
|
||||
{wider.length === 0 ? "No results" : "No results, but found matches for "}
|
||||
{wider.map((w, i) => (
|
||||
<span key={w.text}>
|
||||
{i > 0 && " or "}
|
||||
<button type="button" className="hover:underline" onClick={() => setText(w.text)}>
|
||||
<InlineCode>{w.text === "" ? "all traffic" : w.text}</InlineCode>
|
||||
</button>{" "}
|
||||
({w.count.toLocaleString()})
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</EmptyStateText>
|
||||
);
|
||||
}
|
||||
|
||||
function timeOf(ms: number): string {
|
||||
return new Date(ms).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
import {
|
||||
DndContext,
|
||||
type DragStartEvent,
|
||||
PointerSensor,
|
||||
useDraggable,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import type { FlowSortKey } from "@yaakapp-internal/proxy-lib";
|
||||
import {
|
||||
ContextMenu,
|
||||
Dropdown,
|
||||
type DropdownItem,
|
||||
Icon,
|
||||
IconButton,
|
||||
ResizeHandle,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import {
|
||||
memo,
|
||||
type MouseEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
addSortKey,
|
||||
allColumns,
|
||||
type Column,
|
||||
type ColumnDef,
|
||||
type ColumnId,
|
||||
type ColumnPrefs,
|
||||
type CustomColumnId,
|
||||
canHide,
|
||||
dropBoundary,
|
||||
hideOtherColumns,
|
||||
isCustomColumn,
|
||||
moveColumn,
|
||||
moveColumnBy,
|
||||
removeCustomColumn,
|
||||
resetOrder,
|
||||
resetWidth,
|
||||
resetWidths,
|
||||
resizeColumn,
|
||||
resizeDistance,
|
||||
toggleColumn,
|
||||
unavailableColumns,
|
||||
} from "../lib/columns";
|
||||
import { columnPrefsAtom, hasAppsAtom } from "../lib/store";
|
||||
import { addColumnDialogAtom } from "./AddColumnDialog";
|
||||
|
||||
/** Pixels a press on a label moves before it drags the column rather than sorting by it */
|
||||
const DRAG_DISTANCE = 4;
|
||||
/** How near the list's side edge, in pixels, a dragged column scrolls the list that way */
|
||||
const EDGE = 40;
|
||||
/** The most the list scrolls sideways a frame, at the very edge */
|
||||
const EDGE_SPEED = 18;
|
||||
|
||||
interface Props {
|
||||
columns: Column[];
|
||||
template: string;
|
||||
/** The rows' width, as CSS, so the header scrolls sideways with them */
|
||||
width: string;
|
||||
prefs: ColumnPrefs;
|
||||
sort: FlowSortKey[];
|
||||
/** A click on a label, or with `add`, an Option-click */
|
||||
onSort: (column: ColumnId, add: boolean) => void;
|
||||
/** Sets the sort from the menus; empty goes back to arrival order */
|
||||
onSetSort: (sort: FlowSortKey[]) => void;
|
||||
onResetSort: () => void;
|
||||
/** The rows' scrolling element, which a column dragged to its edge scrolls sideways */
|
||||
scrollElement: () => HTMLElement | null;
|
||||
/** A column's drag, for the list to show: its ghost, and the columns making room for it */
|
||||
onColumnDrag: (event: ColumnDragEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A column's drag: its start, the pointer's moves (`x`, across the window), the boundary it
|
||||
* would drop at when that changes (`to`, null where it would stay), and its end, dropped there
|
||||
* or put back
|
||||
*/
|
||||
export type ColumnDragEvent =
|
||||
| { type: "start"; id: ColumnId; x: number }
|
||||
| { type: "move"; x: number }
|
||||
| { type: "target"; to: number | null }
|
||||
| { type: "end"; dropped: boolean };
|
||||
|
||||
/**
|
||||
* The list's only control surface: a click on a label sorts by it alone, a second reverses it
|
||||
* and a third goes back to arrival order; Option-click adds it to the sort as a further key.
|
||||
* Dragging a label moves its column, and Escape puts it back. Dragging the edge after a label
|
||||
* resizes its column and a double-click on it resets it. A right-click on a label opens the
|
||||
* column's menu; the picker sits in the empty corner on hover.
|
||||
*/
|
||||
export const FlowListHeader = memo(function FlowListHeader({
|
||||
columns,
|
||||
template,
|
||||
width,
|
||||
prefs,
|
||||
sort,
|
||||
onSort,
|
||||
onSetSort: setSort,
|
||||
onResetSort,
|
||||
scrollElement,
|
||||
onColumnDrag,
|
||||
}: Props) {
|
||||
const setPrefs = useSetAtom(columnPrefsAtom);
|
||||
const unavailable = unavailableColumns(useAtomValue(hasAppsAtom));
|
||||
const openAddColumn = useSetAtom(addColumnDialogAtom);
|
||||
const [menu, setMenu] = useState<{ x: number; y: number; column: ColumnDef | null } | null>(null);
|
||||
// Widths when a drag began, so each move sets the width from where it started.
|
||||
const startWidth = useRef(0);
|
||||
const { sensors, dndHandlers, gridRef, onPointerDownCapture } = useColumnDrag({
|
||||
columns,
|
||||
scrollElement,
|
||||
onColumnDrag,
|
||||
onDrop: (id, to) => setPrefs((p) => moveColumn(p, id, to, unavailable)),
|
||||
});
|
||||
|
||||
// Taking out the sort's only key goes back to arrival order, as Reset Sort does
|
||||
const removeColumn = (id: CustomColumnId) => {
|
||||
if (sort.length > 0 && sort.every((k) => k.column === id)) onResetSort();
|
||||
setPrefs((p) => removeCustomColumn(p, id));
|
||||
};
|
||||
|
||||
const pickerItems: DropdownItem[] = [
|
||||
{
|
||||
type: "content",
|
||||
label: <div className="px-1.5 pt-1 text-xs text-text-subtlest">Columns</div>,
|
||||
},
|
||||
...allColumns(prefs).map((c): DropdownItem => {
|
||||
const shown = prefs.visible[c.id] ?? c.defaultVisible;
|
||||
return {
|
||||
label: c.label,
|
||||
keepOpenOnSelect: true,
|
||||
disabled: shown && !canHide(prefs, c.id, unavailable),
|
||||
leftSlot: shown ? (
|
||||
<Icon icon="check" size="sm" />
|
||||
) : (
|
||||
<span aria-hidden className="inline-block w-4 h-4" />
|
||||
),
|
||||
onSelect: () => setPrefs((p) => toggleColumn(p, c.id, unavailable)),
|
||||
...(isCustomColumn(c.id)
|
||||
? {
|
||||
submenuTrigger: "button" as const,
|
||||
submenu: [
|
||||
{
|
||||
label: "Remove Column",
|
||||
icon: "trash" as const,
|
||||
color: "danger" as const,
|
||||
onSelect: () => removeColumn(c.id as CustomColumnId),
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}),
|
||||
{ label: "Add Column", icon: "plus", onSelect: () => openAddColumn(true) },
|
||||
{ type: "separator" },
|
||||
{ label: "Reset Sort", disabled: sort.length === 0, onSelect: onResetSort },
|
||||
{ label: "Reset Column Widths", onSelect: () => setPrefs((p) => resetWidths(p)) },
|
||||
{
|
||||
label: "Reset Column Order",
|
||||
disabled: prefs.order.length === 0,
|
||||
onSelect: () => setPrefs((p) => resetOrder(p)),
|
||||
},
|
||||
];
|
||||
|
||||
const columnItems = (c: ColumnDef): DropdownItem[] => {
|
||||
const sortBy = (descending: boolean) => setSort([{ column: c.id, descending }]);
|
||||
const addTo = (descending: boolean) => setSort(addSortKey(sort, c.id, descending));
|
||||
const at = columns.findIndex((col) => col.id === c.id);
|
||||
return [
|
||||
{ label: "Sort Ascending", onSelect: () => sortBy(false) },
|
||||
{ label: "Sort Descending", onSelect: () => sortBy(true) },
|
||||
{ label: "Add to Sort Ascending", onSelect: () => addTo(false) },
|
||||
{ label: "Add to Sort Descending", onSelect: () => addTo(true) },
|
||||
{ label: "Reset Sort", disabled: sort.length === 0, onSelect: onResetSort },
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Move Left",
|
||||
disabled: at <= 0,
|
||||
onSelect: () => setPrefs((p) => moveColumnBy(p, c.id, -1, unavailable)),
|
||||
},
|
||||
{
|
||||
label: "Move Right",
|
||||
disabled: at === -1 || at >= columns.length - 1,
|
||||
onSelect: () => setPrefs((p) => moveColumnBy(p, c.id, 1, unavailable)),
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Hide Column",
|
||||
disabled: !canHide(prefs, c.id, unavailable),
|
||||
onSelect: () => setPrefs((p) => toggleColumn(p, c.id, unavailable)),
|
||||
},
|
||||
{
|
||||
label: "Hide Other Columns",
|
||||
disabled: columns.length <= 1,
|
||||
onSelect: () => setPrefs((p) => hideOtherColumns(p, c.id)),
|
||||
},
|
||||
...(isCustomColumn(c.id)
|
||||
? [
|
||||
{
|
||||
label: "Remove Column",
|
||||
color: "danger" as const,
|
||||
onSelect: () => removeColumn(c.id as CustomColumnId),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ type: "separator" },
|
||||
{ label: "Reset Column Widths", onSelect: () => setPrefs((p) => resetWidths(p)) },
|
||||
];
|
||||
};
|
||||
|
||||
// A right-click on a label is for its column; anywhere else in the header, the picker.
|
||||
const openMenu = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const el = (e.target as HTMLElement).closest<HTMLElement>("[data-column]");
|
||||
const id = el?.dataset.column as ColumnId | undefined;
|
||||
const column = columns.find((c) => c.id === id) ?? null;
|
||||
setMenu({ x: e.clientX, y: e.clientY, column });
|
||||
},
|
||||
[columns],
|
||||
);
|
||||
|
||||
return (
|
||||
// The same margins and padding as the rows, so each label sits over its cells.
|
||||
<div style={{ width, minWidth: "100%" }} className="px-1.5">
|
||||
{/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- a right-click opens the column's menu */}
|
||||
<div
|
||||
ref={gridRef}
|
||||
data-flow-header
|
||||
onPointerDownCapture={onPointerDownCapture}
|
||||
onContextMenu={openMenu}
|
||||
style={{ gridTemplateColumns: template }}
|
||||
className={classNames(
|
||||
"group/header grid items-center h-xs px-1.5 select-none",
|
||||
"text-xs text-text-subtlest border-b border-border-subtle",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center justify-center">
|
||||
<Dropdown items={pickerItems}>
|
||||
<IconButton
|
||||
size="xs"
|
||||
icon="settings_2"
|
||||
title="Columns"
|
||||
tooltip
|
||||
iconColor="secondary"
|
||||
className="opacity-0 group-hover/header:opacity-100 focus-visible:opacity-100 !h-5 !w-5"
|
||||
/>
|
||||
</Dropdown>
|
||||
</span>
|
||||
<DndContext sensors={sensors} autoScroll={false} {...dndHandlers}>
|
||||
{columns.map((c) => (
|
||||
<HeaderCell
|
||||
key={c.id}
|
||||
column={c}
|
||||
sort={sort}
|
||||
onSort={onSort}
|
||||
onResizeStart={() => {
|
||||
startWidth.current = c.current;
|
||||
}}
|
||||
onResizeMove={(dx) => {
|
||||
const by = resizeDistance(c, dx, rootFontSize());
|
||||
setPrefs((p) => resizeColumn(p, c.id, startWidth.current + by));
|
||||
}}
|
||||
onResetWidth={() => setPrefs((p) => resetWidth(p, c.id))}
|
||||
/>
|
||||
))}
|
||||
</DndContext>
|
||||
<ContextMenu
|
||||
triggerPosition={menu}
|
||||
items={menu == null ? [] : menu.column == null ? pickerItems : columnItems(menu.column)}
|
||||
onClose={() => setMenu(null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/** The interface font size now, which numeric columns' widths grow with */
|
||||
function rootFontSize(): number {
|
||||
return Number.parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||
}
|
||||
|
||||
interface HeaderCellProps {
|
||||
column: Column;
|
||||
sort: FlowSortKey[];
|
||||
onSort: (column: ColumnId, add: boolean) => void;
|
||||
onResizeStart: () => void;
|
||||
/** How far the edge moved since the resize began */
|
||||
onResizeMove: (dx: number) => void;
|
||||
onResetWidth: () => void;
|
||||
}
|
||||
|
||||
function HeaderCell({
|
||||
column: c,
|
||||
sort,
|
||||
onSort,
|
||||
onResizeStart,
|
||||
onResizeMove,
|
||||
onResetWidth,
|
||||
}: HeaderCellProps) {
|
||||
// The label drags the column; the resize handle beside it is outside it, so it still resizes.
|
||||
const { listeners, setNodeRef } = useDraggable({ id: c.id });
|
||||
const sorted = sort.some((k) => k.column === c.id);
|
||||
return (
|
||||
<span data-column={c.id} className="relative h-full flex items-center min-w-0 px-1.5">
|
||||
<button
|
||||
ref={setNodeRef}
|
||||
type="button"
|
||||
{...listeners}
|
||||
// A press that moves drags instead; dnd-kit swallows the click that ends a drag.
|
||||
onClick={(e) => onSort(c.id, e.altKey)}
|
||||
// No tooltip: the header sits under the search field, and a tooltip here covered it
|
||||
aria-label={`Sort by ${c.label}`}
|
||||
className={classNames(
|
||||
"flex items-center gap-0.5 min-w-0 w-full h-full whitespace-nowrap",
|
||||
"hover:text-text-subtle",
|
||||
c.align === "right" && "justify-end",
|
||||
sorted && "text-text-subtle",
|
||||
)}
|
||||
>
|
||||
<ColumnLabel column={c} sort={sort} />
|
||||
</button>
|
||||
{/* On the column's right edge, the boundary with the next, resizing this one */}
|
||||
<ResizeHandle
|
||||
side="right"
|
||||
justify="center"
|
||||
className="absolute top-0 translate-x-1/2"
|
||||
onResizeStart={onResizeStart}
|
||||
onResizeMove={(e) => onResizeMove(e.x - e.xStart)}
|
||||
onReset={onResetWidth}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** A column's name, after its sort direction and, sorted by several, its place in the sort */
|
||||
function ColumnLabel({ column: c, sort }: { column: Column; sort: FlowSortKey[] }) {
|
||||
const at = sort.findIndex((k) => k.column === c.id);
|
||||
const key = sort[at];
|
||||
return (
|
||||
<>
|
||||
{key != null && (
|
||||
<Icon
|
||||
icon="chevron_down"
|
||||
size="xs"
|
||||
className={classNames("shrink-0", !key.descending && "rotate-180")}
|
||||
/>
|
||||
)}
|
||||
{key != null && sort.length > 1 && (
|
||||
<span className="shrink-0 -ml-0.5 text-2xs tabular-nums">{at + 1}</span>
|
||||
)}
|
||||
<span className="truncate">{c.id === "app" ? "" : c.label}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dragging a column's label: where it would drop, the boundary between the labels nearest the
|
||||
* pointer as they were laid out when the drag began, and the list scrolling sideways while the
|
||||
* pointer is near its edge. The list shows the drag; the columns only change order on the drop,
|
||||
* so the rows lay out once.
|
||||
*/
|
||||
function useColumnDrag({
|
||||
columns,
|
||||
scrollElement,
|
||||
onColumnDrag,
|
||||
onDrop,
|
||||
}: {
|
||||
columns: Column[];
|
||||
scrollElement: () => HTMLElement | null;
|
||||
onColumnDrag: (event: ColumnDragEvent) => void;
|
||||
/** Moves the column to the boundary `to` between those shown */
|
||||
onDrop: (id: ColumnId, to: number) => void;
|
||||
}) {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: DRAG_DISTANCE } }),
|
||||
);
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
// Live while dragging, read each frame
|
||||
const state = useRef<{
|
||||
/** The column dragged, and its place among those shown */
|
||||
id: ColumnId;
|
||||
from: number;
|
||||
/** The labels' edges when the drag began, from the header's left, as the columns slide */
|
||||
cells: { left: number; right: number }[];
|
||||
/** The columns shown when it began, which the edges and `from` describe */
|
||||
shown: string;
|
||||
/** The pointer, across the window */
|
||||
x: number;
|
||||
/** The boundary it would move to, null where it would stay */
|
||||
to: number | null;
|
||||
/** The pointer the ghost was last moved to */
|
||||
ghostX: number;
|
||||
frame: number;
|
||||
} | null>(null);
|
||||
const latest = useRef({ columns, scrollElement, onColumnDrag, onDrop });
|
||||
useLayoutEffect(() => {
|
||||
latest.current = { columns, scrollElement, onColumnDrag, onDrop };
|
||||
});
|
||||
|
||||
/** Ends the drag, dropped or put back */
|
||||
const stop = useCallback((dropped: boolean) => {
|
||||
const s = state.current;
|
||||
state.current = null;
|
||||
if (s == null) return null;
|
||||
cancelAnimationFrame(s.frame);
|
||||
latest.current.onColumnDrag({ type: "end", dropped });
|
||||
return s;
|
||||
}, []);
|
||||
|
||||
const onDragStart = useCallback((e: DragStartEvent) => {
|
||||
const grid = gridRef.current;
|
||||
const from = latest.current.columns.findIndex((c) => c.id === e.active.id);
|
||||
if (grid == null || from === -1) return;
|
||||
const id = e.active.id as ColumnId;
|
||||
const x = pointer.current;
|
||||
// First, as it ends the last drag's columns sliding home, so the edges read below are where
|
||||
// the columns are laid out, not where they were sliding
|
||||
latest.current.onColumnDrag({ type: "start", id, x });
|
||||
const gridLeft = grid.getBoundingClientRect().left;
|
||||
const cells = Array.from(grid.querySelectorAll<HTMLElement>(":scope > [data-column]")).map(
|
||||
(el) => {
|
||||
const box = el.getBoundingClientRect();
|
||||
return { left: box.left - gridLeft, right: box.right - gridLeft };
|
||||
},
|
||||
);
|
||||
const shown = shownKey(latest.current.columns);
|
||||
state.current = { id, from, cells, shown, x, to: null, ghostX: x, frame: 0 };
|
||||
target(state.current);
|
||||
|
||||
const tick = () => {
|
||||
const s = state.current;
|
||||
if (s == null) return;
|
||||
// Near an edge, the list scrolls that way, faster the nearer; the header follows it.
|
||||
const list = latest.current.scrollElement();
|
||||
if (list != null) {
|
||||
const { left, right } = list.getBoundingClientRect();
|
||||
const near =
|
||||
s.x < left + EDGE ? s.x - left - EDGE : s.x > right - EDGE ? s.x - right + EDGE : 0;
|
||||
if (near !== 0) {
|
||||
const speed = Math.min(1, Math.abs(near) / EDGE) * EDGE_SPEED;
|
||||
list.scrollLeft += Math.sign(near) * Math.max(1, speed);
|
||||
}
|
||||
}
|
||||
target(s);
|
||||
if (s.x !== s.ghostX) {
|
||||
s.ghostX = s.x;
|
||||
latest.current.onColumnDrag({ type: "move", x: s.x });
|
||||
}
|
||||
s.frame = requestAnimationFrame(tick);
|
||||
};
|
||||
state.current.frame = requestAnimationFrame(tick);
|
||||
// oxlint-disable-next-line react-hooks/exhaustive-deps -- `target` reads only refs
|
||||
}, []);
|
||||
|
||||
/** Drops the column where the pointer is, once; a second call finds no drag */
|
||||
const drop = useCallback(() => {
|
||||
// Where the pointer is now, as a release can come before the next frame reads it
|
||||
if (state.current != null) target(state.current);
|
||||
// The list lets go of the drag and the order changes in the same task, so nothing jumps.
|
||||
const s = stop(true);
|
||||
if (s?.to != null) latest.current.onDrop(s.id, s.to);
|
||||
// oxlint-disable-next-line react-hooks/exhaustive-deps -- `target` reads only refs
|
||||
}, [stop]);
|
||||
const cancel = useCallback(() => {
|
||||
stop(false);
|
||||
}, [stop]);
|
||||
|
||||
// Columns shown or hidden under a drag, as App is when the first flow with an app arrives,
|
||||
// leave its edges and target describing columns that are gone; it is put back instead.
|
||||
const shown = shownKey(columns);
|
||||
useEffect(() => {
|
||||
if (state.current != null && state.current.shown !== shown) cancel();
|
||||
}, [shown, cancel]);
|
||||
|
||||
// The pointer from a press on the header until its release, read before dnd-kit sees it.
|
||||
// dnd-kit starts a drag on the move that crosses `DRAG_DISTANCE` and reports moves and the
|
||||
// release only once it has rendered the drag, so a release right after a move would drop
|
||||
// where the pointer was a frame before, or not at all. The release drops it here instead.
|
||||
const pointer = useRef(0);
|
||||
const track = useCallback((e: PointerEvent) => {
|
||||
pointer.current = e.clientX;
|
||||
if (state.current != null) state.current.x = e.clientX;
|
||||
}, []);
|
||||
const unlisten = useRef<(() => void) | null>(null);
|
||||
const onPointerDownCapture = useCallback(
|
||||
(e: ReactPointerEvent) => {
|
||||
unlisten.current?.();
|
||||
pointer.current = e.clientX;
|
||||
const up = (e: PointerEvent) => {
|
||||
unlisten.current?.();
|
||||
track(e);
|
||||
drop();
|
||||
};
|
||||
const lost = () => {
|
||||
unlisten.current?.();
|
||||
cancel();
|
||||
};
|
||||
window.addEventListener("pointermove", track, true);
|
||||
window.addEventListener("pointerup", up, true);
|
||||
window.addEventListener("pointercancel", lost, true);
|
||||
unlisten.current = () => {
|
||||
unlisten.current = null;
|
||||
window.removeEventListener("pointermove", track, true);
|
||||
window.removeEventListener("pointerup", up, true);
|
||||
window.removeEventListener("pointercancel", lost, true);
|
||||
};
|
||||
},
|
||||
[track, drop, cancel],
|
||||
);
|
||||
|
||||
// A drag never outlives the header
|
||||
useEffect(
|
||||
() => () => {
|
||||
unlisten.current?.();
|
||||
cancel();
|
||||
},
|
||||
[cancel],
|
||||
);
|
||||
|
||||
/** Sets where the drag would drop, telling the list when that changes */
|
||||
function target(s: NonNullable<typeof state.current>) {
|
||||
const gridLeft = gridRef.current?.getBoundingClientRect().left ?? 0;
|
||||
const cells = s.cells.map((c) => ({ left: gridLeft + c.left, right: gridLeft + c.right }));
|
||||
const to = dropBoundary(s.x, cells, s.from);
|
||||
if (to === s.to) return;
|
||||
s.to = to;
|
||||
latest.current.onColumnDrag({ type: "target", to });
|
||||
}
|
||||
|
||||
return {
|
||||
sensors,
|
||||
gridRef,
|
||||
onPointerDownCapture,
|
||||
dndHandlers: { onDragStart, onDragEnd: drop, onDragCancel: cancel },
|
||||
};
|
||||
}
|
||||
|
||||
/** Which columns show, in order, to tell when that changes */
|
||||
function shownKey(columns: Column[]): string {
|
||||
return columns.map((c) => c.id).join(" ");
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { Flow } from "@yaakapp-internal/proxy-lib";
|
||||
import {
|
||||
type EventStreamColumns,
|
||||
EventStreamViewer,
|
||||
HStack,
|
||||
type StreamEvent,
|
||||
type StreamPayloadView,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useFlowMessages, useMessageSource } from "../hooks/useFlowMessages";
|
||||
import { base64ToBytes } from "../lib/body";
|
||||
import { grpcStatusText, messageToStreamEvent, streamIsOpen } from "../lib/messages";
|
||||
|
||||
const WEBSOCKET_COLUMNS: EventStreamColumns = {
|
||||
direction: true,
|
||||
kind: "Type",
|
||||
preview: "Message",
|
||||
size: true,
|
||||
time: true,
|
||||
};
|
||||
const SSE_COLUMNS: EventStreamColumns = {
|
||||
kind: "Event",
|
||||
kindWidth: "9.5rem",
|
||||
preview: "Data",
|
||||
size: true,
|
||||
time: true,
|
||||
};
|
||||
const GRPC_COLUMNS: EventStreamColumns = {
|
||||
direction: true,
|
||||
kind: "Type",
|
||||
preview: "Message",
|
||||
size: true,
|
||||
time: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* A streaming flow's messages: a WebSocket's frames, an event stream's events, a gRPC call's
|
||||
* messages and status, following new ones while the stream is open. The selected message's
|
||||
* payload opens below as text or a hexdump; a gRPC message's also decoded, with its schema or
|
||||
* without one.
|
||||
*/
|
||||
export function FlowMessages({ flow }: { flow: Flow }) {
|
||||
const source = useMessageSource();
|
||||
const { messages, loading, error } = useFlowMessages(flow.id, source, flow.messageCount);
|
||||
const events = useMemo(() => messages.map(messageToStreamEvent), [messages]);
|
||||
const kind = flow.streamKind;
|
||||
const grpc = kind === "grpc" || kind === "grpc_web";
|
||||
const columns = kind === "sse" ? SSE_COLUMNS : grpc ? GRPC_COLUMNS : WEBSOCKET_COLUMNS;
|
||||
const noun = kind === "sse" ? "Event" : "Message";
|
||||
const catchUp = useMemo(
|
||||
() => ({
|
||||
label: (count: number) => `${count} new ${noun.toLowerCase()}${count === 1 ? "" : "s"}`,
|
||||
}),
|
||||
[noun],
|
||||
);
|
||||
|
||||
const loadPayload = useCallback(
|
||||
async (event: StreamEvent) => {
|
||||
const res = await source.payload(flow.id, Number(event.key));
|
||||
return { bytes: base64ToBytes(res.data) };
|
||||
},
|
||||
[flow.id, source],
|
||||
);
|
||||
|
||||
const payloadViews = useCallback(
|
||||
(event: StreamEvent): StreamPayloadView[] | null => {
|
||||
if (!grpc || event.direction === "info") return null;
|
||||
const decode = (schemaless: boolean) => async () => {
|
||||
const decoded = await source.decodeGrpc(flow.id, Number(event.key), schemaless);
|
||||
const note = [
|
||||
decoded.messageType == null ? null : `Decoded as ${decoded.messageType}`,
|
||||
decoded.note,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(". ");
|
||||
return { text: decoded.json, language: "json" as const, note: note || null };
|
||||
};
|
||||
return [
|
||||
{ value: "decoded", label: "Decoded", type: "text", load: decode(false) },
|
||||
{ value: "schemaless", label: "Schemaless", type: "text", load: decode(true) },
|
||||
{ value: "raw", label: "Raw bytes", type: "hexdump" },
|
||||
];
|
||||
},
|
||||
[flow.id, grpc, source],
|
||||
);
|
||||
|
||||
const open = streamIsOpen(flow);
|
||||
const count = Math.max(flow.messageCount, messages.length);
|
||||
const header = (
|
||||
<HStack space={2} className="pl-2 mb-1 h-xs font-mono text-xs text-text-subtle">
|
||||
{kind === "websocket" && (
|
||||
<>
|
||||
{open ? (
|
||||
<span className="text-success">CONNECTED</span>
|
||||
) : flow.error != null ? (
|
||||
<span className="text-danger">ERROR</span>
|
||||
) : (
|
||||
<span className="text-text-subtle">CLOSED</span>
|
||||
)}
|
||||
<span>•</span>
|
||||
</>
|
||||
)}
|
||||
<span>
|
||||
{count} {count === 1 ? noun : `${noun}s`}
|
||||
</span>
|
||||
{grpc && flow.grpcStatus != null && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className={flow.grpcStatus === 0 ? "text-success" : "text-danger"}>
|
||||
{grpcStatusText(flow.grpcStatus, flow.grpcMessage)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</HStack>
|
||||
);
|
||||
|
||||
return (
|
||||
<EventStreamViewer
|
||||
events={events}
|
||||
header={header}
|
||||
error={error ?? (open ? null : flow.error)}
|
||||
isLoading={loading && events.length === 0}
|
||||
emptyMessage={
|
||||
open ? `Waiting for the first ${noun.toLowerCase()}` : `No ${noun.toLowerCase()}s`
|
||||
}
|
||||
splitLayoutStorageKey="proxy_flow_messages"
|
||||
columns={columns}
|
||||
loadPayload={loadPayload}
|
||||
payloadViews={payloadViews}
|
||||
catchUp={catchUp}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, test, vi } from "vite-plus/test";
|
||||
import type { FlowBodyData } from "../lib/flowViews";
|
||||
import { FlowRequestBody } from "./FlowRequestBody";
|
||||
|
||||
// The package's index reaches for the window as it loads; these four are all this renders.
|
||||
vi.mock("@yaakapp-internal/ui", () => ({
|
||||
Banner: ({ children, color }: { children: ReactNode; color?: string }) => (
|
||||
<div data-banner={color}>{children}</div>
|
||||
),
|
||||
EmptyStateText: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
LoadingIcon: () => <span>LOADING</span>,
|
||||
IconTooltip: ({ content }: { content: string }) => <span data-tooltip={content} />,
|
||||
}));
|
||||
|
||||
const data = (truncated = false): FlowBodyData => ({
|
||||
bytes: new TextEncoder().encode('{"a":1}'),
|
||||
truncated,
|
||||
storedSize: 7,
|
||||
});
|
||||
|
||||
function render(body: Parameters<typeof FlowRequestBody>[0]["body"]) {
|
||||
return renderToStaticMarkup(
|
||||
<FlowRequestBody body={body} viewer={<div>EDITOR</div>} requestSize={2048} />,
|
||||
);
|
||||
}
|
||||
|
||||
describe("FlowRequestBody", () => {
|
||||
test("a body that failed to load says why, not that there is none", () => {
|
||||
const html = render({ data: undefined, isLoading: false, error: new Error("Key unavailable") });
|
||||
expect(html).toContain('data-banner="danger"');
|
||||
expect(html).toContain("Key unavailable");
|
||||
expect(html).not.toContain("EDITOR");
|
||||
});
|
||||
|
||||
test("shows it is loading while the body is read", () => {
|
||||
const html = render({ data: undefined, isLoading: true, error: null });
|
||||
expect(html).not.toContain("EDITOR");
|
||||
expect(html).toContain("LOADING");
|
||||
});
|
||||
|
||||
test("the viewer once loaded", () => {
|
||||
expect(render({ data: data(), isLoading: false, error: null })).toContain("EDITOR");
|
||||
});
|
||||
|
||||
test("a truncated body says how much is shown, and why behind its info icon", () => {
|
||||
const html = render({ data: data(true), isLoading: false, error: null });
|
||||
expect(html).toContain("EDITOR");
|
||||
expect(html).toContain('data-banner="notice"');
|
||||
expect(html.replaceAll("<!-- -->", "")).toContain("The first 7 B of 2 KB is shown.");
|
||||
expect(html).toContain(
|
||||
'data-tooltip="The body was truncated at the capture limit. The app sent all of it."',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { formatSize } from "@yaakapp-internal/lib/formatSize";
|
||||
import { Banner, EmptyStateText, IconTooltip, LoadingIcon } from "@yaakapp-internal/ui";
|
||||
import type { HttpBodyResult } from "@yaakapp-internal/ui/httpTypes";
|
||||
import type { ReactNode } from "react";
|
||||
import type { FlowBodyData } from "../lib/flowViews";
|
||||
|
||||
interface Props {
|
||||
/** The request body as the engine's body RPC returned it */
|
||||
body: HttpBodyResult<FlowBodyData | null>;
|
||||
/** The body in the viewer for its type, formatted or raw */
|
||||
viewer: ReactNode;
|
||||
/** Bytes of the request body on the wire */
|
||||
requestSize: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The request half's Body tab: the body in its viewer, or why it isn't there yet. A body that
|
||||
* failed to load says why, as the response's viewer does, rather than reading as no body.
|
||||
*/
|
||||
export function FlowRequestBody({ body, viewer, requestSize }: Props) {
|
||||
if (body.error != null) {
|
||||
return (
|
||||
<Banner color="danger" className="text-sm">
|
||||
{body.error.message}
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
if (body.isLoading) {
|
||||
return (
|
||||
<EmptyStateText>
|
||||
<LoadingIcon />
|
||||
</EmptyStateText>
|
||||
);
|
||||
}
|
||||
if (body.data?.truncated) {
|
||||
return (
|
||||
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
|
||||
<Banner color="notice" className="text-sm mb-2">
|
||||
<span className="flex items-center gap-1.5">
|
||||
The first {formatSize(body.data.storedSize)} of {formatSize(requestSize)} is shown.
|
||||
<IconTooltip
|
||||
iconSize="sm"
|
||||
content="The body was truncated at the capture limit. The app sent all of it."
|
||||
/>
|
||||
</span>
|
||||
</Banner>
|
||||
<div className="min-h-0">{viewer}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return viewer;
|
||||
}
|
||||
@@ -48,8 +48,8 @@ export function HelperDialog() {
|
||||
open={open}
|
||||
onClose={close}
|
||||
size="dynamic"
|
||||
title="Turn on Yaak Proxy under Allow in the Background"
|
||||
description="In System Settings > General > Login Items. Newer macOS calls the list Background App Activity."
|
||||
title="Approve Helper"
|
||||
description="Turn on Yaak Proxy in System Settings > General > Login Items."
|
||||
className="w-[34rem]"
|
||||
>
|
||||
<div className="grid gap-3 pb-4">
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import {
|
||||
Button,
|
||||
CONTROL_TOOLTIP_DELAY,
|
||||
Dropdown,
|
||||
type DropdownItem,
|
||||
Icon,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useLocalStorage } from "react-use";
|
||||
import { canTrustHere, useDecrypt, useTrustCertificate, useTrusting } from "../hooks/useDecrypt";
|
||||
import { useDecryptList } from "../hooks/useDecryptList";
|
||||
import { useRpcQuery } from "../hooks/useRpcQuery";
|
||||
import { everythingEdit, listEdit, setDecrypted } from "../lib/decrypt";
|
||||
import { chooseOptions, connectionCount, stripState } from "../lib/httpsStrip";
|
||||
import { sidebarFlowsAtom } from "../lib/store";
|
||||
import { AppIcon } from "./AppIcon";
|
||||
import { SETTINGS_TAB_PROXY, useOpenSettings } from "./Settings";
|
||||
|
||||
const STRIP =
|
||||
"flex items-center gap-2.5 h-[2.2rem] mx-2 mb-1 pl-3 pr-1.5 rounded-md bg-surface-highlight/45 border border-border-subtle text-sm text-text-subtle min-w-0";
|
||||
|
||||
/**
|
||||
* Above the list while nothing is decrypted: says once that HTTPS is encrypted, and offers
|
||||
* Decrypt Everything, or Choose for apps and sites one at a time. Goes once anything is on
|
||||
* the list, or when dismissed; comes back, with Trust, while the list decrypts something the
|
||||
* certificate isn't trusted for.
|
||||
*/
|
||||
export function HttpsStrip() {
|
||||
const list = useDecryptList();
|
||||
const flows = useAtomValue(sidebarFlowsAtom);
|
||||
const caStatus = useRpcQuery("ca_status", {});
|
||||
const [dismissed, setDismissed] = useLocalStorage("https_strip_dismissed", false);
|
||||
// Kept while Choose is open, so checking a row there leaves the menu where it is.
|
||||
const [choosing, setChoosing] = useState(false);
|
||||
const { apply } = useDecrypt();
|
||||
const trusting = useTrusting();
|
||||
const trust = useTrustCertificate();
|
||||
const openSettings = useOpenSettings();
|
||||
const encrypted = useMemo(() => flows.filter((f) => f.tunnelled).length, [flows]);
|
||||
const state = stripState({
|
||||
list,
|
||||
encrypted,
|
||||
dismissed: dismissed ?? false,
|
||||
trusted: caStatus.data?.trusted ?? null,
|
||||
canTrust: canTrustHere(),
|
||||
});
|
||||
const options = useMemo(() => chooseOptions(flows, list), [flows, list]);
|
||||
|
||||
if (state === "hidden" && !choosing) return null;
|
||||
|
||||
if (state === "broken") {
|
||||
return (
|
||||
<div className={STRIP}>
|
||||
<Icon icon="shield_off" size="sm" color="warning" className="shrink-0" />
|
||||
<span className="text-text shrink-0">Certificate not trusted</span>
|
||||
<span className="truncate">The certificate must be trusted to decrypt traffic.</span>
|
||||
<Button
|
||||
size="2xs"
|
||||
color="primary"
|
||||
className="ml-auto shrink-0"
|
||||
isLoading={trusting}
|
||||
onClick={() => trust.mutate({})}
|
||||
>
|
||||
{trusting ? "Waiting for macOS" : "Trust"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const items: DropdownItem[] = [
|
||||
{ type: "separator", label: "Apps", hidden: options.apps.length === 0 },
|
||||
...options.apps.map((app): DropdownItem => ({
|
||||
label:
|
||||
app.path == null ? (
|
||||
app.name
|
||||
) : (
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<AppIcon path={app.path} className="size-4 min-w-4" />
|
||||
<span className="truncate">{app.name}</span>
|
||||
</span>
|
||||
),
|
||||
keepOpenOnSelect: true,
|
||||
leftSlot: <Check on={app.checked} />,
|
||||
rightSlot: <Count n={app.count} />,
|
||||
onSelect: async () => {
|
||||
const subject = { kind: "app", name: app.name, bundleId: app.bundleId } as const;
|
||||
await apply({
|
||||
edit: listEdit(list, setDecrypted(list, [subject], !app.checked)),
|
||||
message: `${app.checked ? "Stopped decrypting" : "Decrypting"} ${app.name}`,
|
||||
});
|
||||
},
|
||||
})),
|
||||
{ type: "separator", label: "Domains", hidden: options.sites.length === 0 },
|
||||
...options.sites.map((site): DropdownItem => ({
|
||||
label: <span className="font-mono text-xs">*.{site.site}</span>,
|
||||
keepOpenOnSelect: true,
|
||||
leftSlot: <Check on={site.checked} />,
|
||||
rightSlot: <Count n={site.count} />,
|
||||
onSelect: async () => {
|
||||
const subject = { kind: "site", site: site.site } as const;
|
||||
await apply({
|
||||
edit: listEdit(list, setDecrypted(list, [subject], !site.checked)),
|
||||
message: `${site.checked ? "Stopped decrypting" : "Decrypting"} *.${site.site}`,
|
||||
});
|
||||
},
|
||||
})),
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Decryption Settings",
|
||||
leftSlot: <Icon icon="settings" />,
|
||||
onSelect: () => openSettings(SETTINGS_TAB_PROXY),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div className={STRIP}>
|
||||
<Icon icon="lock" size="sm" className="shrink-0 text-text-subtle" />
|
||||
<span className="text-text shrink-0">HTTPS is encrypted</span>
|
||||
<span className="font-mono text-xs text-text-subtlest truncate">
|
||||
{connectionCount(encrypted)}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-1.5 shrink-0">
|
||||
<Tooltip
|
||||
wrap
|
||||
content="New connections will be decrypted, except for excluded hosts and apps."
|
||||
delay={CONTROL_TOOLTIP_DELAY}
|
||||
>
|
||||
<Button
|
||||
size="2xs"
|
||||
variant={trusting ? "border" : "solid"}
|
||||
color={trusting ? "default" : "primary"}
|
||||
isLoading={trusting}
|
||||
onClick={() =>
|
||||
void apply({
|
||||
edit: everythingEdit(true),
|
||||
message: "Decrypting everything",
|
||||
sub: "Excluded hosts and apps stay encrypted.",
|
||||
})
|
||||
}
|
||||
>
|
||||
{trusting ? "Waiting for macOS" : "Decrypt Everything"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Dropdown items={items} onOpen={() => setChoosing(true)} onClose={() => setChoosing(false)}>
|
||||
<Button size="2xs" variant="border">
|
||||
Choose
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<IconButton
|
||||
size="xs"
|
||||
icon="x"
|
||||
iconColor="secondary"
|
||||
title="Dismiss"
|
||||
tooltip
|
||||
onClick={() => setDismissed(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Check({ on }: { on: boolean }) {
|
||||
return on ? (
|
||||
<Icon icon="check" size="sm" />
|
||||
) : (
|
||||
<span aria-hidden className="inline-block w-4 h-4" />
|
||||
);
|
||||
}
|
||||
|
||||
function Count({ n }: { n: number }) {
|
||||
return <span className="pl-4 font-mono text-xs text-text-subtlest">{n.toLocaleString()}</span>;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { ProxyLock } from "@yaakapp-internal/proxy-lib";
|
||||
import { Button, Icon, LoadingIcon } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useRpcMutation } from "../hooks/useRpcMutation";
|
||||
import { encryptingPercent } from "../lib/encrypting";
|
||||
import { lockedLine } from "../lib/locked";
|
||||
import { proxyStateAtom } from "../lib/store";
|
||||
import { getOsType } from "../lib/tauri";
|
||||
|
||||
/**
|
||||
* Over the list and the detail pane while captured traffic can't be read: its key is
|
||||
* unavailable, or traffic from before encryption is being encrypted. Retry reads the key again
|
||||
* and opens it.
|
||||
*/
|
||||
export function LockedState({ lock }: { lock: ProxyLock }) {
|
||||
const proxyState = useAtomValue(proxyStateAtom);
|
||||
const retry = useRpcMutation("take_over");
|
||||
const encrypting = encryptingPercent(proxyState);
|
||||
|
||||
return (
|
||||
<div className="grid place-items-center h-full px-10 pb-16">
|
||||
<div className="flex flex-col items-center text-center gap-2 max-w-[28rem]">
|
||||
{encrypting != null ? (
|
||||
<LoadingIcon size="lg" className="text-text-subtle mb-1" />
|
||||
) : (
|
||||
<Icon icon="lock" size="lg" className="text-text-subtle mb-1" />
|
||||
)}
|
||||
<div className="text-lg font-semibold whitespace-nowrap">
|
||||
{encrypting != null ? (
|
||||
<>
|
||||
Encrypting recorded traffic,{" "}
|
||||
{/* Sized for "100%" and right-aligned, so the centred line never changes width as
|
||||
the number ticks: a changing width re-rasterizes every glyph at a new
|
||||
sub-pixel offset, and the line shakes */}
|
||||
<span className="inline-block w-[4.5ch] text-right tabular-nums">{encrypting}</span>
|
||||
</>
|
||||
) : (
|
||||
"Traffic is locked"
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-text-subtle">
|
||||
{encrypting != null
|
||||
? "Earlier traffic shows here once it's encrypted."
|
||||
: lockedLine(lock, getOsType())}
|
||||
</div>
|
||||
{encrypting == null && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="border"
|
||||
className="mt-2"
|
||||
isLoading={retry.isPending}
|
||||
onClick={() => retry.mutate({})}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
{encrypting == null && retry.error != null && (
|
||||
<div className="text-xs text-danger">{retry.error.message}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
type EditorHost,
|
||||
EditorHostProvider,
|
||||
MultipartViewer,
|
||||
type MultipartViewerProps,
|
||||
TextViewer,
|
||||
type TextViewerProps,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import { formatText as engineFormatText } from "../lib/body";
|
||||
|
||||
/**
|
||||
* Gives the shared panes the proxy's formatting. Everything else keeps the shared defaults:
|
||||
* plain editors with no templating, state kept for the session, no dialogs.
|
||||
*/
|
||||
export function ProxyEditorHost({
|
||||
children,
|
||||
formatText = engineFormatText,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
/** Pretty-prints a body; the engine's formatter unless replaced */
|
||||
formatText?: TextViewerProps["formatText"];
|
||||
}) {
|
||||
// Components made once per formatter, so viewers aren't remounted on every render
|
||||
const host = useMemo<Partial<EditorHost>>(
|
||||
() => ({
|
||||
TextViewer: (props: TextViewerProps) => <TextViewer formatText={formatText} {...props} />,
|
||||
MultipartViewer: (props: MultipartViewerProps) => (
|
||||
<MultipartViewer textViewerProps={{ formatText }} {...props} />
|
||||
),
|
||||
}),
|
||||
[formatText],
|
||||
);
|
||||
return <EditorHostProvider value={host}>{children}</EditorHostProvider>;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
HeaderSize,
|
||||
HeaderTint,
|
||||
IconButton,
|
||||
SidebarLayout,
|
||||
SplitLayout,
|
||||
@@ -11,22 +12,34 @@ import { useAtom, useAtomValue } from "jotai";
|
||||
import { useState } from "react";
|
||||
import { useLocalStorage } from "react-use";
|
||||
import { flowLayoutAtom } from "../lib/atoms";
|
||||
import { searchWindowAtom, showFirstRunAtom, shownTotalAtom } from "../lib/store";
|
||||
import { setupIncomplete } from "../lib/firstRun";
|
||||
import { useMenuExportHar } from "../lib/flowActions";
|
||||
import { useNavigationControls } from "../lib/navigation";
|
||||
import { proxyStatusOf, statusTintColor } from "../lib/proxyStatus";
|
||||
import { lockedAtom, noFlowsAtom, proxyStateAtom, systemProxyEverOnAtom } from "../lib/store";
|
||||
import { getOsType } from "../lib/tauri";
|
||||
import { AddColumnDialog } from "./AddColumnDialog";
|
||||
import { ClearFlowsButton } from "./ClearFlowsButton";
|
||||
import { FirstRun } from "./FirstRun";
|
||||
import { FlowDetail } from "./FlowDetail";
|
||||
import { FlowList } from "./FlowList";
|
||||
import { ProxyStatus } from "./ProxyStatus";
|
||||
import { LockedState } from "./LockedState";
|
||||
import { ProxyStatus, useProxySetup } from "./ProxyStatus";
|
||||
import { HelperDialog } from "./HelperDialog";
|
||||
import { SaveDialog } from "./SaveDialog";
|
||||
import { selectedSidebarLabelAtom, Sidebar } from "./Sidebar";
|
||||
import { SavedSearchDialog } from "./SavedSearchDialog";
|
||||
import { SearchBar } from "./SearchBar";
|
||||
import { SettingsDialogFallback, useOpenSettings } from "./Settings";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { HttpsStrip } from "./HttpsStrip";
|
||||
import { TakeOverBanner } from "./TakeOverBanner";
|
||||
|
||||
export function ProxyLayout() {
|
||||
const os = getOsType();
|
||||
const title = useAtomValue(selectedSidebarLabelAtom);
|
||||
const [flowLayout, setFlowLayout] = useAtom(flowLayoutAtom);
|
||||
const openSettings = useOpenSettings();
|
||||
useMenuExportHar();
|
||||
useNavigationControls();
|
||||
const [sidebarWidth, setSidebarWidth] = useLocalStorage("sidebar_width", 250);
|
||||
const [sidebarHidden, setSidebarHidden] = useLocalStorage("sidebar_hidden", false);
|
||||
const [floatingSidebarHidden, setFloatingSidebarHidden] = useLocalStorage(
|
||||
@@ -35,6 +48,7 @@ export function ProxyLayout() {
|
||||
);
|
||||
const [floating, setFloating] = useState(false);
|
||||
const isHidden = floating ? (floatingSidebarHidden ?? true) : (sidebarHidden ?? false);
|
||||
const tint = statusTintColor(proxyStatusOf(useAtomValue(proxyStateAtom)));
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -52,11 +66,21 @@ export function ProxyLayout() {
|
||||
interfaceScale={1}
|
||||
className="x-theme-appHeader bg-surface"
|
||||
>
|
||||
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center w-full h-full pointer-events-none">
|
||||
<div className="flex items-center pl-1 pointer-events-auto">
|
||||
<HeaderTint color={tint} animate />
|
||||
{/* Yaak's silhouette: the sidebar toggle and the state pill where Yaak has the toggle and
|
||||
the environment, the search where it names the request, then its icons. Only the
|
||||
controls take the pointer: everything around them, the traffic lights' corner
|
||||
included, falls through to the header, which drags the window. */}
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="grid grid-cols-[minmax(auto,1fr)_minmax(0,44rem)_minmax(auto,1fr)] gap-2 items-center w-full h-full pointer-events-none"
|
||||
>
|
||||
<div data-tauri-drag-region className="flex items-center gap-1 pl-1 whitespace-nowrap">
|
||||
<IconButton
|
||||
className="pointer-events-auto"
|
||||
size="sm"
|
||||
title="Toggle sidebar"
|
||||
tooltip
|
||||
icon={isHidden ? "left_panel_hidden" : "left_panel_visible"}
|
||||
iconColor="secondary"
|
||||
onClick={() => {
|
||||
@@ -67,27 +91,35 @@ export function ProxyLayout() {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="pointer-events-none flex items-center justify-center gap-2 text-sm px-2 min-w-0"
|
||||
>
|
||||
<span className="truncate">{title}</span>
|
||||
<FlowCount />
|
||||
</div>
|
||||
<div className="flex items-center gap-1 pr-1">
|
||||
<ProxyStatus />
|
||||
</div>
|
||||
<div data-tauri-drag-region className="min-w-0">
|
||||
<div className="pointer-events-auto">
|
||||
<SearchBar />
|
||||
</div>
|
||||
</div>
|
||||
<div data-tauri-drag-region className="flex items-center justify-end gap-1 pr-1">
|
||||
<ClearFlowsButton />
|
||||
<IconButton
|
||||
className="pointer-events-auto"
|
||||
icon={flowLayout === "horizontal" ? "columns_2" : "rows_2"}
|
||||
title={`Change to ${flowLayout === "horizontal" ? "vertical" : "horizontal"} layout`}
|
||||
tooltip
|
||||
size="sm"
|
||||
iconColor="secondary"
|
||||
onClick={() =>
|
||||
setFlowLayout((prev) => (prev === "horizontal" ? "vertical" : "horizontal"))
|
||||
}
|
||||
/>
|
||||
<IconButton
|
||||
className="pointer-events-auto"
|
||||
icon="settings"
|
||||
title="Settings"
|
||||
tooltip
|
||||
size="sm"
|
||||
iconColor="secondary"
|
||||
onClick={() => openSettings()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</HeaderSize>
|
||||
@@ -120,6 +152,7 @@ export function ProxyLayout() {
|
||||
<IconButton
|
||||
size="sm"
|
||||
title="Toggle sidebar"
|
||||
tooltip
|
||||
icon="left_panel_visible"
|
||||
iconColor="secondary"
|
||||
onClick={() => setFloatingSidebarHidden(true)}
|
||||
@@ -134,41 +167,47 @@ export function ProxyLayout() {
|
||||
>
|
||||
<div className="h-full flex flex-col min-h-0">
|
||||
<TakeOverBanner />
|
||||
<HttpsStrip />
|
||||
<div className="flex-1 min-h-0 grid grid-rows-[minmax(0,1fr)]">
|
||||
<FlowsOrFirstRun flowLayout={flowLayout} />
|
||||
</div>
|
||||
</div>
|
||||
</SidebarLayout>
|
||||
<SaveDialog />
|
||||
<SavedSearchDialog />
|
||||
<AddColumnDialog />
|
||||
<HelperDialog />
|
||||
<SettingsDialogFallback />
|
||||
<Toasts />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Its own component, so new traffic re-renders the count rather than the whole layout */
|
||||
function FlowCount() {
|
||||
const count = useAtomValue(shownTotalAtom);
|
||||
const searching = useAtomValue(searchWindowAtom) != null;
|
||||
const noun = searching ? (count === 1 ? "match" : "matches") : count === 1 ? "flow" : "flows";
|
||||
return <span className="text-text-subtlest shrink-0">{`${count} ${noun}`}</span>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The flow list above the detail pane, or, while the database has no flows at all, the first-run
|
||||
* card across the whole area: it is the empty state until traffic replaces it.
|
||||
* The flow list beside or above the detail pane, or, while setup is incomplete and there are no
|
||||
* flows, the first-run card across the whole area. Once setup is done an empty list says so
|
||||
* itself, after a Clear too. While captured traffic is locked, the locked state takes the whole
|
||||
* area, or the first-run card does, saying why, while setup is incomplete too.
|
||||
*/
|
||||
function FlowsOrFirstRun({ flowLayout }: { flowLayout: SplitLayoutLayout }) {
|
||||
if (useAtomValue(showFirstRunAtom)) return <FirstRun />;
|
||||
const locked = useAtomValue(lockedAtom);
|
||||
const noFlows = useAtomValue(noFlowsAtom) || locked != null;
|
||||
const { systemProxy } = useProxySetup();
|
||||
const systemProxyEverOn = useAtomValue(systemProxyEverOnAtom);
|
||||
const incomplete = setupIncomplete({
|
||||
systemProxySupported: systemProxy.data?.supported ?? null,
|
||||
systemProxyEverOn,
|
||||
});
|
||||
if (noFlows && incomplete) return <FirstRun />;
|
||||
if (locked != null) return <LockedState lock={locked} />;
|
||||
return (
|
||||
<SplitLayout
|
||||
storageKey="proxy_detail"
|
||||
className="gap-1"
|
||||
// The card sits a wider gutter from the list side by side; the handle fills the gutter.
|
||||
gutter={flowLayout === "vertical" ? 8 : 20}
|
||||
layout={flowLayout}
|
||||
defaultRatio={0.55}
|
||||
firstSlot={({ style, orientation }) => (
|
||||
<FlowList sideBySide={orientation === "horizontal"} style={style} className="pt-1" />
|
||||
)}
|
||||
firstSlot={({ style }) => <FlowList style={style} className="pt-1" />}
|
||||
secondSlot={({ style, orientation }) => (
|
||||
<FlowDetail style={style} sideBySide={orientation === "horizontal"} />
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { CaStatus, SystemProxyStatus } from "@yaakapp-internal/proxy-lib";
|
||||
import type { SystemProxyStatus } from "@yaakapp-internal/proxy-lib";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
@@ -7,66 +7,78 @@ import {
|
||||
type DropdownItem,
|
||||
type DropdownRef,
|
||||
Icon,
|
||||
LoadingIcon,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useAtomValue, useSetAtom, useStore } from "jotai";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import type { HTMLAttributes, ReactNode, Ref } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { canTrustHere, useDecrypt, useTrustCertificate } from "../hooks/useDecrypt";
|
||||
import { useDecryptList } from "../hooks/useDecryptList";
|
||||
import { useRpcEvent } from "../hooks/useRpcEvent";
|
||||
import { useRpcMutation } from "../hooks/useRpcMutation";
|
||||
import { useRpcQuery } from "../hooks/useRpcQuery";
|
||||
import { helperDialogOpenAtom, helperRegisteredAtAtom } from "../lib/atoms";
|
||||
import { helperRegisteredAtAtom } from "../lib/atoms";
|
||||
import { decryptsAnything, decryptsEverything, everythingEdit, listSummary } from "../lib/decrypt";
|
||||
import { encryptingPercent } from "../lib/encrypting";
|
||||
import { environmentExports } from "../lib/env";
|
||||
import { proxyStateAtom, refreshProxyState } from "../lib/store";
|
||||
import {
|
||||
helperPollInterval,
|
||||
helperRow,
|
||||
recordingLabel,
|
||||
systemProxySwitch,
|
||||
} from "../lib/systemProxy";
|
||||
import { proxyStatusOf } from "../lib/proxyStatus";
|
||||
import { proxyStateAtom, systemProxyEverOnAtom } from "../lib/store";
|
||||
import { helperPollInterval, recordingLabel, systemProxySwitch } from "../lib/systemProxy";
|
||||
import { onWindowFocus } from "../lib/tauri";
|
||||
import { useShowToast } from "../lib/toast";
|
||||
|
||||
/**
|
||||
* The header's state pill and its menu: recording, the system proxy, the certificate and HTTP/2. The
|
||||
* engine runs for as long as the app does, so the pill shows whether flows are being stored. A
|
||||
* certificate that is not trusted steps out of the menu beside the pill until it is.
|
||||
* The header's state pill and its menu: the listening address, which copies itself, the state,
|
||||
* Pause or Resume Recording, Override system proxy, and Decrypt everything. The engine runs for
|
||||
* as long as the app does, so the pill shows whether flows are being stored. A certificate that
|
||||
* is not trusted while something is listed to decrypt steps out of the menu beside the pill
|
||||
* until it is. Everything set once and left alone lives in Settings.
|
||||
*/
|
||||
export function ProxyStatus() {
|
||||
const dropdownRef = useRef<DropdownRef>(null);
|
||||
const [copied, setCopied] = useCopied();
|
||||
const proxyState = useAtomValue(proxyStateAtom);
|
||||
const setup = useProxySetup();
|
||||
const execute = useRpcMutation("execute_action");
|
||||
const retry = useRpcMutation("take_over");
|
||||
const setAtLaunch = useSetSystemProxyAtLaunch();
|
||||
const setHttpVersion = useSetHttpVersion();
|
||||
const copyEnvironment = useCopyEnvironment();
|
||||
const setHelperDialogOpen = useSetAtom(helperDialogOpenAtom);
|
||||
const { caStatus, trust, systemProxy, setSystemProxy } = setup;
|
||||
const { caStatus, systemProxy, setSystemProxy } = setup;
|
||||
const systemSwitch = useSystemProxySwitch(setup);
|
||||
|
||||
const running = proxyState?.state === "running";
|
||||
const recording = proxyState?.recording !== false;
|
||||
const other = proxyState?.otherOwner ?? null;
|
||||
const port = proxyState?.port ?? other?.proxyPort ?? null;
|
||||
const untrusted = caStatus.data != null && !caStatus.data.trusted;
|
||||
const decryptList = useDecryptList();
|
||||
// Something is listed to decrypt but the certificate isn't trusted, so it can't be.
|
||||
// Only where the app can trust it itself: elsewhere the status always reads untrusted.
|
||||
const broken =
|
||||
canTrustHere() &&
|
||||
caStatus.data != null &&
|
||||
!caStatus.data.trusted &&
|
||||
decryptsAnything(decryptList);
|
||||
// The same status the header's tint takes its colour from
|
||||
const status = proxyStatusOf(proxyState);
|
||||
const encrypting = status === "encrypting" ? encryptingPercent(proxyState) : null;
|
||||
|
||||
const stateRow =
|
||||
other != null ? (
|
||||
status === "encrypting" ? (
|
||||
<MenuRow
|
||||
icon={<LoadingIcon size="sm" className="text-text-subtle" />}
|
||||
title={`Encrypting recorded traffic, ${encrypting}`}
|
||||
description="Recording will start once encryption finishes."
|
||||
/>
|
||||
) : status === "cli" && other != null ? (
|
||||
<MenuRow
|
||||
icon={<StateDot state="cli" />}
|
||||
title="Running from the CLI"
|
||||
description={`Started by yaak proxy start, pid ${other.pid}, since ${timeOf(other.startedAt)}.${other.proxyPort == null ? "" : ` Port ${other.proxyPort}.`}`}
|
||||
description={`The proxy was started by the CLI at ${timeOf(other.startedAt)}.`}
|
||||
/>
|
||||
) : running ? (
|
||||
) : status === "recording" || status === "paused" ? (
|
||||
<MenuRow
|
||||
icon={<StateDot state={recording ? "recording" : "paused"} />}
|
||||
icon={<StateDot state={status} />}
|
||||
title={recording ? `Recording on port ${port}` : `Paused on port ${port}`}
|
||||
description={
|
||||
recording
|
||||
? "Listening on 127.0.0.1, and storing every flow."
|
||||
: "Traffic passes through untouched, and no flows are stored."
|
||||
}
|
||||
description={recording ? null : "Traffic passes through without being recorded."}
|
||||
/>
|
||||
) : (
|
||||
<MenuRow
|
||||
@@ -74,7 +86,7 @@ export function ProxyStatus() {
|
||||
title="Not running"
|
||||
description={
|
||||
<span className="text-danger">
|
||||
{retry.error?.message ?? proxyState?.startError ?? "The proxy has not started."}
|
||||
{retry.error?.message ?? proxyState?.startError ?? "The proxy failed to start."}
|
||||
</span>
|
||||
}
|
||||
action={
|
||||
@@ -93,9 +105,40 @@ export function ProxyStatus() {
|
||||
/>
|
||||
);
|
||||
|
||||
// Until the helper is approved, every change asks for the password; said where it is felt.
|
||||
const helperNotice =
|
||||
systemProxy.data != null &&
|
||||
systemProxy.data.backend !== "helper" &&
|
||||
systemProxy.data.helper !== "enabled" &&
|
||||
systemProxy.data.helper !== "unavailable";
|
||||
|
||||
const address = port == null ? null : `127.0.0.1:${port}`;
|
||||
const items: DropdownItem[] = [
|
||||
{
|
||||
label: <span className="font-mono text-editor">{address}</span>,
|
||||
hidden: address == null,
|
||||
keepOpenOnSelect: true,
|
||||
rightSlot: copied ? (
|
||||
<span className="text-xs text-text-subtle">Copied</span>
|
||||
) : (
|
||||
<Icon icon="copy" size="sm" className="text-text-subtlest" />
|
||||
),
|
||||
onSelect: () => {
|
||||
if (address == null) return;
|
||||
navigator.clipboard.writeText(address).then(setCopied, console.error);
|
||||
},
|
||||
},
|
||||
{ type: "separator", hidden: address == null },
|
||||
{ type: "content", label: stateRow },
|
||||
{ type: "separator", hidden: systemProxy.data?.supported !== true },
|
||||
{
|
||||
label: recordingLabel(recording),
|
||||
icon: recording ? "circle_pause" : "circle_play",
|
||||
hidden: !running,
|
||||
hotKeyAction: "recording_toggle",
|
||||
hotKeyLabelOnly: true,
|
||||
onSelect: () => execute.mutate({ scope: "global", action: "recording_toggle" }),
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
type: "content",
|
||||
hidden: systemProxy.data?.supported !== true,
|
||||
@@ -104,9 +147,14 @@ export function ProxyStatus() {
|
||||
icon={<Icon icon="wifi" size="sm" color="secondary" />}
|
||||
title="Override system proxy"
|
||||
description={
|
||||
<span className={classNames(systemSwitch.failed && "text-danger")}>
|
||||
{systemSwitch.description}
|
||||
</span>
|
||||
<>
|
||||
<span className={classNames(systemSwitch.failed && "text-danger")}>
|
||||
{systemSwitch.description}
|
||||
</span>
|
||||
{helperNotice && (
|
||||
<span className="block text-notice">macOS will ask for a password each time.</span>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
action={
|
||||
<KeepMenuOpen>
|
||||
@@ -123,136 +171,28 @@ export function ProxyStatus() {
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: "content",
|
||||
hidden: systemProxy.data?.supported !== true,
|
||||
label: (
|
||||
<KeepMenuOpen className="block pl-[2.125rem] pr-1.5 pb-1.5">
|
||||
<Checkbox
|
||||
title={
|
||||
<span className="text-xs text-text-subtle">Override system proxy at launch</span>
|
||||
}
|
||||
checked={proxyState?.systemProxyAtLaunch ?? true}
|
||||
disabled={setAtLaunch.isPending}
|
||||
onChange={(enabled) => setAtLaunch.mutate({ enabled })}
|
||||
/>
|
||||
</KeepMenuOpen>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: "content",
|
||||
hidden: systemProxy.data?.supported !== true,
|
||||
label: systemProxy.data != null && (
|
||||
<HelperMenuRow
|
||||
status={systemProxy.data}
|
||||
onOpenDialog={() => {
|
||||
dropdownRef.current?.close?.();
|
||||
setHelperDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: "content",
|
||||
label: caStatus.data?.trusted ? (
|
||||
<MenuRow
|
||||
icon={<Icon icon="shield_check" color="success" size="sm" />}
|
||||
title="Certificate trusted"
|
||||
description="Yaak Proxy CA, in your login keychain."
|
||||
/>
|
||||
) : (
|
||||
<MenuRow
|
||||
icon={<Icon icon="shield_off" color="warning" size="sm" />}
|
||||
title="Certificate not trusted"
|
||||
description={
|
||||
trust.error != null ? (
|
||||
<span className="text-danger">{trust.error.message}</span>
|
||||
) : trust.isPending ? (
|
||||
"Waiting for your password in the macOS prompt."
|
||||
) : (
|
||||
"So HTTPS reads as plain text. macOS asks for your login password once."
|
||||
)
|
||||
}
|
||||
action={
|
||||
<Button
|
||||
size="2xs"
|
||||
variant="border"
|
||||
color="primary"
|
||||
isLoading={trust.isPending}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
trust.mutate({});
|
||||
}}
|
||||
>
|
||||
{trust.error != null ? "Try again" : "Trust certificate"}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: "content",
|
||||
label: (
|
||||
<MenuRow
|
||||
icon={<Icon icon="zap" size="sm" color="secondary" />}
|
||||
title="HTTP/2"
|
||||
description="Lets browsers send many requests at once. Turn off for apps that misbehave on it. Applies to new connections."
|
||||
action={
|
||||
<KeepMenuOpen>
|
||||
<Checkbox
|
||||
title="HTTP/2"
|
||||
hideLabel
|
||||
checked={proxyState?.httpVersion !== "http1Only"}
|
||||
// The owner's engine would not see the change until it restarts.
|
||||
disabled={other != null || setHttpVersion.isPending}
|
||||
onChange={(enabled) =>
|
||||
setHttpVersion.mutate({ httpVersion: enabled ? "auto" : "http1Only" })
|
||||
}
|
||||
/>
|
||||
</KeepMenuOpen>
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Copy Environment for a Terminal",
|
||||
icon: "square_terminal",
|
||||
onSelect: copyEnvironment,
|
||||
},
|
||||
{
|
||||
label: recordingLabel(recording),
|
||||
icon: recording ? "circle_pause" : "circle_play",
|
||||
hidden: !running,
|
||||
hotKeyAction: "recording_toggle",
|
||||
hotKeyLabelOnly: true,
|
||||
onSelect: () => execute.mutate({ scope: "global", action: "recording_toggle" }),
|
||||
},
|
||||
{ type: "content", label: <DecryptEverythingRow broken={broken} /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{untrusted && (
|
||||
<Pill color="warning" onClick={() => dropdownRef.current?.open()}>
|
||||
<Icon icon="shield_off" size="xs" />
|
||||
Certificate not trusted
|
||||
</Pill>
|
||||
)}
|
||||
<Dropdown ref={dropdownRef} items={items}>
|
||||
<Pill color={!running && other == null && proxyState != null ? "warning" : undefined}>
|
||||
{other != null ? (
|
||||
<Pill color={status === "stopped" ? "warning" : undefined}>
|
||||
{status === "encrypting" ? (
|
||||
<>
|
||||
<LoadingIcon size="xs" />
|
||||
<span className="text-text">Encrypting</span>
|
||||
<span className="font-mono text-text-subtle">{encrypting}</span>
|
||||
</>
|
||||
) : status === "cli" && other != null ? (
|
||||
<>
|
||||
<StateDot state="cli" />
|
||||
<span className="text-text">CLI</span>
|
||||
{other.proxyPort != null && (
|
||||
<span className="font-mono text-text-subtle">:{other.proxyPort}</span>
|
||||
)}
|
||||
</>
|
||||
) : running ? (
|
||||
) : status === "recording" || status === "paused" ? (
|
||||
<>
|
||||
<StateDot state={recording ? "recording" : "paused"} />
|
||||
<StateDot state={status} />
|
||||
<span className="text-text">{recording ? "Recording" : "Paused"}</span>
|
||||
{port != null && <span className="font-mono text-text-subtle">:{port}</span>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -263,10 +203,83 @@ export function ProxyStatus() {
|
||||
<Icon icon="chevron_down" size="xs" className="text-text-subtlest" />
|
||||
</Pill>
|
||||
</Dropdown>
|
||||
{broken && (
|
||||
<Pill color="warning" onClick={() => dropdownRef.current?.open()}>
|
||||
<Icon icon="shield_off" size="xs" />
|
||||
Certificate not trusted
|
||||
</Pill>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt everything, `*` on the list, as a switch: what else is listed when off, and Trust
|
||||
* while something is listed and the certificate isn't trusted.
|
||||
*/
|
||||
function DecryptEverythingRow({ broken }: { broken: boolean }) {
|
||||
const list = useDecryptList();
|
||||
const { apply, trusting } = useDecrypt();
|
||||
const trust = useTrustCertificate();
|
||||
const on = decryptsEverything(list);
|
||||
if (broken) {
|
||||
return (
|
||||
<MenuRow
|
||||
icon={<Icon icon="shield_off" size="sm" color="warning" />}
|
||||
title="Decrypt everything"
|
||||
description={<span className="text-warning">Certificate not trusted</span>}
|
||||
action={
|
||||
<Button
|
||||
size="2xs"
|
||||
color="primary"
|
||||
isLoading={trusting}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
trust.mutate({});
|
||||
}}
|
||||
>
|
||||
{trusting ? "Waiting for macOS" : "Trust"}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<MenuRow
|
||||
icon={<Icon icon="lock_open" size="sm" color="secondary" />}
|
||||
title="Decrypt everything"
|
||||
description={
|
||||
trusting
|
||||
? "Waiting for macOS"
|
||||
: on
|
||||
? "Excluded hosts and apps stay encrypted."
|
||||
: (listSummary(list) ?? "No hosts or apps")
|
||||
}
|
||||
action={
|
||||
<KeepMenuOpen>
|
||||
<Checkbox
|
||||
title="Decrypt everything"
|
||||
hideLabel
|
||||
checked={on}
|
||||
disabled={trusting}
|
||||
onChange={(checked) =>
|
||||
void apply(
|
||||
checked
|
||||
? {
|
||||
edit: everythingEdit(true),
|
||||
message: "Decrypting everything",
|
||||
sub: "Excluded hosts and apps stay encrypted.",
|
||||
}
|
||||
: { edit: everythingEdit(false), message: "Stopped decrypting everything" },
|
||||
)
|
||||
}
|
||||
/>
|
||||
</KeepMenuOpen>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The certificate and the system proxy, read once and shared by the header and the first-run
|
||||
* card, with the actions that change them.
|
||||
@@ -287,10 +300,6 @@ export function useProxySetup() {
|
||||
// The user comes back from System Settings, maybe having just allowed the helper.
|
||||
const { refetch: refetchSystemProxy } = systemProxy;
|
||||
useEffect(() => onWindowFocus(() => void refetchSystemProxy()), [refetchSystemProxy]);
|
||||
const trust = useRpcMutation("ca_trust", {
|
||||
mutationKey: ["ca_trust"],
|
||||
onSuccess: (status) => queryClient.setQueryData<CaStatus>(["ca_status", {}], status),
|
||||
});
|
||||
const setSystemProxy = useRpcMutation("system_proxy_set", {
|
||||
mutationKey: ["system_proxy_set"],
|
||||
onSuccess: (status) => {
|
||||
@@ -300,7 +309,13 @@ export function useProxySetup() {
|
||||
// The engine turns the system proxy on as it starts, and says so once the prompt is
|
||||
// answered, and turns it off as it stops.
|
||||
useRpcEvent("proxy_state_changed", () => void systemProxy.refetch());
|
||||
return { caStatus, trust, systemProxy, setSystemProxy };
|
||||
// Setup counts as done once the system proxy has been on, whatever it is now.
|
||||
const [everOn, setEverOn] = useAtom(systemProxyEverOnAtom);
|
||||
const on = systemProxy.data?.enabled === true;
|
||||
useEffect(() => {
|
||||
if (on && !everOn) setEverOn(true);
|
||||
}, [on, everOn, setEverOn]);
|
||||
return { caStatus, systemProxy, setSystemProxy };
|
||||
}
|
||||
|
||||
/** The system proxy switch's state, from where it points and the change in flight */
|
||||
@@ -317,74 +332,6 @@ export function useSystemProxySwitch(setup: ReturnType<typeof useProxySetup>) {
|
||||
}
|
||||
|
||||
/** The privileged helper: whether it is installed and approved, and which way changes go */
|
||||
function HelperMenuRow({
|
||||
status,
|
||||
onOpenDialog,
|
||||
}: {
|
||||
status: SystemProxyStatus;
|
||||
onOpenDialog: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const unregister = useRpcMutation("helper_unregister", {
|
||||
onSuccess: (status) =>
|
||||
queryClient.setQueryData<SystemProxyStatus>(["system_proxy_status", {}], status),
|
||||
});
|
||||
const row = helperRow(status.helper, status.backend);
|
||||
const error = unregister.error;
|
||||
return (
|
||||
<MenuRow
|
||||
icon={
|
||||
<Icon
|
||||
icon={status.helper === "enabled" ? "badge_check" : "lock"}
|
||||
size="sm"
|
||||
color={status.helper === "enabled" ? "success" : "secondary"}
|
||||
/>
|
||||
}
|
||||
title={row.title}
|
||||
description={
|
||||
error != null ? <span className="text-danger">{error.message}</span> : row.description
|
||||
}
|
||||
action={
|
||||
row.action != null && (
|
||||
<Button
|
||||
size="2xs"
|
||||
variant="border"
|
||||
color={row.action === "unregister" ? "default" : "primary"}
|
||||
isLoading={unregister.isPending}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (row.action === "unregister") unregister.mutate({});
|
||||
else onOpenDialog();
|
||||
}}
|
||||
>
|
||||
{row.action === "install"
|
||||
? "Install"
|
||||
: row.action === "approve"
|
||||
? "Approve"
|
||||
: "Unregister"}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Saves whether the system proxy turns on as the app launches */
|
||||
function useSetSystemProxyAtLaunch() {
|
||||
const store = useStore();
|
||||
return useRpcMutation("set_system_proxy_at_launch", {
|
||||
onSettled: () => void refreshProxyState(store),
|
||||
});
|
||||
}
|
||||
|
||||
/** Saves the HTTP versions offered to clients, which the engine applies to new connections */
|
||||
function useSetHttpVersion() {
|
||||
const store = useStore();
|
||||
return useRpcMutation("set_http_version", {
|
||||
onSettled: () => void refreshProxyState(store),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCopyEnvironment() {
|
||||
const proxyState = useAtomValue(proxyStateAtom);
|
||||
const caStatus = useRpcQuery("ca_status", {});
|
||||
@@ -397,13 +344,25 @@ export function useCopyEnvironment() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(environmentExports(port, caPath));
|
||||
showToast({
|
||||
message: "Copied the environment. Paste it into a terminal to proxy what runs there.",
|
||||
message: "Copied the environment. Paste it into a terminal.",
|
||||
});
|
||||
} catch (err) {
|
||||
showToast({ message: `Failed to copy: ${String(err)}`, color: "danger" });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether something was just copied, for a moment, and the call that says it was */
|
||||
function useCopied(): [boolean, () => void] {
|
||||
const [copied, setCopied] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!copied) return;
|
||||
const timeout = setTimeout(() => setCopied(false), 1500);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [copied]);
|
||||
return [copied, () => setCopied(true)];
|
||||
}
|
||||
|
||||
/** Keeps a click on a control inside a menu row from closing the menu, as a row's click does */
|
||||
function KeepMenuOpen({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
@@ -464,6 +423,7 @@ function MenuRow({
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
/** Under the title; null for a title that says it all */
|
||||
description: ReactNode;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
@@ -472,7 +432,9 @@ function MenuRow({
|
||||
<span className="h-5 flex items-center justify-center">{icon}</span>
|
||||
<div>
|
||||
<div className="text-sm text-text">{title}</div>
|
||||
<div className="text-xs text-text-subtle mt-0.5">{description}</div>
|
||||
{description != null && (
|
||||
<div className="text-xs text-text-subtle mt-0.5">{description}</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="self-center">{action}</span>
|
||||
</div>
|
||||
|
||||
@@ -6,11 +6,13 @@ import type {
|
||||
} from "@yaakapp-internal/proxy-lib";
|
||||
import {
|
||||
Banner,
|
||||
CONTROL_TOOLTIP_DELAY,
|
||||
Dialog,
|
||||
DialogFooter,
|
||||
HttpMethodTagRaw,
|
||||
LoadingIcon,
|
||||
PlainInput,
|
||||
Tooltip,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { unnamedRequestName } from "@yaakapp-internal/ui/resolvedModelName";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
@@ -38,8 +40,8 @@ export function SaveDialog() {
|
||||
open
|
||||
onClose={close}
|
||||
size="dynamic"
|
||||
title="Save as Yaak request"
|
||||
description="A new request in Yaak with this flow's method, URL, headers and body."
|
||||
title="Save as Yaak Request"
|
||||
description="A new request will be created in Yaak."
|
||||
className="w-[36rem]"
|
||||
>
|
||||
{workspaces.error != null ? (
|
||||
@@ -101,7 +103,7 @@ function SaveForm({
|
||||
message: (
|
||||
<>
|
||||
Saved <b className="font-semibold">{saved.name || unnamed}</b> to {workspace.name}
|
||||
{folderName == null ? "." : `, in ${folderName}.`} Yaak picks it up within a second.
|
||||
{folderName == null ? "" : `, in ${folderName}`}
|
||||
</>
|
||||
),
|
||||
});
|
||||
@@ -111,11 +113,7 @@ function SaveForm({
|
||||
};
|
||||
|
||||
if (workspaces.length === 0) {
|
||||
return (
|
||||
<Banner color="notice">
|
||||
Yaak has no workspaces yet. Create one in Yaak, then save here.
|
||||
</Banner>
|
||||
);
|
||||
return <Banner color="notice">Create a workspace in Yaak first.</Banner>;
|
||||
}
|
||||
|
||||
if (p == null) {
|
||||
@@ -181,13 +179,16 @@ function SaveForm({
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{p.droppedHeaders.map((h) => (
|
||||
<span
|
||||
<Tooltip
|
||||
key={h.name}
|
||||
title={`${h.name}: ${h.value}\n${reasonLabel(h.reason)}`}
|
||||
className="font-mono text-xs leading-[22px] px-1.5 rounded-sm border border-border-subtle text-text-subtle line-through decoration-text-subtlest"
|
||||
wrap
|
||||
content={`${h.name}: ${h.value}\n${reasonLabel(h.reason)}`}
|
||||
delay={CONTROL_TOOLTIP_DELAY}
|
||||
>
|
||||
{h.name}
|
||||
</span>
|
||||
<span className="font-mono text-xs leading-[22px] px-1.5 rounded-sm border border-border-subtle text-text-subtle line-through decoration-text-subtlest">
|
||||
{h.name}
|
||||
</span>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -204,13 +205,10 @@ function SaveForm({
|
||||
</div>
|
||||
{save.error != null && <Banner color="danger">{save.error.message}</Banner>}
|
||||
<DialogFooter
|
||||
leftSlot={
|
||||
<span className="text-xs text-text-subtlest">Yaak picks it up within a second</span>
|
||||
}
|
||||
actions={[
|
||||
{ label: "Cancel", onClick: onClose },
|
||||
{
|
||||
label: "Save request",
|
||||
label: "Save Request",
|
||||
form: SAVE_FORM_ID,
|
||||
color: "primary",
|
||||
isLoading: save.isPending,
|
||||
@@ -238,15 +236,19 @@ function NoYaakDialog({ flow, onClose }: { flow: Flow; onClose: () => void }) {
|
||||
return (
|
||||
<Dialog open onClose={onClose} size="dynamic" className="w-[32rem]">
|
||||
<DialogFooter
|
||||
leftSlot={<span className="text-xs text-text-subtlest">Or keep the flow as a file</span>}
|
||||
leftSlot={
|
||||
<span className="text-xs text-text-subtlest">
|
||||
The flow can be exported as a file instead.
|
||||
</span>
|
||||
}
|
||||
actions={[
|
||||
{
|
||||
label: "Export HAR",
|
||||
label: "Export as HAR",
|
||||
onClick: async () => {
|
||||
try {
|
||||
if ((await exportHar({ ids: [flow.id] }, "flow.har")) != null) {
|
||||
onClose();
|
||||
showToast({ message: "Exported the flow as HAR" });
|
||||
showToast({ message: "Exported 1 flow as HAR" });
|
||||
}
|
||||
} catch (err) {
|
||||
showToast({ message: `Failed to export HAR: ${String(err)}`, color: "danger" });
|
||||
@@ -257,11 +259,10 @@ function NoYaakDialog({ flow, onClose }: { flow: Flow; onClose: () => void }) {
|
||||
]}
|
||||
/>
|
||||
<div className="pt-6 pb-2">
|
||||
<div className="text-lg font-semibold mb-1.5">Saving needs Yaak</div>
|
||||
<div className="text-lg font-semibold mb-1.5">Yaak Required</div>
|
||||
<div className="text-sm text-text-subtle leading-relaxed">
|
||||
This flow becomes a request in one of Yaak's workspaces, and Yaak has not been opened on
|
||||
this Mac. Yaak is free for personal use and opens the request with the same viewers you
|
||||
see here.
|
||||
This flow will be saved as a request in one of Yaak's workspaces, but Yaak has not been
|
||||
opened on this computer.
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
@@ -303,18 +304,18 @@ function folderOptions(folders: FolderSummary[]): { id: string; name: string }[]
|
||||
function reasonLabel(reason: DropReason): string {
|
||||
switch (reason) {
|
||||
case "set_by_sending":
|
||||
return "Yaak sets it when it sends";
|
||||
return "Yaak sets it when it sends the request.";
|
||||
case "hop_by_hop":
|
||||
return "Only meant for the proxy";
|
||||
return "It was only meant for the proxy.";
|
||||
case "body_decoded":
|
||||
return "The body is saved decoded";
|
||||
return "The body is saved decoded.";
|
||||
}
|
||||
}
|
||||
|
||||
function droppedSummary(reasons: DropReason[]): string {
|
||||
const unique = [...new Set(reasons)];
|
||||
if (unique.length === 1 && unique[0] === "set_by_sending") {
|
||||
return "Yaak sets these itself when it sends";
|
||||
return "Yaak sets these itself when it sends the request.";
|
||||
}
|
||||
return "Yaak sets these itself, or they only meant something to the proxy";
|
||||
return "Yaak sets these, or they were meant for the proxy.";
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Dialog, DialogFooter, PlainInput } from "@yaakapp-internal/ui";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { useState } from "react";
|
||||
import { useRpcMutation } from "../hooks/useRpcMutation";
|
||||
import { useShowToast } from "../lib/toast";
|
||||
|
||||
/** Saving the search box's text under a name, or renaming a saved search */
|
||||
export type SavedSearchDialog =
|
||||
| { mode: "save"; query: string }
|
||||
| { mode: "rename"; id: string; name: string };
|
||||
|
||||
export const savedSearchDialogAtom = atom<SavedSearchDialog | null>(null);
|
||||
|
||||
const FORM_ID = "saved-search";
|
||||
|
||||
/** Names a saved search, as `savedSearchDialogAtom` asks */
|
||||
export function SavedSearchDialog() {
|
||||
const [dialog, setDialog] = useAtom(savedSearchDialogAtom);
|
||||
if (dialog == null) return null;
|
||||
// Keyed, so each opening starts from its own name.
|
||||
return (
|
||||
<NameDialog
|
||||
key={dialog.mode === "save" ? `save:${dialog.query}` : `rename:${dialog.id}`}
|
||||
dialog={dialog}
|
||||
onClose={() => setDialog(null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NameDialog({ dialog, onClose }: { dialog: SavedSearchDialog; onClose: () => void }) {
|
||||
const [name, setName] = useState(dialog.mode === "rename" ? dialog.name : "");
|
||||
const showToast = useShowToast();
|
||||
const onError = (err: Error) =>
|
||||
showToast({ id: "saved-search", message: err.message, color: "danger" });
|
||||
const create = useRpcMutation("create_saved_query", { onSuccess: onClose, onError });
|
||||
const rename = useRpcMutation("rename_saved_query", { onSuccess: onClose, onError });
|
||||
const busy = create.isPending || rename.isPending;
|
||||
|
||||
const submit = () => {
|
||||
if (name.trim() === "" || busy) return;
|
||||
if (dialog.mode === "save") create.mutate({ name, query: dialog.query });
|
||||
else rename.mutate({ id: dialog.id, name });
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={onClose}
|
||||
size="dynamic"
|
||||
title={dialog.mode === "save" ? "Save Search" : "Rename Search"}
|
||||
className="w-[26rem]"
|
||||
>
|
||||
<form
|
||||
id={FORM_ID}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
<PlainInput
|
||||
label="Name"
|
||||
autoFocus
|
||||
defaultValue={name}
|
||||
onChange={setName}
|
||||
placeholder={dialog.mode === "save" ? dialog.query : undefined}
|
||||
/>
|
||||
</form>
|
||||
<DialogFooter
|
||||
actions={[
|
||||
{ label: "Cancel", variant: "border", onClick: onClose },
|
||||
{
|
||||
label: dialog.mode === "save" ? "Save" : "Rename",
|
||||
color: "primary",
|
||||
form: FORM_ID,
|
||||
disabled: name.trim() === "",
|
||||
isLoading: busy,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,111 +1,529 @@
|
||||
import { Icon, IconButton, PlainInput } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { memo, useEffect, useRef, useState } from "react";
|
||||
import { exportHar } from "../lib/flowActions";
|
||||
import { searchTerms } from "../lib/search";
|
||||
import { activeQueryAtom, searchTermsAtom } from "../lib/store";
|
||||
import {
|
||||
autocompletion,
|
||||
closeCompletion,
|
||||
type Completion,
|
||||
completionStatus,
|
||||
startCompletion,
|
||||
} from "@codemirror/autocomplete";
|
||||
import type { Extension } from "@codemirror/state";
|
||||
import { Compartment, Prec } from "@codemirror/state";
|
||||
import { EditorView, keymap, tooltips, ViewPlugin, type ViewUpdate } from "@codemirror/view";
|
||||
import {
|
||||
Dropdown,
|
||||
type DropdownItem,
|
||||
Icon,
|
||||
IconButton,
|
||||
Input,
|
||||
type InputHandle,
|
||||
LazyCodeEditor,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { queryFilter } from "@yaakapp-internal/ui/editor";
|
||||
import classNames from "classnames";
|
||||
import { useAtom, useAtomValue, useSetAtom, useStore } from "jotai";
|
||||
import {
|
||||
type CSSProperties,
|
||||
memo,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useRpcQueryWithEvent } from "../hooks/useRpcQueryWithEvent";
|
||||
import { useRpcQuery } from "../hooks/useRpcQuery";
|
||||
import { appliedFilterAtom } from "../lib/atoms";
|
||||
import { isUnapplied, pressEnter, pressEscape, type Recall, stepHistory } from "../lib/filterBox";
|
||||
import { useExportShownHar } from "../lib/flowActions";
|
||||
import { applyFilter, searchDraftAtom, searchDraftVersionAtom } from "../lib/navigation";
|
||||
import { rpc } from "../lib/rpc";
|
||||
import { loadSeenValues, savedCompletions, searchProblems } from "../lib/searchBox";
|
||||
import { allTrafficTotalAtom, lockedAtom, savedQueriesAtom, type Store } from "../lib/store";
|
||||
import { useShowToast } from "../lib/toast";
|
||||
import { savedSearchDialogAtom } from "./SavedSearchDialog";
|
||||
|
||||
/** How long typing settles before the search runs */
|
||||
const SEARCH_DEBOUNCE_MS = 200;
|
||||
/** Recent filters listed above the fields when the empty box is focused */
|
||||
const RECENT_IN_COMPLETIONS = 5;
|
||||
|
||||
const NO_HISTORY: string[] = [];
|
||||
|
||||
/**
|
||||
* Searches every flow in the database by URL, method, status and content type, within the
|
||||
* sidebar's selection. ⌘F focuses it and Escape clears it.
|
||||
* The header's search over every flow in the database, in the query language: bare words match
|
||||
* the URL, method, status and content type, and `@field:value` terms the rest, highlighted and
|
||||
* completed as Yaak's sidebar filter is, with the values the proxy has seen. The box composes a
|
||||
* filter and Enter applies it, as Yaak's URL bar sends; until then a return key at its right
|
||||
* says so, and Escape puts the applied filter back. The magnifier lists the filters applied
|
||||
* before, which Up in the empty box steps through too. Its placeholder counts the flows. ⌘F
|
||||
* focuses it. Focused, it wraps a long query over as many lines as it needs, as Yaak's URL bar
|
||||
* does, over what is below; blurred, it is one line again.
|
||||
*/
|
||||
export const SearchBar = memo(function SearchBar() {
|
||||
const [text, setText] = useState("");
|
||||
const setTerms = useSetAtom(searchTermsAtom);
|
||||
const query = useAtomValue(activeQueryAtom);
|
||||
const showToast = useShowToast();
|
||||
// PlainInput hands back its <input> as the ref
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
// Bumped to empty the field, which only takes a value from outside when this changes
|
||||
const [resetKey, setResetKey] = useState(0);
|
||||
const [draft, setDraft] = useAtom(searchDraftAtom);
|
||||
const version = useAtomValue(searchDraftVersionAtom);
|
||||
const applied = useAtomValue(appliedFilterAtom);
|
||||
const total = useAtomValue(allTrafficTotalAtom);
|
||||
const store = useStore();
|
||||
const setSavedDialog = useSetAtom(savedSearchDialogAtom);
|
||||
const exportShown = useExportShownHar();
|
||||
// Nothing to search while captured traffic is locked.
|
||||
const locked = useAtomValue(lockedAtom) != null;
|
||||
const schema = useRpcQuery("query_schema", {}, { staleTime: Infinity }).data?.schema;
|
||||
const historyQuery = useRpcQueryWithEvent("list_filter_history", {}, "filter_history_changed", {
|
||||
enabled: !locked,
|
||||
});
|
||||
const history = useMemo(
|
||||
() => historyQuery.data?.map((e) => e.filter) ?? NO_HISTORY,
|
||||
[historyQuery.data],
|
||||
);
|
||||
const handleRef = useRef<InputHandle | null>(null);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
const [focused, setFocused] = useState(false);
|
||||
// Where the box is while focused, so it grows over the list from the header's place for it
|
||||
const [place, setPlace] = useState<CSSProperties | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setTerms(searchTerms(text)), SEARCH_DEBOUNCE_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [text, setTerms]);
|
||||
// Read by the editor's keys, which are made once
|
||||
const latest = useRef({ applied, history });
|
||||
useLayoutEffect(() => {
|
||||
latest.current = { applied, history };
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!focused) {
|
||||
setPlace(null);
|
||||
return;
|
||||
}
|
||||
const measure = () => {
|
||||
const rect = boxRef.current?.getBoundingClientRect();
|
||||
if (rect != null) setPlace({ top: rect.top, left: rect.left, width: rect.width });
|
||||
};
|
||||
measure();
|
||||
window.addEventListener("resize", measure);
|
||||
return () => window.removeEventListener("resize", measure);
|
||||
}, [focused]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "f") {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
handleRef.current?.selectAll();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, []);
|
||||
|
||||
// The field is replaced to empty it, so focus moves to the new one.
|
||||
// The language goes in once the schema is read: into the editor there is, through a
|
||||
// compartment, and into each one made after, as taking text from the sidebar makes one.
|
||||
const compartment = useRef(new Compartment());
|
||||
const filter = useMemo<Extension>(
|
||||
() =>
|
||||
schema == null
|
||||
? []
|
||||
: queryFilter({
|
||||
schema,
|
||||
loadValues: loadSeenValues,
|
||||
// Read as completion opens, so saved searches are current without remaking it.
|
||||
moreCompletions: (typed) => savedCompletions(store.get(savedQueriesAtom), typed),
|
||||
leadCompletions: () => recentCompletions(latest.current.history),
|
||||
problems: searchProblems,
|
||||
}),
|
||||
[schema, store],
|
||||
);
|
||||
const keys = useMemo(() => boxKeys(store, latest), [store]);
|
||||
const extensions = useMemo(
|
||||
() => [compartment.current.of(filter), completionAcrossBox, keys],
|
||||
[filter, keys],
|
||||
);
|
||||
useEffect(() => {
|
||||
handleRef.current?.dispatch({ effects: compartment.current.reconfigure(filter) });
|
||||
}, [filter]);
|
||||
|
||||
// Applies no filter at once, leaving the box focused and empty.
|
||||
const clear = () => {
|
||||
setText("");
|
||||
setTerms(null);
|
||||
setResetKey((k) => k + 1);
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
const handle = handleRef.current;
|
||||
handle?.dispatch({ changes: { from: 0, to: handle.value().length, insert: "" } });
|
||||
applyFilter(store, "");
|
||||
requestAnimationFrame(() => handleRef.current?.focus());
|
||||
};
|
||||
|
||||
const unapplied = isUnapplied({ draft, applied });
|
||||
const hasText = draft !== "" || applied !== "";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 px-3 pb-1.5">
|
||||
<div className="flex-1 min-w-0">
|
||||
<PlainInput
|
||||
ref={inputRef}
|
||||
// Holds the header's place for the box, which floats over the list while focused
|
||||
<div ref={boxRef} className="relative h-sm">
|
||||
<div
|
||||
style={place ?? undefined}
|
||||
className={classNames(place == null ? "absolute inset-x-0 top-0" : "fixed z-30")}
|
||||
>
|
||||
<Input
|
||||
renderEditor={(props) => (
|
||||
<LazyCodeEditor {...props} keyDownHandler={startCompletionOnCtrlSpace} />
|
||||
)}
|
||||
setRef={(h) => {
|
||||
handleRef.current = h;
|
||||
}}
|
||||
label="Search flows"
|
||||
hideLabel
|
||||
size="xs"
|
||||
placeholder="Search flows by URL, method, status or type"
|
||||
defaultValue={text}
|
||||
forceUpdateKey={String(resetKey)}
|
||||
onChange={setText}
|
||||
onKeyDownCapture={(e) => {
|
||||
if (e.key === "Escape" && text !== "") {
|
||||
e.stopPropagation();
|
||||
clear();
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
className="font-mono text-xs"
|
||||
containerClassName={classNames(focused && "bg-surface shadow-lg")}
|
||||
stateKey={null}
|
||||
language={null}
|
||||
wrapLines={focused}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
placeholder={locked ? "Traffic is locked" : placeholderFor(total)}
|
||||
disabled={locked}
|
||||
defaultValue={draft}
|
||||
forceUpdateKey={String(version)}
|
||||
onChange={setDraft}
|
||||
extensions={extensions}
|
||||
leftSlot={
|
||||
<Icon icon="search" size="sm" className="ml-2 mr-2.5 self-center text-text-subtlest" />
|
||||
// oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- keeps focus in the editor
|
||||
<div
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
className="flex items-center self-center ml-0.5"
|
||||
>
|
||||
<FilterHistory history={history} store={store} disabled={locked} />
|
||||
</div>
|
||||
}
|
||||
rightSlot={
|
||||
text === "" ? (
|
||||
<kbd className="self-center mr-2 px-1 rounded-sm border border-border-subtle font-sans text-2xs text-text-subtlest">
|
||||
⌘F
|
||||
</kbd>
|
||||
) : (
|
||||
<IconButton
|
||||
size="xs"
|
||||
icon="x"
|
||||
title="Clear search"
|
||||
iconColor="secondary"
|
||||
className="self-center"
|
||||
onClick={clear}
|
||||
/>
|
||||
)
|
||||
// oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- keeps focus in the editor
|
||||
<div
|
||||
// Pressing these leaves the editor focused, so a wrapped box does not collapse and
|
||||
// move them before the click lands.
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
className="flex items-center self-center ml-1 mr-0.5"
|
||||
>
|
||||
{unapplied ? (
|
||||
<Key className="mr-1">↵</Key>
|
||||
) : hasText ? null : (
|
||||
<Key className="ml-1 mr-1.5">⌘F</Key>
|
||||
)}
|
||||
{hasText && (
|
||||
<>
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: "Save Search",
|
||||
leftSlot: <Icon icon="bookmark" />,
|
||||
onSelect: () =>
|
||||
setSavedDialog({
|
||||
mode: "save",
|
||||
query: (draft.trim() === "" ? applied : draft).trim(),
|
||||
}),
|
||||
},
|
||||
exportShown(),
|
||||
]}
|
||||
>
|
||||
<IconButton
|
||||
size="xs"
|
||||
icon="ellipsis"
|
||||
title="Search actions"
|
||||
tooltip
|
||||
iconColor="secondary"
|
||||
/>
|
||||
</Dropdown>
|
||||
<IconButton
|
||||
size="xs"
|
||||
icon="x"
|
||||
title="Clear search"
|
||||
tooltip
|
||||
iconColor="secondary"
|
||||
onClick={clear}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<IconButton
|
||||
size="xs"
|
||||
icon="download"
|
||||
iconColor="secondary"
|
||||
title={query == null ? "Export every flow as HAR" : "Export the flows shown as HAR"}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const written = await exportHar(query == null ? {} : { filter: query }, "flows.har");
|
||||
if (written != null) {
|
||||
showToast({
|
||||
message:
|
||||
written === 1 ? "Exported 1 flow as HAR" : `Exported ${written} flows as HAR`,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
showToast({ message: `Failed to export HAR: ${String(err)}`, color: "danger" });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function Key({ children, className }: { children: string; className?: string }) {
|
||||
return (
|
||||
<kbd
|
||||
className={classNames(
|
||||
className,
|
||||
"px-1 rounded-sm border border-border-subtle font-sans text-2xs text-text-subtlest",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</kbd>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The magnifier, which lists the filters applied before, newest first: choosing one applies it
|
||||
* again, and each can be saved as a search or forgotten
|
||||
*/
|
||||
function FilterHistory({
|
||||
history,
|
||||
store,
|
||||
disabled,
|
||||
}: {
|
||||
history: string[];
|
||||
store: Store;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const setSavedDialog = useSetAtom(savedSearchDialogAtom);
|
||||
const showToast = useShowToast();
|
||||
const fail = (err: unknown) =>
|
||||
showToast({ id: "filter-history", message: String(err), color: "danger" });
|
||||
|
||||
const items: DropdownItem[] =
|
||||
history.length === 0
|
||||
? [{ label: "No recent searches", disabled: true }]
|
||||
: [
|
||||
...history.map((filter): DropdownItem => ({
|
||||
label: (
|
||||
<span className="block font-mono text-xs truncate max-w-md" title={filter}>
|
||||
{filter}
|
||||
</span>
|
||||
),
|
||||
onSelect: () => applyFilter(store, filter, { record: true }),
|
||||
actions: [
|
||||
{
|
||||
icon: "bookmark",
|
||||
title: "Save search",
|
||||
closeMenu: true,
|
||||
onSelect: () => setSavedDialog({ mode: "save", query: filter }),
|
||||
},
|
||||
{
|
||||
icon: "x",
|
||||
title: "Remove",
|
||||
onSelect: () => void rpc("delete_filter_history", { filter }).catch(fail),
|
||||
},
|
||||
],
|
||||
})),
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Clear History",
|
||||
leftSlot: <Icon icon="trash" />,
|
||||
onSelect: () => void rpc("clear_filter_history", {}).catch(fail),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Dropdown items={items}>
|
||||
<IconButton
|
||||
size="xs"
|
||||
icon="search"
|
||||
title="Recent searches"
|
||||
tooltip
|
||||
iconColor="secondary"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
/** The recent filters the empty box's completions start with */
|
||||
function recentCompletions(history: string[]): Completion[] {
|
||||
return history.slice(0, RECENT_IN_COMPLETIONS).map((filter) => ({
|
||||
label: filter,
|
||||
detail: "Recent",
|
||||
type: "text",
|
||||
apply: filter,
|
||||
}));
|
||||
}
|
||||
|
||||
/** The menu of completions is open, so the keys are its own */
|
||||
function menuOpen(view: EditorView): boolean {
|
||||
return completionStatus(view.state) === "active";
|
||||
}
|
||||
|
||||
function replaceText(view: EditorView, text: string) {
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: text },
|
||||
selection: { anchor: text.length },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter, Escape, Up and Down in the box, over the editor's own and under its completions': Enter
|
||||
* applies the draft, Escape puts the applied filter back or clears it, and Up and Down step
|
||||
* through the filter history from an empty box. Each is the completion menu's while it is open,
|
||||
* except Up from an empty box, which the history has.
|
||||
*/
|
||||
function boxKeys(
|
||||
store: Store,
|
||||
latest: { current: { applied: string; history: string[] } },
|
||||
): Extension {
|
||||
let recall: Recall | null = null;
|
||||
const step = (view: EditorView, key: "up" | "down") => {
|
||||
const next = stepHistory(latest.current.history, recall, view.state.doc.toString(), key);
|
||||
if (next == null) return false;
|
||||
recall = next.recall;
|
||||
closeCompletion(view);
|
||||
replaceText(view, next.draft);
|
||||
return true;
|
||||
};
|
||||
const box = (view: EditorView) => ({
|
||||
draft: view.state.doc.toString(),
|
||||
applied: latest.current.applied,
|
||||
});
|
||||
return [
|
||||
Prec.high(
|
||||
keymap.of([
|
||||
{
|
||||
key: "Enter",
|
||||
stopPropagation: true,
|
||||
run: (view) => {
|
||||
if (menuOpen(view)) return false;
|
||||
const next = pressEnter(box(view));
|
||||
recall = null;
|
||||
applyFilter(store, next.apply ?? "", { record: next.record });
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "Escape",
|
||||
stopPropagation: true,
|
||||
run: (view) => {
|
||||
if (menuOpen(view)) return false;
|
||||
const next = pressEscape(box(view));
|
||||
if (next == null) return false;
|
||||
recall = null;
|
||||
replaceText(view, next.draft);
|
||||
if (next.apply != null) applyFilter(store, next.apply);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "ArrowUp",
|
||||
run: (view) => {
|
||||
const draft = view.state.doc.toString();
|
||||
const stepping = recall != null && recall.text === draft;
|
||||
if (menuOpen(view) && draft !== "" && !stepping) return false;
|
||||
return step(view, "up");
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "ArrowDown",
|
||||
run: (view) => (menuOpen(view) ? false : step(view, "down")),
|
||||
},
|
||||
]),
|
||||
),
|
||||
// The empty box lists the recent filters as it is focused.
|
||||
EditorView.domEventHandlers({
|
||||
focus: (_e, view) => {
|
||||
if (view.state.doc.length > 0 || latest.current.history.length === 0) return;
|
||||
requestAnimationFrame(() => {
|
||||
if (view.hasFocus && view.state.doc.length === 0) startCompletion(view);
|
||||
});
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
/** The search box's width, which its completions take */
|
||||
const BOX_WIDTH_VAR = "--proxy-search-width";
|
||||
|
||||
/** The search box an editor is in */
|
||||
function boxOf(view: EditorView): HTMLElement | null {
|
||||
return view.dom.closest<HTMLElement>(".x-theme-input");
|
||||
}
|
||||
|
||||
/** Pixels between a completion's info and the window's edge */
|
||||
const INFO_MARGIN = 8;
|
||||
|
||||
/**
|
||||
* Where a completion's info goes: beside the list, in the window's room either side of the box,
|
||||
* or, with none, under the option as CodeMirror puts it in a narrow space
|
||||
*/
|
||||
function positionInfo(
|
||||
view: EditorView,
|
||||
list: { left: number; right: number; top: number; bottom: number },
|
||||
option: { top: number; bottom: number },
|
||||
info: { left: number; right: number; top: number; bottom: number },
|
||||
): { style: string; class: string } {
|
||||
const win = view.dom.ownerDocument.defaultView ?? window;
|
||||
const width = info.right - info.left;
|
||||
const height = info.bottom - info.top;
|
||||
const right = win.innerWidth - list.right - INFO_MARGIN;
|
||||
const left = list.left - INFO_MARGIN;
|
||||
if (Math.max(left, right) >= width) {
|
||||
const top = Math.max(0, Math.min(option.top, win.innerHeight - height)) - list.top;
|
||||
const onRight = right >= width;
|
||||
return {
|
||||
style: `top: ${top}px; max-width: ${onRight ? right : left}px`,
|
||||
class: onRight ? "cm-completionInfo-right" : "cm-completionInfo-left",
|
||||
};
|
||||
}
|
||||
return {
|
||||
style: `top: ${option.bottom - list.top}px; max-width: 400px`,
|
||||
class: "cm-completionInfo-right-narrow",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The completions under the search box, as wide as it and level with its left edge, rather
|
||||
* than a narrow list wherever the cursor is
|
||||
*/
|
||||
const completionAcrossBox: Extension = [
|
||||
autocompletion({ positionInfo }),
|
||||
tooltips({
|
||||
tooltipSpace: (view) => {
|
||||
const win = view.dom.ownerDocument.defaultView ?? window;
|
||||
const rect = boxOf(view)?.getBoundingClientRect();
|
||||
return {
|
||||
top: 0,
|
||||
bottom: win.innerHeight,
|
||||
left: rect?.left ?? 0,
|
||||
right: rect?.right ?? win.innerWidth,
|
||||
};
|
||||
},
|
||||
}),
|
||||
ViewPlugin.fromClass(
|
||||
class {
|
||||
observer: ResizeObserver | null = null;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.watch(view);
|
||||
}
|
||||
|
||||
// The editor is put in the page after it is made, so the box may only be found later.
|
||||
update(update: ViewUpdate) {
|
||||
if (this.observer == null) this.watch(update.view);
|
||||
}
|
||||
|
||||
watch(view: EditorView) {
|
||||
const box = boxOf(view);
|
||||
if (box == null) return;
|
||||
const root = view.dom.ownerDocument.documentElement;
|
||||
this.observer = new ResizeObserver(() => {
|
||||
root.style.setProperty(BOX_WIDTH_VAR, `${box.getBoundingClientRect().width}px`);
|
||||
});
|
||||
this.observer.observe(box);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.observer?.disconnect();
|
||||
}
|
||||
},
|
||||
),
|
||||
EditorView.theme({
|
||||
".cm-tooltip.cm-tooltip-autocomplete": { width: `var(${BOX_WIDTH_VAR})` },
|
||||
".cm-tooltip.cm-tooltip-autocomplete > ul": { minWidth: "0", maxWidth: "none" },
|
||||
}),
|
||||
];
|
||||
|
||||
/**
|
||||
* Ctrl+Space lists what can be typed, fields and saved searches on an empty spot, as it does in
|
||||
* Yaak's editors, whose shared editor leaves the key to each app
|
||||
*/
|
||||
function startCompletionOnCtrlSpace(e: KeyboardEvent, view: EditorView): boolean {
|
||||
if (!(e.ctrlKey && e.key === " " && !e.metaKey && !e.altKey && !e.shiftKey)) return false;
|
||||
e.preventDefault();
|
||||
startCompletion(view);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** "Search 4,102 flows", the title and count that sat in the header's middle */
|
||||
function placeholderFor(total: number): string {
|
||||
if (total === 0) return "Search flows";
|
||||
return `Search ${total.toLocaleString()} ${total === 1 ? "flow" : "flows"}`;
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import { appDataDir, appLogDir } from "@tauri-apps/api/path";
|
||||
import {
|
||||
Dialog,
|
||||
EmptyStateText,
|
||||
SETTINGS_TAB_GENERAL,
|
||||
SETTINGS_TAB_INTERFACE,
|
||||
SETTINGS_TAB_LICENSE,
|
||||
SETTINGS_TAB_THEME,
|
||||
type SettingsCallbacks,
|
||||
SettingsDialog,
|
||||
SettingsGeneral,
|
||||
SettingsInterface,
|
||||
SettingsTheme,
|
||||
type SettingsTitlebar,
|
||||
Toasts,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { atom, useAtom, useAtomValue, useSetAtom, useStore } from "jotai";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { settingsValuesAtom } from "../lib/interfaceSettings";
|
||||
import { openLink, revealPath } from "../lib/opener";
|
||||
import { proxySettingsView } from "../lib/settings";
|
||||
import { closeWindow, command, currentWindowLabel, getOsType, subscribe } from "../lib/tauri";
|
||||
import { useShowToast } from "../lib/toast";
|
||||
import { checkForUpdatesNow, updateCheckingAtom, updaterEnabled } from "../lib/updates";
|
||||
import {
|
||||
openSettingsVia,
|
||||
requestedSettingsTab,
|
||||
SETTINGS_TAB_EVENT,
|
||||
windowKind,
|
||||
} from "../lib/windows";
|
||||
import { HelperDialog } from "./HelperDialog";
|
||||
import { useProxySetup } from "./ProxyStatus";
|
||||
import { SettingsProxy } from "./SettingsProxy";
|
||||
|
||||
export const SETTINGS_TAB_PROXY = "proxy";
|
||||
|
||||
/** The settings dialog is open, on a tab, or closed, where Settings has no window of its own */
|
||||
const settingsDialogAtom = atom<string | null>(null);
|
||||
|
||||
/**
|
||||
* Yaak's settings, shared: its General, Theme, Interface and License tabs, plus the proxy's own
|
||||
* Proxy tab. The header's gear and `⌘,` open it in a window of its own, or, where the app has
|
||||
* no second window to give, such as the browser harness, in this dialog.
|
||||
*/
|
||||
export function SettingsDialogFallback() {
|
||||
const [tab, setTab] = useAtom(settingsDialogAtom);
|
||||
useSettingsHotkey();
|
||||
return (
|
||||
<Dialog
|
||||
open={tab != null}
|
||||
onClose={() => setTab(null)}
|
||||
size="lg"
|
||||
noPadding
|
||||
noScroll
|
||||
className="h-[min(44rem,90vh)]"
|
||||
>
|
||||
{tab != null && <SettingsContent defaultTab={tab} />}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Settings window: the settings alone, full-window under their own title bar, on the tab
|
||||
* asked for, moving to another tab each time Settings is opened on one. Escape closes it. The
|
||||
* dialogs the settings open, and their toasts, show over it.
|
||||
*/
|
||||
export function SettingsWindowContent() {
|
||||
const [shown, setShown] = useState(() => ({
|
||||
tab: requestedSettingsTab() ?? SETTINGS_TAB_GENERAL,
|
||||
// Keys the tabs, which take their tab only as they mount
|
||||
version: 0,
|
||||
}));
|
||||
useEffect(
|
||||
() =>
|
||||
subscribe<string>(SETTINGS_TAB_EVENT, (tab) =>
|
||||
setShown((s) => ({ tab, version: s.version + 1 })),
|
||||
),
|
||||
[],
|
||||
);
|
||||
useEffect(() => {
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
// A dialog over the settings takes its own Escape and stops it here, an editor's popup
|
||||
// marks it handled, and an open menu closes on it, leaving the window open
|
||||
if (e.key !== "Escape" || e.defaultPrevented) return;
|
||||
if (document.querySelector('[role="menu"]') != null) return;
|
||||
closeWindow().catch(console.error);
|
||||
}
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, []);
|
||||
// Reads the system proxy and certificate as the main window's header does, polling while the
|
||||
// helper awaits approval, so the helper's dialog here sees it given.
|
||||
useProxySetup();
|
||||
const { hideWindowControls } = useAtomValue(settingsValuesAtom);
|
||||
const titlebar = useMemo<SettingsTitlebar>(
|
||||
() => ({
|
||||
osType: getOsType(),
|
||||
hideWindowControls,
|
||||
// The proxy draws its own title bar always, as its main window does
|
||||
useNativeTitlebar: false,
|
||||
interfaceScale: 1,
|
||||
}),
|
||||
[hideWindowControls],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full bg-surface">
|
||||
<SettingsContent key={shown.version} defaultTab={shown.tab} titlebar={titlebar} />
|
||||
<HelperDialog />
|
||||
<Toasts />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The settings themselves, as the window or the dialog holds them */
|
||||
export function SettingsContent({
|
||||
defaultTab,
|
||||
titlebar = null,
|
||||
}: {
|
||||
defaultTab: string;
|
||||
/** The window's title bar; none in the dialog, which has its own frame */
|
||||
titlebar?: SettingsTitlebar | null;
|
||||
}) {
|
||||
const [values, change] = useAtom(settingsValuesAtom);
|
||||
const app = useAppInfo();
|
||||
const systemAppearance = useSystemAppearance();
|
||||
const store = useStore();
|
||||
const checking = useAtomValue(updateCheckingAtom);
|
||||
const { data: hasUpdater = false } = useQuery({
|
||||
queryKey: ["updater_enabled"],
|
||||
queryFn: updaterEnabled,
|
||||
retry: false,
|
||||
});
|
||||
const view = useMemo(
|
||||
() => proxySettingsView(values, app, systemAppearance, hasUpdater ? { checking } : null),
|
||||
[values, app, systemAppearance, hasUpdater, checking],
|
||||
);
|
||||
const showToast = useShowToast();
|
||||
const callbacks = useMemo<SettingsCallbacks>(() => {
|
||||
const fail = (message: string) => showToast({ message, color: "danger" });
|
||||
return {
|
||||
onChange: (patch) => change(patch),
|
||||
// The proxy draws its own title bar always; the row isn't shown
|
||||
onApplyNativeTitlebar: (useNativeTitlebar) => change({ useNativeTitlebar }),
|
||||
onCheckForUpdates: () => void checkForUpdatesNow(store),
|
||||
onActivateLicense: async () => undefined,
|
||||
onDeactivateLicense: () => undefined,
|
||||
onOpenUrl: (url) => void openLink(url, fail),
|
||||
onRevealPath: (path) => void revealPath(path, fail),
|
||||
pricingUrl: (intent) => `https://yaak.app/pricing?intent=${encodeURIComponent(intent)}`,
|
||||
};
|
||||
}, [change, showToast, store]);
|
||||
const props = { view, callbacks };
|
||||
|
||||
return (
|
||||
<SettingsDialog
|
||||
titlebar={titlebar}
|
||||
defaultTab={defaultTab}
|
||||
tabs={[
|
||||
{
|
||||
value: SETTINGS_TAB_GENERAL,
|
||||
label: "General",
|
||||
icon: "settings",
|
||||
content: <SettingsGeneral {...props} />,
|
||||
},
|
||||
{
|
||||
value: SETTINGS_TAB_THEME,
|
||||
label: "Theme",
|
||||
icon: "palette",
|
||||
content: <SettingsTheme {...props} />,
|
||||
},
|
||||
{
|
||||
value: SETTINGS_TAB_INTERFACE,
|
||||
label: "Interface",
|
||||
icon: "columns_2",
|
||||
content: <SettingsInterface {...props} />,
|
||||
},
|
||||
{
|
||||
value: SETTINGS_TAB_PROXY,
|
||||
label: "Proxy",
|
||||
icon: "wifi",
|
||||
content: <SettingsProxy />,
|
||||
},
|
||||
{
|
||||
value: SETTINGS_TAB_LICENSE,
|
||||
label: "License",
|
||||
icon: "key_round",
|
||||
content: <EmptyStateText>Coming soon</EmptyStateText>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** `⌘,`, or `Ctrl+,` off macOS, opens settings, as in Yaak, on the tab they were left on */
|
||||
function useSettingsHotkey() {
|
||||
const open = useOpenSettings();
|
||||
useEffect(() => {
|
||||
const mac = getOsType() === "macos";
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key !== "," || e.shiftKey || e.altKey) return;
|
||||
if (mac ? !e.metaKey || e.ctrlKey : !e.ctrlKey || e.metaKey) return;
|
||||
e.preventDefault();
|
||||
open(null);
|
||||
}
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [open]);
|
||||
}
|
||||
|
||||
function useAppInfo() {
|
||||
const os = getOsType();
|
||||
const { data } = useQuery({
|
||||
queryKey: ["settings_app_info"],
|
||||
queryFn: async () => {
|
||||
const [version, dataDir, logDir] = await Promise.all([
|
||||
getVersion(),
|
||||
appDataDir(),
|
||||
appLogDir(),
|
||||
]);
|
||||
return { version, dataDir, logDir };
|
||||
},
|
||||
retry: false,
|
||||
});
|
||||
return useMemo(
|
||||
() => ({
|
||||
name: "Yaak Proxy",
|
||||
version: data?.version ?? "",
|
||||
dataDir: data?.dataDir ?? "",
|
||||
logDir: data?.logDir ?? "",
|
||||
revealPathLabel:
|
||||
os === "macos"
|
||||
? "Reveal in Finder"
|
||||
: os === "windows"
|
||||
? "Show in Explorer"
|
||||
: "Show in File Manager",
|
||||
}),
|
||||
[data, os],
|
||||
);
|
||||
}
|
||||
|
||||
function useSystemAppearance(): "light" | "dark" {
|
||||
const query = "(prefers-color-scheme: dark)";
|
||||
const [dark, setDark] = useState(() => window.matchMedia(query).matches);
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia(query);
|
||||
const onChange = () => setDark(media.matches);
|
||||
media.addEventListener("change", onChange);
|
||||
return () => media.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
return dark ? "dark" : "light";
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens settings on a tab, General unless another is given. With `null`, opens it on General,
|
||||
* or brings it forward on the tab it is on.
|
||||
*/
|
||||
export function useOpenSettings() {
|
||||
const setDialog = useSetAtom(settingsDialogAtom);
|
||||
const showToast = useShowToast();
|
||||
return useCallback(
|
||||
(tab: string | null = SETTINGS_TAB_GENERAL) => {
|
||||
openSettingsVia(tab, {
|
||||
kind: windowKind(currentWindowLabel()),
|
||||
openWindow: (tab) => command<void>("cmd_open_settings", { tab }),
|
||||
openDialog: (tab) => setDialog((open) => tab ?? open ?? SETTINGS_TAB_GENERAL),
|
||||
}).catch((err) => showToast({ id: "open-settings", color: "danger", message: String(err) }));
|
||||
},
|
||||
[setDialog, showToast],
|
||||
);
|
||||
}
|
||||
Loaded 100 of 442 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user