Compare commits

..
Author SHA1 Message Date
Gregory Schier 0f8413c441 Read OAuth 2.0 token responses from the send instead of the filesystem
Both token requests opened HttpResponse.bodyPath with readFileSync, which
ties the plugin to bodies living on a filesystem. The send now hands the body
back, so they read it from there and keep working once bodies move into the
blob DB and in the browser Worker, where there is no fs to read.

text() on a response with no body returns "", which is what the bodyPath
check produced, so an empty response still parses to {} rather than throwing.
2026-08-16 10:09:43 -07:00
Gregory Schier 0e14625e62 Hand the body back from send instead of holding it for a lookup
Holding an unsaved body against its response id let a plugin stash the id
and read it in a later call, which would throw only sometimes and only
for ad-hoc sends. Documenting that was never going to be enough.

send now returns the response and its body together, so there is nothing
to stash: an unsaved body is a value you were handed. ctx.httpResponse
.body() goes back to meaning one thing, a saved response read by id, and
refuses ids it has no row for. Reading is identical either way, so no
caller has to know which kind of send it made.
2026-08-16 10:07:44 -07:00
Gregory Schier 8c72538102 Say on send() that an unsaved response's body is call-scoped 2026-08-16 10:00:35 -07:00
Gregory Schier ffa6a610b8 Return the body of a send that saved nothing
Replaces the response-directory fallback with what the frontend already
does for ephemeral sends: the engine hands the body back, because it is
the only copy. Guessing at a file named for an id was the store reaching
around its own abstraction, and it is exactly what must not survive the
move to blob storage.

The send reply carries the bytes when the engine returned them, and the
runtime holds them for the rest of the call that sent them, so
ctx.httpResponse.body() answers for a saved and an unsaved response the
same way. Unsaved ones now report a real contentType too, taken from the
response the send already handed back.
2026-08-16 09:54:56 -07:00
Gregory Schier 8b031db685 Read bodies of responses the engine never recorded
A send with no request behind it — a plugin's ad-hoc ctx.httpRequest.send,
GraphQL introspection — gets a generated id and a body file, but no row.
Resolving purely through the database refused those, which would have
broken auth-oauth2 the moment it moved off readFileSync, since every
request it sends is ad-hoc.

The store now falls back to the response directory when there is no row,
accepting only ids shaped the way the engine generates them. Such a
response has no stored headers, so contentType is null and text()
decodes as UTF-8 — which is what the filesystem readers did anyway.
2026-08-16 09:35:03 -07:00
Gregory Schier 96c8a95094 Add a plugin API for reading HTTP response bodies
Plugins read bodies by response id through ctx.httpResponse.body()
instead of opening HttpResponse.bodyPath themselves. The accessors are
named after fetch's, minus the single-use semantics, since the bytes are
durable and re-reading should work.

Underneath is a chunked pull over the existing plugin protocol, so the
host can move bodies off the filesystem without plugins noticing.
text() now decodes with the response's charset rather than assuming
UTF-8, and the buffering accessors refuse past 32 MiB and point at
chunks().
2026-08-16 09:20:29 -07:00
501 changed files with 12758 additions and 47171 deletions

No files matched your search

-11
View File
@@ -1,11 +0,0 @@
node_modules
**/node_modules
dist
**/dist
target
**/target
.claude
vendored
**/vendored
*.log
.git
-55
View File
@@ -1,55 +0,0 @@
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 5
ignore:
# Build tooling majors are opted into, not queued. These never reach a
# user, so the cost of being a release behind is low and the cost of a
# surprise is a broken build. Note this also suppresses security PRs for
# these, so Dependabot alerts are the backstop.
- dependency-name: typescript
update-types: ["version-update:semver-major"]
- dependency-name: vitest
update-types: ["version-update:semver-major"]
- dependency-name: "@types/*"
update-types: ["version-update:semver-major"]
- dependency-name: "vite-plugin-*"
update-types: ["version-update:semver-major"]
# 0.8 dropped stateExtensions and updateSchema from the package root and
# exposes no subpath to reach them, which GrpcEditor needs. Unpin once
# that editor moves to the jsonSchema helper.
- dependency-name: codemirror-json-schema
groups:
npm-production:
dependency-type: production
update-types:
- minor
- patch
npm-development:
dependency-type: development
update-types:
- minor
- patch
- package-ecosystem: cargo
directory: /
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 5
ignore:
# Held at 11.10.0 to pin what rolldown_resolver ("^11") resolves to; it
# does not build against 11.11. See crates-cli/yaak-cli/Cargo.toml.
- dependency-name: oxc_resolver
groups:
cargo:
patterns:
- "*"
update-types:
- minor
- patch
@@ -1,79 +0,0 @@
// Retain only distributable bundle files, never the target tree or signing material.
import { createHash } from "node:crypto";
import {
copyFileSync,
createReadStream,
existsSync,
mkdirSync,
readdirSync,
writeFileSync,
} from "node:fs";
import { join, relative } from "node:path";
const env = process.env;
const bundle = join("target", env.RELEASE_TARGET || "", "release", "bundle");
const destination = join(env.RUNNER_TEMP, "yaak-release-artifacts");
const extensions =
/\.(dmg|deb|rpm|AppImage|exe|msi|app\.tar\.gz|AppImage\.tar\.gz|nsis\.zip|msi\.zip)(\.sig)?$/;
const files = [];
function snapshot(directory) {
if (!existsSync(directory)) return;
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const source = join(directory, entry.name);
// Only descend through the bundler's immediate output folders. This excludes
// .app trees and the extracted CEF deb contents (which contain executables).
if (entry.isDirectory() && directory === bundle) {
snapshot(source);
} else if (
entry.isFile() &&
(extensions.test(entry.name) || /^yaak-cef_.*_linux_.*\.tar\.gz$/.test(entry.name))
) {
// Match tauri-action's default macOS updater asset naming exactly.
const arch = env.RELEASE_ARCH === "arm64" ? "aarch64" : "x64";
const assetName = entry.name.replace(/\.app\.tar\.gz(\.sig)?$/, `_${arch}.app.tar.gz$1`);
mkdirSync(destination, { recursive: true });
const retained = join(destination, assetName);
// The second snapshot must not overwrite the original user installer/signature.
if (!existsSync(retained)) copyFileSync(source, retained);
files.push({ source: relative(".", source), assetName });
}
}
}
snapshot(bundle);
if (existsSync(destination)) {
const assets = readdirSync(destination)
.filter((name) => name !== "provenance.json")
.sort();
const checksums = [];
for (const assetName of assets) {
const hash = createHash("sha256");
for await (const chunk of createReadStream(join(destination, assetName))) hash.update(chunk);
checksums.push({ assetName, sha256: hash.digest("hex") });
}
writeFileSync(
join(destination, "provenance.json"),
JSON.stringify(
{
repository: env.GITHUB_REPOSITORY,
tag: env.GITHUB_REF_NAME,
sha: env.GITHUB_SHA,
runId: env.GITHUB_RUN_ID,
runAttempt: env.GITHUB_RUN_ATTEMPT,
workflowRef: env.GITHUB_WORKFLOW_REF,
workflowSha: env.GITHUB_WORKFLOW_SHA,
tauriAction: "tauri-apps/tauri-action@v0",
platform: env.RELEASE_PLATFORM,
runtime: env.RELEASE_RUNTIME,
arch: env.RELEASE_ARCH,
target: env.RELEASE_TARGET,
args: env.RELEASE_ARGS,
assets: checksums,
},
null,
2,
) + "\n",
);
}
console.log(`Found ${files.length} distributable bundle files in ${bundle}`);
-4
View File
@@ -31,7 +31,3 @@ jobs:
run: vp test
- name: Run Rust Tests
run: cargo test --all --features yaak-app-client/wry
- name: OpenAPI import round-trip
run: |
cargo build -p yaak-cli
node plugins/importer-openapi/tests/roundtrip.mjs
+5 -63
View File
@@ -70,7 +70,7 @@ jobs:
runtime: "wry"
targets: "aarch64-pc-windows-msvc"
runs-on: ${{ matrix.platform }}
timeout-minutes: 360
timeout-minutes: 60
steps:
- name: Checkout yaakapp/app
uses: actions/checkout@v4
@@ -103,18 +103,6 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install -y cmake ninja-build libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libnss3 patchelf xdg-utils
# crates/yaak-wasm compiles SQLite to wasm via sqlite-wasm-rs, whose C shim
# uses C23 [[noreturn]] and expects a freestanding wasm32 target. Ubuntu
# 22.04 ships only clang <=15: 14 rejects the attribute, and 15 falls
# through to host glibc headers ("bits/libc-header-start.h" not found).
# clang-18 handles it (it is what ubuntu-24.04 uses). Install it from
# apt.llvm.org since 22.04's repos stop at 15. Only the wasm build uses
# this compiler, so the shipped binary keeps 22.04's glibc floor.
wget -qO /tmp/llvm.sh https://apt.llvm.org/llvm.sh
chmod +x /tmp/llvm.sh
sudo /tmp/llvm.sh 18
echo "CC_wasm32_unknown_unknown=/usr/bin/clang-18" >> "$GITHUB_ENV"
echo "AR_wasm32_unknown_unknown=/usr/bin/llvm-ar-18" >> "$GITHUB_ENV"
- name: Install Protoc for plugin-runtime
uses: arduino/setup-protoc@v3
@@ -172,8 +160,7 @@ jobs:
codesign --force --options runtime --entitlements crates-tauri/yaak-app-client/macos/entitlements.yaakprotoc.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app-client/vendored/protoc/yaakprotoc || true
codesign --force --options runtime --entitlements crates-tauri/yaak-app-client/macos/entitlements.yaaknode.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app-client/vendored/node/yaaknode || true
- name: Build and upload Tauri bundles
uses: tauri-apps/tauri-action@v0
- uses: tauri-apps/tauri-action@v0
env:
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
@@ -195,8 +182,6 @@ jobs:
AZURE_CLIENT_SECRET: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_SECRET }}
AZURE_TENANT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_TENANT_ID }}
with:
# Also retries builds; upload retries alone cannot clean up starter assets.
retryAttempts: 3
tauriScript: "node ../../node_modules/@tauri-apps/cli/tauri.js"
tagName: "v__VERSION__"
releaseName: "Release __VERSION__"
@@ -206,17 +191,6 @@ jobs:
projectPath: ./crates-tauri/yaak-app-client
args: "${{ matrix.args }}"
# Snapshot before the per-machine build overwrites the regular NSIS bundle/signature.
- name: Snapshot Tauri bundles
if: ${{ !cancelled() }}
run: node .github/scripts/preserve-release-artifacts.mjs
env:
RELEASE_TARGET: ${{ matrix.targets }}
RELEASE_PLATFORM: ${{ matrix.platform }}
RELEASE_RUNTIME: ${{ matrix.runtime }}
RELEASE_ARCH: ${{ matrix.yaak_arch }}
RELEASE_ARGS: ${{ matrix.args }}
- name: Build and upload CEF tarball from deb (Linux only)
if: matrix.os == 'ubuntu' && matrix.runtime == 'cef'
env:
@@ -231,13 +205,7 @@ jobs:
mkdir -p "$extract_dir"
dpkg-deb -x "$deb" "$extract_dir"
tar -C "$extract_dir" -czf "$tarball" .
for attempt in 1 2 3 4; do
if gh release upload "$GITHUB_REF_NAME" "$tarball" --clobber; then
break
fi
if [ "$attempt" -eq 4 ]; then exit 1; fi
sleep 10
done
gh release upload "${{ github.ref_name }}" "$tarball" --clobber
# Build a per-machine NSIS installer for enterprise deployment (PDQ, SCCM, Intune)
- name: Build and upload machine-wide installer (Windows only)
@@ -255,7 +223,6 @@ jobs:
Get-ChildItem -Recurse -Path target -File -Filter "*.exe.sig" | Remove-Item -Force
Push-Location crates-tauri/yaak-app-client
npx tauri bundle ${{ matrix.args }} --bundles nsis --config '{"bundle":{"createUpdaterArtifacts":true,"windows":{"nsis":{"installMode":"perMachine"}}}}'
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Pop-Location
$setup = Get-ChildItem -Recurse -Path target -Filter "*setup*.exe" | Select-Object -First 1
$setupSig = "$($setup.FullName).sig"
@@ -263,30 +230,5 @@ jobs:
$destSig = "$dest.sig"
Copy-Item $setup.FullName $dest
Copy-Item $setupSig $destSig
foreach ($asset in @($dest, $destSig)) {
for ($attempt = 1; $attempt -le 4; $attempt++) {
gh release upload "$env:GITHUB_REF_NAME" "$asset" --clobber
if ($LASTEXITCODE -eq 0) { break }
if ($attempt -eq 4) { exit $LASTEXITCODE }
Start-Sleep -Seconds 10
}
}
- name: Snapshot additional release bundles
if: ${{ !cancelled() }}
run: node .github/scripts/preserve-release-artifacts.mjs
env:
RELEASE_TARGET: ${{ matrix.targets }}
RELEASE_PLATFORM: ${{ matrix.platform }}
RELEASE_RUNTIME: ${{ matrix.runtime }}
RELEASE_ARCH: ${{ matrix.yaak_arch }}
RELEASE_ARGS: ${{ matrix.args }}
- name: Retain release bundles for recovery
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: release-${{ github.ref_name }}-${{ matrix.platform }}-${{ matrix.runtime }}-${{ matrix.yaak_arch }}-${{ github.run_id }}-attempt-${{ github.run_attempt }}
path: ${{ runner.temp }}/yaak-release-artifacts/
retention-days: 7
if-no-files-found: warn
gh release upload "${{ github.ref_name }}" "$dest" --clobber
gh release upload "${{ github.ref_name }}" "$destSig" --clobber
-123
View File
@@ -1,123 +0,0 @@
name: Release web image
# Builds ghcr.io/mountain-loop/yaak-web: the browser client and the server that serves it.
# One image per architecture on its own native runner (emulating a Rust release build is hours),
# joined into one multi-arch tag at the end.
on:
push:
tags: [v*]
workflow_dispatch:
inputs:
version:
description: Version to publish, without the v (e.g. 2026.2.0). Empty publishes main and sha tags only.
required: false
type: string
permissions:
contents: read
packages: write
env:
IMAGE: ghcr.io/mountain-loop/yaak-web
jobs:
build:
if: github.repository == 'mountain-loop/yaak'
name: Build ${{ matrix.platform }}
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-22.04
arch: amd64
- platform: linux/arm64
runner: ubuntu-22.04-arm
arch: arm64
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.web
platforms: ${{ matrix.platform }}
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p "${{ runner.temp }}/digests"
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digest-${{ matrix.arch }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
publish:
name: Publish manifest
needs: build
runs-on: ubuntu-latest
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digest-*
merge-multiple: true
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# `latest` follows a release tag, and a manual run that names a version — the way to
# publish before the first release. A prerelease (v2026.2.1-beta.1) never takes it.
- name: Tags
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE }}
flavor: latest=false
tags: |
type=semver,pattern={{version}}
type=raw,value=${{ inputs.version }},enable=${{ inputs.version != '' }}
type=raw,value=latest,enable=${{ inputs.version != '' || (github.event_name == 'push' && !contains(github.ref_name, '-')) }}
type=ref,event=branch
type=sha,format=short
- name: Create and push the manifest
working-directory: ${{ runner.temp }}/digests
run: |
docker buildx imagetools create \
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.IMAGE }}@sha256:%s ' *)
- name: Inspect
run: docker buildx imagetools inspect ${{ env.IMAGE }}:${{ steps.meta.outputs.version }}
+3
View File
@@ -0,0 +1,3 @@
**/bindings/**
**/routeTree.gen.ts
crates/yaak-templates/pkg/**
+1 -2
View File
@@ -3,7 +3,6 @@
"ignorePatterns": [
"**/bindings/**",
"crates/yaak-templates/pkg/**",
"apps/yaak-client/routeTree.gen.ts",
"plugins/importer-bruno/tests/fixtures/upstream/**"
"apps/yaak-client/routeTree.gen.ts"
]
}
Generated
+1051 -1187
View File
File diff suppressed because it is too large. Load diff
+2 -18
View File
@@ -2,8 +2,6 @@
resolver = "2"
members = [
"crates/yaak",
"crates/yaak-assertions",
"crates/yaak-jsonpath",
"crates/yaak-commands",
# Common/foundation crates
"crates/common/yaak-database",
@@ -16,7 +14,6 @@ members = [
"crates/yaak-git",
"crates/yaak-grpc",
"crates/yaak-http",
"crates/yaak-lifecycle",
"crates/yaak-models",
"crates/yaak-plugins",
"crates/yaak-sse",
@@ -24,14 +21,11 @@ members = [
"crates/yaak-templates",
"crates/yaak-tls",
"crates/yaak-ws",
"crates/yaak-wasm",
"crates/yaak-web",
"crates/yaak-api",
"crates/yaak-proxy",
# Proxy-specific crates
"crates-proxy/yaak-proxy-lib",
# Server crates (the browser tier's hosted send executor)
"crates-server/yaak-web",
"crates-server/yaak-playground",
# CLI crates
"crates-cli/yaak-cli",
# Tauri-specific crates
@@ -50,14 +44,7 @@ chrono = "0.4.42"
hex = "0.4.3"
keyring = "3.6.3"
log = "0.4.29"
reqwest = { version = "0.13.5", default-features = false, features = [
# reqwest 0.13 switched `default-tls` from native-tls to rustls; keep the
# rest of the old default set and stay on native-tls.
"charset",
"http2",
"system-proxy",
"native-tls",
] }
reqwest = "0.12.20"
rustls = { version = "0.23.34", default-features = false }
rustls-platform-verifier = "0.6.2"
schemars = { version = "0.8.22", features = ["chrono"] }
@@ -84,15 +71,12 @@ yaak-rpc-schema = { path = "crates/common/yaak-rpc-schema" }
# Internal crates - shared
yaak-core = { path = "crates/yaak-core" }
yaak = { path = "crates/yaak" }
yaak-assertions = { path = "crates/yaak-assertions" }
yaak-jsonpath = { path = "crates/yaak-jsonpath" }
yaak-commands = { path = "crates/yaak-commands" }
yaak-common = { path = "crates/yaak-common" }
yaak-crypto = { path = "crates/yaak-crypto" }
yaak-git = { path = "crates/yaak-git" }
yaak-grpc = { path = "crates/yaak-grpc" }
yaak-http = { path = "crates/yaak-http" }
yaak-lifecycle = { path = "crates/yaak-lifecycle" }
yaak-models = { path = "crates/yaak-models" }
yaak-plugins = { path = "crates/yaak-plugins" }
yaak-sse = { path = "crates/yaak-sse" }
-15
View File
@@ -1,15 +0,0 @@
# syntax=docker/dockerfile:1
# The playground API behind yaak.run. See crates-server/yaak-playground/README.md.
FROM rust:1-bookworm AS build
WORKDIR /app
COPY . .
RUN cargo build --release -p yaak-playground
FROM debian:bookworm-slim
COPY --from=build /app/target/release/yaak-playground /usr/local/bin/yaak-playground
ENV HOST=0.0.0.0
ENV PORT=8080
EXPOSE 8080
USER nobody
CMD ["yaak-playground"]
-49
View File
@@ -1,49 +0,0 @@
# syntax=docker/dockerfile:1
# Yaak in a browser, whole: the web client and the server that executes its sends, in one
# image serving both from one origin.
#
# docker run -p 8080:8080 ghcr.io/mountain-loop/yaak-web
#
# See crates-server/yaak-web/README.md for the knobs.
FROM node:22-slim AS web
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
git python3 make g++ ca-certificates && rm -rf /var/lib/apt/lists/*
COPY . .
# `npm ci` runs a prepare hook (`vp config`) that shells out to git, and there is no .git in
# the build context — it is ignored, and in a worktree it is a pointer file anyway.
RUN git init -q && git add -A \
&& git -c user.email=build@yaak.app -c user.name=build commit -qm build
# Empty means the tab posts sends to its own origin, which is what this image serves. Set it
# only to build a bundle for a deployment whose server lives somewhere else.
ARG VITE_YAAK_WEB_URL=""
ENV VITE_YAAK_WEB_URL=$VITE_YAAK_WEB_URL
ENV YAAK_TARGET=web
# crates/yaak-wasm's wasm package is committed; rebuilding it needs a clang with a WebAssembly
# backend, which this image has no reason to carry.
ENV SKIP_WASM_BUILD=1
RUN npm ci
RUN node_modules/.bin/vp -C apps/yaak-client build
FROM rust:1-bookworm AS server
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config libssl-dev protobuf-compiler && rm -rf /var/lib/apt/lists/*
COPY . .
RUN cargo build --release -p yaak-web
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates libssl3 && rm -rf /var/lib/apt/lists/*
COPY --from=server /app/target/release/yaak-web /usr/local/bin/yaak-web
COPY --from=web /app/dist/apps/yaak-client /srv
# 0.0.0.0 so the port is reachable from outside the container. PORT is the variable a
# platform-as-a-service assigns, so a deploy that sets it needs nothing else changed.
ENV HOST=0.0.0.0
ENV PORT=8080
EXPOSE 8080
USER nobody
# Overriding the command (dropping --serve) leaves the stateless send executor:
# docker run ghcr.io/mountain-loop/yaak-web yaak-web
CMD ["yaak-web", "--serve", "/srv"]
+1 -11
View File
@@ -17,7 +17,7 @@
<br>
<p align="center">
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https:&#x2F;&#x2F;github.com&#x2F;MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a>&nbsp;&nbsp;<a href="https://github.com/dharsanb"><img src="https:&#x2F;&#x2F;github.com&#x2F;dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a>&nbsp;&nbsp;<a href="https://github.com/railwayapp"><img src="https:&#x2F;&#x2F;github.com&#x2F;railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a>&nbsp;&nbsp;<a href="https://github.com/caseyamcl"><img src="https:&#x2F;&#x2F;github.com&#x2F;caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a>&nbsp;&nbsp;<a href="https://github.com/bytebase"><img src="https:&#x2F;&#x2F;github.com&#x2F;bytebase.png" width="80px" alt="User avatar: bytebase" /></a>&nbsp;&nbsp;<a href="https://github.com/rauchg"><img src="https:&#x2F;&#x2F;github.com&#x2F;rauchg.png" width="80px" alt="User avatar: rauchg" /></a>&nbsp;&nbsp;<a href="https://github.com/"><img src="https:&#x2F;&#x2F;raw.githubusercontent.com&#x2F;JamesIves&#x2F;github-sponsors-readme-action&#x2F;dev&#x2F;.github&#x2F;assets&#x2F;placeholder.png" width="80px" alt="User avatar: " /></a>&nbsp;&nbsp;<!-- sponsors-premium -->
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https:&#x2F;&#x2F;github.com&#x2F;MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a>&nbsp;&nbsp;<a href="https://github.com/dharsanb"><img src="https:&#x2F;&#x2F;github.com&#x2F;dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a>&nbsp;&nbsp;<a href="https://github.com/railwayapp"><img src="https:&#x2F;&#x2F;github.com&#x2F;railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a>&nbsp;&nbsp;<a href="https://github.com/caseyamcl"><img src="https:&#x2F;&#x2F;github.com&#x2F;caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a>&nbsp;&nbsp;<a href="https://github.com/bytebase"><img src="https:&#x2F;&#x2F;github.com&#x2F;bytebase.png" width="80px" alt="User avatar: bytebase" /></a>&nbsp;&nbsp;<a href="https://github.com/"><img src="https:&#x2F;&#x2F;raw.githubusercontent.com&#x2F;JamesIves&#x2F;github-sponsors-readme-action&#x2F;dev&#x2F;.github&#x2F;assets&#x2F;placeholder.png" width="80px" alt="User avatar: " /></a>&nbsp;&nbsp;<!-- sponsors-premium -->
</p>
<p align="center">
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https:&#x2F;&#x2F;github.com&#x2F;seanwash.png" width="50px" alt="User avatar: seanwash" /></a>&nbsp;&nbsp;<a href="https://github.com/jerath"><img src="https:&#x2F;&#x2F;github.com&#x2F;jerath.png" width="50px" alt="User avatar: jerath" /></a>&nbsp;&nbsp;<a href="https://github.com/itsa-sh"><img src="https:&#x2F;&#x2F;github.com&#x2F;itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a>&nbsp;&nbsp;<a href="https://github.com/dmmulroy"><img src="https:&#x2F;&#x2F;github.com&#x2F;dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a>&nbsp;&nbsp;<a href="https://github.com/timcole"><img src="https:&#x2F;&#x2F;github.com&#x2F;timcole.png" width="50px" alt="User avatar: timcole" /></a>&nbsp;&nbsp;<a href="https://github.com/VLZH"><img src="https:&#x2F;&#x2F;github.com&#x2F;VLZH.png" width="50px" alt="User avatar: VLZH" /></a>&nbsp;&nbsp;<a href="https://github.com/terasaka2k"><img src="https:&#x2F;&#x2F;github.com&#x2F;terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a>&nbsp;&nbsp;<a href="https://github.com/andriyor"><img src="https:&#x2F;&#x2F;github.com&#x2F;andriyor.png" width="50px" alt="User avatar: andriyor" /></a>&nbsp;&nbsp;<a href="https://github.com/majudhu"><img src="https:&#x2F;&#x2F;github.com&#x2F;majudhu.png" width="50px" alt="User avatar: majudhu" /></a>&nbsp;&nbsp;<a href="https://github.com/axelrindle"><img src="https:&#x2F;&#x2F;github.com&#x2F;axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a>&nbsp;&nbsp;<a href="https://github.com/jirizverina"><img src="https:&#x2F;&#x2F;github.com&#x2F;jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a>&nbsp;&nbsp;<a href="https://github.com/chip-well"><img src="https:&#x2F;&#x2F;github.com&#x2F;chip-well.png" width="50px" alt="User avatar: chip-well" /></a>&nbsp;&nbsp;<a href="https://github.com/GRAYAH"><img src="https:&#x2F;&#x2F;github.com&#x2F;GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a>&nbsp;&nbsp;<a href="https://github.com/flashblaze"><img src="https:&#x2F;&#x2F;github.com&#x2F;flashblaze.png" width="50px" alt="User avatar: flashblaze" /></a>&nbsp;&nbsp;<a href="https://github.com/Frostist"><img src="https:&#x2F;&#x2F;github.com&#x2F;Frostist.png" width="50px" alt="User avatar: Frostist" /></a>&nbsp;&nbsp;<a href="https://github.com/PurplProto"><img src="https:&#x2F;&#x2F;github.com&#x2F;PurplProto.png" width="50px" alt="User avatar: PurplProto" /></a>&nbsp;&nbsp;<!-- sponsors-base -->
@@ -25,10 +25,6 @@
![Yaak API Client](https://yaak.app/static/screenshot.png)
## Install
Download Yaak for macOS, Windows, or Linux from [yaak.app/download](https://yaak.app/download). Package managers and other install methods are in the [installation docs](https://yaak.app/docs/getting-started/installation).
## Features
Yaak is an offline-first API client designed to stay out of your way while giving you everything you need when you need it.
@@ -72,9 +68,3 @@ Built with [Tauri](https://tauri.app), Rust, and React, it’s fast, lightweight
- [Yaak vs Postman](https://yaak.app/alternatives/postman)
- [Yaak vs Bruno](https://yaak.app/alternatives/bruno)
- [Yaak vs Insomnia](https://yaak.app/alternatives/insomnia)
## License
The source is [MIT licensed](LICENSE) and can be built and run for free, for both personal and commercial use.
The prebuilt binaries follow Yaak's [pricing terms](https://yaak.app/pricing): free for personal use, license required for commercial use.
+13 -22
View File
@@ -1,42 +1,33 @@
import { platform } from "@yaakapp-internal/platform";
import type { SettingsTab, SettingsTabWithSubtab } from "../components/Settings/Settings";
import type { SettingsTab } from "../components/Settings/Settings";
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
import { createFastMutation } from "../hooks/useFastMutation";
import { showDialog } from "../lib/dialog";
import { jotaiStore } from "../lib/jotai";
import { router } from "../lib/router";
import { rpc } from "../lib/rpc";
export const openSettings = createFastMutation<void, string, SettingsTabWithSubtab | null>({
// Allow tab with optional subtab (e.g., "plugins:installed")
type SettingsTabWithSubtab = SettingsTab | `${SettingsTab}:${string}` | null;
export const openSettings = createFastMutation<void, string, SettingsTabWithSubtab>({
mutationKey: ["open_settings"],
mutationFn: async (tab) => {
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
if (workspaceId == null) return;
const to = "/workspaces/$workspaceId/settings" as const;
const params = { workspaceId };
const search = { tab: (tab ?? undefined) as SettingsTab | undefined };
// Settings is its own window where the host has windows to give. Where it
// doesn't — a browser tab — it's a dialog like any other, so opening it
// doesn't take you away from the request you were working on.
// doesn't — a browser tab — the same route opens in place, which is the
// whole difference: it is already a route, not a separate app.
if (!platform.capabilities.multiWindow) {
// Imported here so Settings stays out of the startup bundle, the way the
// route that renders it on desktop already keeps it
const { default: Settings } = await import("../components/Settings/Settings");
showDialog({
id: "settings",
size: "md",
className: "h-[calc(100vh-5rem)] max-h-150! overflow-hidden",
noPadding: true,
noScroll: true,
// Keyed so opening a specific tab while the dialog is already up moves to it
render: ({ hide }) => <Settings key={tab ?? "general"} tab={tab} hide={hide} />,
});
await router.navigate({ to, params, search });
return;
}
const location = router.buildLocation({
to: "/workspaces/$workspaceId/settings",
params: { workspaceId },
search: { tab: (tab ?? undefined) as SettingsTab | undefined },
});
const location = router.buildLocation({ to, params, search });
await rpc("cmd_new_child_window", {
url: location.href,
@@ -1,12 +1,12 @@
import { gitClone } from "@yaakapp-internal/git";
import { Banner, VStack } from "@yaakapp-internal/ui";
import { useId, useState } from "react";
import { useState } from "react";
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
import { appInfo } from "../lib/appInfo";
import { CommercialUseBanner } from "./CommercialUseBanner";
import { showErrorToast } from "../lib/toast";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { DialogFooter } from "./core/Dialog";
import { IconButton } from "./core/IconButton";
import { PlainInput } from "./core/PlainInput";
import { promptCredentials } from "./git/credentials";
@@ -29,7 +29,6 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
const [subdirectory, setSubdirectory] = useState<string>("");
const [isCloning, setIsCloning] = useState(false);
const [error, setError] = useState<string | null>(null);
const formId = useId();
const repoName = extractRepoName(url);
const sep = getPathSeparator(baseDirectory);
@@ -84,7 +83,7 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
};
return (
<VStack as="form" id={formId} space={3} alignItems="start" onSubmit={handleClone}>
<VStack as="form" space={3} alignItems="start" className="pb-3" onSubmit={handleClone}>
{error && (
<Banner color="danger" className="w-full">
{error}
@@ -133,17 +132,15 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
/>
)}
<DialogFooter
actions={[
{
label: isCloning ? "Cloning..." : "Clone Repository",
color: "primary",
form: formId,
disabled: !url || !directory || isCloning,
isLoading: isCloning,
},
]}
/>
<Button
type="submit"
color="primary"
className="w-full mt-3"
disabled={!url || !directory || isCloning}
isLoading={isCloning}
>
{isCloning ? "Cloning..." : "Clone Repository"}
</Button>
</VStack>
);
}
@@ -1,10 +1,10 @@
import { createWorkspaceModel } from "@yaakapp-internal/models";
import { useId, useState } from "react";
import { useState } from "react";
import { useToggle } from "../hooks/useToggle";
import { ColorIndicator } from "./ColorIndicator";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { ColorPickerWithThemeColors } from "./core/ColorPicker";
import { DialogFooter } from "./core/Dialog";
import { Label } from "./core/Label";
import { PlainInput } from "./core/PlainInput";
@@ -18,11 +18,9 @@ export function CreateEnvironmentDialog({ workspaceId, hide, onCreate }: Props)
const [name, setName] = useState<string>("");
const [color, setColor] = useState<string | null>(null);
const [sharable, toggleSharable] = useToggle(false);
const formId = useId();
return (
<form
id={formId}
className="flex flex-col gap-3"
className="pb-3 flex flex-col gap-3"
onSubmit={async (e) => {
e.preventDefault();
const id = await createWorkspaceModel({
@@ -61,17 +59,10 @@ export function CreateEnvironmentDialog({ workspaceId, hide, onCreate }: Props)
</Label>
<ColorPickerWithThemeColors onChange={setColor} color={color} />
</div>
<DialogFooter
inline
actions={[
{
label: "Create Environment",
color: "secondary",
form: formId,
leftSlot: color != null ? <ColorIndicator color={color} /> : undefined,
},
]}
/>
<Button type="submit" color="secondary" className="mt-3">
{color != null && <ColorIndicator color={color} />}
Create Environment
</Button>
</form>
);
}
@@ -2,13 +2,13 @@ import { gitMutations } from "@yaakapp-internal/git";
import type { WorkspaceMeta } from "@yaakapp-internal/models";
import { createGlobalModel, updateModel } from "@yaakapp-internal/models";
import { VStack } from "@yaakapp-internal/ui";
import { useId, useState } from "react";
import { useState } from "react";
import { router } from "../lib/router";
import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
import { rpc } from "../lib/rpc";
import { showErrorToast } from "../lib/toast";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { DialogFooter } from "./core/Dialog";
import { Label } from "./core/Label";
import { PlainInput } from "./core/PlainInput";
import { EncryptionHelp } from "./EncryptionHelp";
@@ -26,13 +26,12 @@ export function CreateWorkspaceDialog({ hide }: Props) {
initGit?: boolean;
}>({ filePath: null, initGit: false });
const [setupEncryption, setSetupEncryption] = useState<boolean>(false);
const formId = useId();
return (
<VStack
as="form"
id={formId}
space={3}
alignItems="start"
className="pb-3"
onSubmit={async (e) => {
e.preventDefault();
const workspaceId = await createGlobalModel({ model: "workspace", name });
@@ -90,7 +89,9 @@ export function CreateWorkspaceDialog({ hide }: Props) {
title="Enable Encryption"
/>
</div>
<DialogFooter actions={[{ label: "Create Workspace", color: "primary", form: formId }]} />
<Button type="submit" color="primary" className="w-full mt-3">
Create Workspace
</Button>
</VStack>
);
}
@@ -67,14 +67,7 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo
)}
// If no environments, the button simply opens the dialog.
// NOTE: We don't create a new button because we want to reuse the hotkey from the menu items
onClick={
subEnvironments.length === 0
? (event) => {
event.preventDefault();
editEnvironment(null);
}
: undefined
}
onClick={subEnvironments.length === 0 ? () => editEnvironment(null) : undefined}
{...buttonProps}
>
<EnvironmentColorIndicator environment={activeEnvironment ?? null} />
@@ -1,6 +1,6 @@
import type { Workspace } from "@yaakapp-internal/models";
import { workspacesAtom } from "@yaakapp-internal/models";
import { VStack } from "@yaakapp-internal/ui";
import { HStack, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useCallback, useMemo, useState } from "react";
import slugify from "slugify";
@@ -8,9 +8,9 @@ import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
import { pluralizeCount } from "../lib/pluralize";
import { rpc } from "../lib/rpc";
import { CommercialUseBanner } from "./CommercialUseBanner";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { DetailsBanner } from "./core/DetailsBanner";
import { DialogFooter } from "./core/Dialog";
import { Link } from "./core/Link";
import { platform } from "@yaakapp-internal/platform";
@@ -65,30 +65,29 @@ function ExportDataDialogContent({
const ids = Object.keys(selectedWorkspaces).filter((k) => selectedWorkspaces[k]);
const workspace = ids.length === 1 ? workspaces.find((w) => w.id === ids[0]) : undefined;
const slug = workspace ? slugify(workspace.name, { lower: true }) : "workspaces";
const document = await rpc<string>("cmd_export_data", {
workspaceIds: ids,
includePrivateEnvironments: includePrivateEnvironments,
const exportPath = await platform.dialog.save({
title: "Export Data",
defaultPath: `yaak.${slug}.json`,
});
const savedTo = await platform.files.save(
`yaak.${slug}.json`,
new TextEncoder().encode(document),
[{ name: "JSON", extensions: ["json"] }],
);
if (savedTo == null) {
return; // Cancelled
if (exportPath == null) {
return;
}
await rpc("cmd_export_data", {
workspaceIds: ids,
exportPath,
includePrivateEnvironments: includePrivateEnvironments,
});
onHide();
onSuccess(savedTo);
onSuccess(exportPath);
}, [includePrivateEnvironments, onHide, onSuccess, selectedWorkspaces, workspaces]);
const allSelected = workspaces.every((w) => selectedWorkspaces[w.id]);
const numSelected = Object.values(selectedWorkspaces).filter(Boolean).length;
const noneSelected = numSelected === 0;
return (
<>
<VStack space={3} className="pb-4">
<div className="h-full w-full grid grid-rows-[minmax(0,1fr)_auto] overflow-hidden rounded-b-lg">
<VStack space={3} className="overflow-auto px-5 pb-6">
<CommercialUseBanner source="data-export" title="Exporting work data?" />
<table className="w-full mb-auto min-w-full max-w-full divide-y divide-surface-highlight">
@@ -141,22 +140,29 @@ function ExportDataDialogContent({
/>
</DetailsBanner>
</VStack>
<DialogFooter
leftSlot={
<footer className="px-5 grid grid-cols-[1fr_auto] items-center bg-surface py-3 border-t border-border-subtle">
<div>
<Link href="https://yaak.app/button/new" noUnderline className="text-text-subtlest">
Create Run Button
</Link>
}
actions={[
{ label: "Cancel", onClick: onHide },
{
label: `Export ${pluralizeCount("Workspace", numSelected, { omitSingle: true, noneWord: "Nothing" })}`,
color: "primary",
disabled: noneSelected,
onClick: () => handleExport(),
},
]}
/>
</>
</div>
<HStack space={2} justifyContent="end">
<Button size="sm" className="focus" variant="border" onClick={onHide}>
Cancel
</Button>
<Button
size="sm"
type="submit"
className="focus"
color="primary"
disabled={noneSelected}
onClick={() => handleExport()}
>
Export{" "}
{pluralizeCount("Workspace", numSelected, { omitSingle: true, noneWord: "Nothing" })}
</Button>
</HStack>
</footer>
</div>
);
}
@@ -1,4 +1,3 @@
import type { NavigateWorkspace } from "@yaakapp-internal/tauri-client";
import { activeRequestAtom } from "../hooks/useActiveRequest";
import { useSubscribeActiveWorkspaceId } from "../hooks/useActiveWorkspace";
import { useActiveWorkspaceChangedToast } from "../hooks/useActiveWorkspaceChangedToast";
@@ -6,13 +5,10 @@ import { useHotKey, useSubscribeHotKeys } from "../hooks/useHotKey";
import { useSubscribeHttpAuthentication } from "../hooks/useHttpAuthentication";
import { useSyncFontSizeSetting } from "../hooks/useSyncFontSizeSetting";
import { useSyncWorkspaceChildModels } from "../hooks/useSyncWorkspaceChildModels";
import { usePlatformEvent } from "../hooks/usePlatformEvent";
import { useSyncZoomSetting } from "../hooks/useSyncZoomSetting";
import { useSubscribeTemplateFunctions } from "../hooks/useTemplateFunctions";
import { fireAndForget } from "../lib/fireAndForget";
import { jotaiStore } from "../lib/jotai";
import { renameModelWithPrompt } from "../lib/renameModelWithPrompt";
import { router } from "../lib/router";
export function GlobalHooks() {
useSyncZoomSetting();
@@ -28,20 +24,6 @@ export function GlobalHooks() {
useActiveWorkspaceChangedToast();
useSubscribeHotKeys();
usePlatformEvent("show_home", () => {
fireAndForget(router.navigate({ to: "/", search: { home: true } }));
});
usePlatformEvent<NavigateWorkspace>("navigate_workspace", ({ workspaceId, environmentId }) => {
fireAndForget(
router.navigate({
to: "/workspaces/$workspaceId",
params: { workspaceId },
search: { environment_id: environmentId },
}),
);
});
useHotKey(
"request.rename",
async () => {
+62 -180
View File
@@ -1,8 +1,9 @@
import { linter } from "@codemirror/lint";
import type { EditorView } from "@codemirror/view";
import { jsoncLanguage } from "@shopify/lang-jsonc";
import { type GrpcRequest, patchModel } from "@yaakapp-internal/models";
import { Banner, FormattedError, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import type { GrpcRequest } from "@yaakapp-internal/models";
import { FormattedError, InlineCode, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import {
handleRefresh,
jsonCompletion,
@@ -10,20 +11,12 @@ import {
stateExtensions,
updateSchema,
} from "codemirror-json-schema";
import type { JSONSchema7 } from "json-schema";
import type { ReactNode } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ReflectResponseService } from "../hooks/useGrpc";
import { wasUpdatedExternally } from "../hooks/useRequestUpdateKey";
import { showAlert } from "../lib/alert";
import { showConfirm } from "../lib/confirm";
import { showDialog } from "../lib/dialog";
import type { JsonSchema } from "../lib/jsonSchemaExample";
import { buildExampleFromSchema } from "../lib/jsonSchemaExample";
import { pluralizeCount } from "../lib/pluralize";
import { queryClient } from "../lib/queryClient";
import { Button } from "./core/Button";
import { Dropdown } from "./core/Dropdown";
import type { EditorProps } from "./core/Editor/Editor";
import { Editor } from "./core/Editor/LazyEditor";
import { GrpcProtoSelectionDialog } from "./GrpcProtoSelectionDialog";
@@ -36,11 +29,6 @@ type Props = Pick<EditorProps, "heightMode" | "onChange" | "className" | "forceU
protoFiles: string[];
};
type MethodSchema =
| { type: "none" }
| { type: "schema"; schema: JsonSchema }
| { type: "error"; id: string; title: string; body: ReactNode; log: unknown[] };
export function GrpcEditor({
services,
reflectionError,
@@ -54,16 +42,21 @@ export function GrpcEditor({
setEditorView(h);
}, []);
// Find the schema for the selected service and method
const methodSchema = useMemo<MethodSchema>(() => {
if (services === null || request.service === null || request.method === null) {
return { type: "none" };
// Find the schema for the selected service and method and update the editor
useEffect(() => {
if (
editorView == null ||
services === null ||
request.service === null ||
request.method === null
) {
return;
}
const s = services.find((s) => s.name === request.service);
if (s == null) {
return {
type: "error",
console.log("Failed to find service", { service: request.service, services });
showAlert({
id: "grpc-find-service-error",
title: "Couldn't Find Service",
body: (
@@ -71,14 +64,14 @@ export function GrpcEditor({
Failed to find service <InlineCode>{request.service}</InlineCode> in schema
</>
),
log: ["Failed to find service", { service: request.service, services }],
};
});
return;
}
const schema = s.methods.find((m) => m.name === request.method)?.schema;
if (schema == null) {
return {
type: "error",
if (request.method != null && schema == null) {
console.log("Failed to find method", { method: request.method, methods: s?.methods });
showAlert({
id: "grpc-find-schema-error",
title: "Couldn't Find Method",
body: (
@@ -87,15 +80,18 @@ export function GrpcEditor({
<InlineCode>{request.service}</InlineCode> in schema
</>
),
log: ["Failed to find method", { method: request.method, methods: s.methods }],
};
});
return;
}
if (schema == null) {
return;
}
try {
return { type: "schema", schema: JSON.parse(schema) as JsonSchema };
updateSchema(editorView, JSON.parse(schema));
} catch (err) {
return {
type: "error",
showAlert({
id: "grpc-parse-schema-error",
title: "Failed to Parse Schema",
body: (
@@ -107,22 +103,9 @@ export function GrpcEditor({
<FormattedError>{String(err)}</FormattedError>
</VStack>
),
log: ["Failed to parse schema", err],
};
});
}
}, [services, request.method, request.service]);
useEffect(() => {
if (methodSchema.type !== "error") return;
console.log(...methodSchema.log);
showAlert({ id: methodSchema.id, title: methodSchema.title, body: methodSchema.body });
}, [methodSchema]);
// Update the editor whenever the schema changes
useEffect(() => {
if (editorView == null || methodSchema.type !== "schema") return;
updateSchema(editorView, methodSchema.schema as JSONSchema7);
}, [editorView, methodSchema]);
}, [editorView, services, request.method, request.service]);
const extraExtensions = useMemo(
() => [
@@ -141,146 +124,45 @@ export function GrpcEditor({
const reflectionUnavailable = reflectionError?.match(/unimplemented/i);
reflectionError = reflectionUnavailable ? undefined : reflectionError;
const handleGenerateExample = useCallback(async () => {
if (methodSchema.type !== "schema") return;
if (request.message.trim() !== "") {
const confirmed = await showConfirm({
id: "grpc-generate-example",
title: "Generate Example",
description: "The current message will be replaced with an example.",
confirmText: "Generate",
});
if (!confirmed) return;
}
const message = JSON.stringify(buildExampleFromSchema(methodSchema.schema), null, 2);
await patchModel(request, { message });
// Force the editor to pick up the new message
wasUpdatedExternally(request.id);
}, [methodSchema, request]);
// The reflect query is keyed by request, url and proto files, so a prefix invalidate
// reaches it without threading a refetch down from the connection layout.
const handleReloadSchema = useCallback(
() => queryClient.invalidateQueries({ queryKey: ["grpc_reflect", request.id] }),
[request.id],
);
const handleShowReflectionError = useCallback(() => {
showDialog({
id: "grpc-reflection-error",
title: "Reflection Failed",
size: "sm",
render: ({ hide }) => (
<>
<FormattedError>{reflectionError ?? "unknown"}</FormattedError>
<div className="w-full my-4">
<Button
className="ml-auto"
color="primary"
size="sm"
onClick={async () => {
hide();
await handleReloadSchema();
}}
>
Retry
</Button>
</div>
</>
),
});
}, [handleReloadSchema, reflectionError]);
const actions = useMemo(
() => [
// Matches the GraphQL editor: one always-visible control labelled by schema state,
// with everything schema-related behind it.
<div key="schema" className="opacity-100!">
<Dropdown
items={[
{
// Hidden for servers without reflection, which isn't an error
hidden: !reflectionError,
type: "content",
label: (
<Banner color="danger">
<p className="mb-1">Reflection failed</p>
<Button
size="xs"
color="danger"
variant="border"
onClick={handleShowReflectionError}
>
View Error
</Button>
</Banner>
),
},
{
label: "Generate Example Message",
leftSlot: <Icon icon="magic_wand" />,
disabled: methodSchema.type !== "schema",
onSelect: handleGenerateExample,
},
{ type: "separator" },
{
label: "Reload Schema",
leftSlot: <Icon icon="refresh" spin={reflectionLoading} />,
keepOpenOnSelect: true,
onSelect: handleReloadSchema,
},
{
label: protoFiles.length > 0 ? "Select Proto Files\u2026" : "Configure Schema\u2026",
leftSlot: <Icon icon="settings" />,
onSelect: () => {
showDialog({
title: "Configure Schema",
size: "md",
className: "h-[30rem]",
id: "grpc-configure-schema",
render: ({ hide }) => <GrpcProtoSelectionDialog onDone={hide} />,
});
},
},
]}
>
<Button
size="sm"
variant="border"
title="Schema"
forDropdown
isLoading={reflectionLoading}
color={reflectionUnavailable ? "info" : reflectionError ? "danger" : "default"}
>
{reflectionLoading
? "Inspecting Schema"
<div key="reflection" className={classNames(services == null && "opacity-100!")}>
<Button
size="xs"
color={
reflectionLoading
? "secondary"
: reflectionUnavailable
? "Select Proto Files"
? "info"
: reflectionError
? "Server Error"
: protoFiles.length > 0
? pluralizeCount("File", protoFiles.length)
: services != null
? "Schema Detected"
: "Select Schema"}
</Button>
</Dropdown>
? "danger"
: "secondary"
}
isLoading={reflectionLoading}
onClick={() => {
showDialog({
title: "Configure Schema",
size: "md",
id: "reflection-failed",
render: ({ hide }) => <GrpcProtoSelectionDialog onDone={hide} />,
});
}}
>
{reflectionLoading
? "Inspecting Schema"
: reflectionUnavailable
? "Select Proto Files"
: reflectionError
? "Server Error"
: protoFiles.length > 0
? pluralizeCount("File", protoFiles.length)
: services != null && protoFiles.length === 0
? "Schema Detected"
: "Select Schema"}
</Button>
</div>,
],
[
handleGenerateExample,
handleReloadSchema,
handleShowReflectionError,
methodSchema.type,
protoFiles.length,
reflectionError,
reflectionLoading,
reflectionUnavailable,
services,
],
[protoFiles.length, reflectionError, reflectionLoading, reflectionUnavailable, services],
);
return (
@@ -1,11 +1,11 @@
import type { GrpcRequest } from "@yaakapp-internal/models";
import { Banner, InlineCode, VStack } from "@yaakapp-internal/ui";
import { Banner, HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import { useActiveRequest } from "../hooks/useActiveRequest";
import { useGrpc } from "../hooks/useGrpc";
import { useGrpcProtoFiles } from "../hooks/useGrpcProtoFiles";
import { pluralizeCount } from "../lib/pluralize";
import { DialogFooter } from "./core/Dialog";
import { PathList } from "./core/PathList";
import { Button } from "./core/Button";
import { IconButton } from "./core/IconButton";
import { Link } from "./core/Link";
import { platform } from "@yaakapp-internal/platform";
@@ -20,10 +20,7 @@ export function GrpcProtoSelectionDialog(props: Props) {
return <GrpcProtoSelectionDialogWithRequest request={request} {...props} />;
}
function GrpcProtoSelectionDialogWithRequest({
request,
onDone,
}: Props & { request: GrpcRequest }) {
function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: GrpcRequest }) {
const protoFilesKv = useGrpcProtoFiles(request.id);
const protoFiles = protoFilesKv.value ?? [];
const grpc = useGrpc(request, null, protoFiles);
@@ -41,8 +38,54 @@ function GrpcProtoSelectionDialogWithRequest({
}
return (
<>
<VStack space={5} className="pb-4">
<VStack className="flex-col-reverse mb-3" space={3}>
{/* Buttons on top so they get focus first */}
<HStack space={2} justifyContent="start" className="flex-row-reverse mt-3">
<Button
color="primary"
variant="border"
onClick={async () => {
const selected = await platform.dialog.open({
title: "Select Proto Files",
multiple: true,
filters: [{ name: "Proto Files", extensions: ["proto"] }],
});
if (selected == null) return;
const newFiles = selected.filter((p) => !protoFiles.includes(p));
await protoFilesKv.set([...protoFiles, ...newFiles]);
await grpc.reflect.refetch();
}}
>
Add Proto Files
</Button>
<Button
variant="border"
color="primary"
onClick={async () => {
const selected = await platform.dialog.open({
title: "Select Proto Directory",
directory: true,
});
if (selected == null) return;
await protoFilesKv.set([...protoFiles.filter((f) => f !== selected), selected]);
await grpc.reflect.refetch();
}}
>
Add Import Folders
</Button>
<Button
isLoading={grpc.reflect.isFetching}
disabled={grpc.reflect.isFetching}
variant="border"
color="secondary"
onClick={() => grpc.reflect.refetch()}
>
Refresh Schema
</Button>
</HStack>
<VStack space={5}>
{reflectError && (
<Banner color="warning">
<h1 className="font-bold">
@@ -85,47 +128,46 @@ function GrpcProtoSelectionDialogWithRequest({
</Banner>
)}
<PathList
items={protoFiles.map((path) => ({
path,
icon: path.endsWith(".proto") ? "file_code" : "folder_code",
}))}
onRemove={async (path) => {
await protoFilesKv.set(protoFiles.filter((p) => p !== path));
await grpc.reflect.refetch();
}}
addLabel="Add proto files or an import folder"
addItems={[
{
label: "Proto files",
icon: "file_code",
onSelect: async () => {
const selected = await platform.dialog.open({
title: "Select Proto Files",
multiple: true,
filters: [{ name: "Proto Files", extensions: ["proto"] }],
});
if (selected == null) return;
const newFiles = selected.filter((p) => !protoFiles.includes(p));
await protoFilesKv.set([...protoFiles, ...newFiles]);
await grpc.reflect.refetch();
},
},
{
label: "Import folder",
icon: "folder_code",
onSelect: async () => {
const selected = await platform.dialog.open({
title: "Select Proto Directory",
directory: true,
});
if (selected == null) return;
await protoFilesKv.set([...protoFiles.filter((f) => f !== selected), selected]);
await grpc.reflect.refetch();
},
},
]}
/>
{protoFiles.length > 0 && (
<table className="w-full divide-y divide-surface-highlight">
<thead>
<tr>
<th className="text-text-subtlest" colSpan={3}>
Added File Paths
</th>
</tr>
</thead>
<tbody className="divide-y divide-surface-highlight">
{protoFiles.map((f, i) => {
const parts = f.split("/");
// oxlint-disable-next-line no-array-index-key -- none
return (
<tr key={f + i} className="group">
<td>
<Icon icon={f.endsWith(".proto") ? "file_code" : "folder_code"} />
</td>
<td className="pl-1 font-mono text-sm" title={f}>
{parts.length > 3 && ".../"}
{parts.slice(-3).join("/")}
</td>
<td className="w-0 py-0.5">
<IconButton
title="Remove file"
variant="border"
size="xs"
icon="trash"
className="my-0.5 ml-auto opacity-50 transition-opacity group-hover:opacity-100"
onClick={async () => {
await protoFilesKv.set(protoFiles.filter((p) => p !== f));
}}
/>
</td>
</tr>
);
})}
</tbody>
</table>
)}
{reflectionUnimplemented && protoFiles.length === 0 && (
<Banner>
<InlineCode>{request.url}</InlineCode> doesn&apos;t implement{" "}
@@ -136,17 +178,6 @@ function GrpcProtoSelectionDialogWithRequest({
</Banner>
)}
</VStack>
<DialogFooter
actions={[
{
label: "Refresh Schema",
isLoading: grpc.reflect.isFetching,
disabled: grpc.reflect.isFetching,
onClick: () => grpc.reflect.refetch(),
},
{ label: "Done", color: "primary", onClick: onDone },
]}
/>
</>
</VStack>
);
}
@@ -133,8 +133,6 @@ export function GrpcRequestPane({
onSend({ message: activeRequest.message });
}, [activeRequest, onSend]);
const hasDescription = activeRequest.description.trim().length > 0;
const tabs: TabItem[] = useMemo(
() => [
{ value: TAB_MESSAGE, label: "Message" },
@@ -148,10 +146,10 @@ export function GrpcRequestPane({
{
value: TAB_DESCRIPTION,
label: "Info",
rightSlot: hasDescription && <CountBadge count={true} />,
rightSlot: activeRequest.description && <CountBadge count={true} />,
},
],
[authTab, hasDescription, metadataTab, numSettingsOverrides],
[activeRequest.description, authTab, metadataTab, numSettingsOverrides],
);
const handleMetadataChange = useCallback(
@@ -196,6 +194,13 @@ export function GrpcRequestPane({
type: "default",
shortLabel: o.label,
}))}
itemsAfter={[
{
label: "Refresh",
type: "default",
leftSlot: <Icon size="sm" icon="refresh" />,
},
]}
>
<Button
size="sm"
@@ -1,79 +0,0 @@
import deepEqual from "@gilbarbara/deep-equal";
import { useMutation } from "@tanstack/react-query";
import type { AssertionReport, HttpAssertions, HttpResponse } from "@yaakapp-internal/models";
import { format } from "date-fns";
import { rpc } from "../lib/rpc";
import { Button } from "./core/Button";
import { HttpAssertionReport } from "./HttpAssertionResults";
interface Props {
assertions: HttpAssertions;
response: HttpResponse | null;
}
export function HttpAssertionPreview(props: Props) {
// A response switch (including a new send) discards results and in-flight UI
// state. The old evaluation may finish, but it cannot label the new response.
return <ResponsePreview key={`${props.response?.id}:${props.response?.updatedAt}`} {...props} />;
}
function ResponsePreview({ assertions, response }: Props) {
const preview = useMutation({
mutationFn: (definition: HttpAssertions) =>
rpc<AssertionReport>("cmd_preview_http_assertions", {
responseId: response?.id,
assertions: definition,
}),
retry: false,
gcTime: 0,
});
const unavailable = !response
? "Send a request first to get a response to try."
: response.state !== "closed"
? "Wait for the response to finish."
: !assertions.checks.some((check) => check.enabled)
? "Enable an assertion to try it."
: null;
const changed = preview.data != null && !deepEqual(preview.data.definition, assertions);
return (
<div className="space-y-2">
<Button
size="sm"
variant="border"
disabled={unavailable != null || preview.isPending}
onClick={() => preview.mutate(assertions)}
>
{preview.isPending ? "Trying assertions…" : "Try assertions"}
</Button>
<p className="text-xs text-text-subtle">
{unavailable ?? "Preview against the selected response without sending again."}
</p>
{preview.isError && (
<p className="text-sm text-danger" role="alert">
Could not preview assertions:{" "}
{preview.error instanceof Error ? preview.error.message : String(preview.error)}
</p>
)}
{preview.data && response && (
<section
aria-label="Assertion preview"
aria-live="polite"
className="border border-border-subtle rounded-md p-3 space-y-2"
>
<p className="text-sm font-medium">Preview</p>
<p className="text-xs text-text-subtle">
Response from {format(`${response.createdAt}Z`, "MMM d, h:mm:ss a")}. Recorded results
are unchanged.
</p>
{changed && (
<p className="text-xs text-warning" role="status">
Assertions changed. Try again to update this preview.
</p>
)}
<HttpAssertionReport report={preview.data} />
</section>
)}
</div>
);
}
@@ -1,81 +0,0 @@
import type { AssertionReport, HttpResponse } from "@yaakapp-internal/models";
import classNames from "classnames";
import { EmptyStateText } from "./EmptyStateText";
export function HttpAssertionResults({ response }: { response: HttpResponse }) {
const report = response.assertionResults;
if (!report)
return <EmptyStateText>Assertions were not evaluated for this response.</EmptyStateText>;
if (!report.error && report.results.length === 0)
return <EmptyStateText>Waiting for the response to finish.</EmptyStateText>;
return (
<div className="h-full overflow-auto space-y-2 pb-3">
<p className="text-xs text-text-subtle">Checks saved with this response.</p>
<HttpAssertionReport report={report} />
</div>
);
}
export function HttpAssertionReport({ report }: { report: AssertionReport }) {
const passed = report.results.filter((r) => r.outcome === "passed").length;
const failed = report.results.filter((r) => r.outcome === "failed").length;
const errors = report.results.filter(
(r) => r.outcome === "error" || r.outcome === "invalid",
).length;
const skipped = report.results.filter((r) => r.outcome === "skipped").length;
return (
<div className="space-y-2">
<p className="text-sm">
{passed} passed · {failed} failed · {errors} errors · {skipped} skipped
</p>
{report.error && <p className="text-sm text-danger">{report.error}</p>}
{report.results.map((result, index) => {
const check = report.definition.checks[index];
return (
<div
key={`${result.assertionId}:${index}`}
className="border border-border-subtle rounded-md p-2 text-sm space-y-1"
>
<div className="flex items-start justify-between gap-2">
<span className="font-mono break-all">
{check?.target === "status" ? "Status" : check?.selector}
</span>
<span
className={classNames(
"capitalize shrink-0",
result.outcome === "passed"
? "text-success"
: result.outcome === "skipped"
? "text-text-subtle"
: "text-danger",
)}
>
{result.outcome}
</span>
</div>
{check && (
<p className="text-xs break-all">
{check.operator.replaceAll("_", " ")}
{check.operator !== "exists" && (
<>
{" "}
{check.expectedType === "null"
? "null"
: check.expectedType === "text"
? JSON.stringify(check.expected)
: check.expected}{" "}
({check.expectedType})
</>
)}
</p>
)}
<p className="text-xs text-text-subtle">{result.reason}</p>
{result.actual != null && (
<p className="font-mono text-xs break-all">Actual: {JSON.stringify(result.actual)}</p>
)}
</div>
);
})}
</div>
);
}
@@ -1,216 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import type { HttpAssertion, HttpAssertions, HttpRequest } from "@yaakapp-internal/models";
import { patchModelDebounced } from "@yaakapp-internal/models";
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
import { JsonPathInput } from "./JsonPathInput";
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
import { useStateWithDeps } from "../hooks/useStateWithDeps";
import { generateId } from "../lib/generateId";
import { rpc } from "../lib/rpc";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { IconButton } from "./core/IconButton";
import { PlainInput } from "./core/PlainInput";
import { Select } from "./core/Select";
import { HttpAssertionPreview } from "./HttpAssertionPreview";
const EMPTY: HttpAssertions = { version: 1, checks: [] };
const operators = [
{ value: "equals", label: "Equals" },
{ value: "not_equals", label: "Does not equal" },
{ value: "greater_than", label: "Greater than" },
{ value: "less_than", label: "Less than" },
{ value: "contains", label: "Contains" },
{ value: "exists", label: "Exists" },
];
export function HttpAssertionsEditor({ request }: { request: HttpRequest }) {
const updateKey = useRequestUpdateKey(request.id);
const { activeResponse } = usePinnedHttpResponse(request.id);
const [definition, setDefinition] = useStateWithDeps(request.assertions ?? EMPTY, [updateKey]);
const validation = useQuery({
queryKey: ["validate_assertions", definition],
queryFn: () =>
rpc<Record<string, string>>("cmd_validate_http_assertions", { assertions: definition }),
staleTime: Infinity,
gcTime: 0,
retry: false,
});
const save = (checks: HttpAssertion[]) => {
const next = { ...definition, checks };
setDefinition(next);
patchModelDebounced(request, { assertions: next });
};
const update = (id: string, patch: Partial<HttpAssertion>) =>
save(definition.checks.map((c) => (c.id === id ? { ...c, ...patch } : c)));
return (
<div className="@container h-full overflow-auto pb-3 space-y-3">
<p className="text-sm text-text-subtle">
Check the response each time you send. Results appear in the response’s Assertions tab.
</p>
{definition.checks.map((check, index) => (
<div key={check.id} className="border border-border-subtle rounded-md p-2 space-y-2">
<div className="flex items-center gap-2">
<Checkbox
title={`Assertion ${index + 1}`}
checked={check.enabled}
onChange={(enabled) => update(check.id, { enabled })}
/>
<IconButton
icon="trash"
size="xs"
title={`Delete assertion ${index + 1}`}
onClick={() => save(definition.checks.filter((c) => c.id !== check.id))}
/>
</div>
<div className="grid grid-cols-[minmax(0,1fr)] @[28rem]:grid-cols-[8rem_minmax(0,1fr)] gap-2">
<Select
name={`target-${check.id}`}
label="Response"
size="sm"
value={check.target}
options={[
{ value: "json", label: "JSON body" },
{ value: "status", label: "Status" },
{ value: "header", label: "Header" },
]}
onChange={(target) =>
update(check.id, {
target,
selector: "",
operator: "equals",
expected: target === "status" ? "200" : "",
expectedType: target === "status" ? "number" : "text",
})
}
/>
{check.target === "json" && (
<JsonPathInput
response={activeResponse}
stateKey={`assertion-path:${request.id}:${check.id}`}
defaultValue={check.selector}
forceUpdateKey={updateKey}
onChange={(selector) => update(check.id, { selector })}
/>
)}
{check.target === "header" && (
<PlainInput
label="Header name"
size="sm"
defaultValue={check.selector}
forceUpdateKey={`${updateKey}:${check.target}`}
placeholder="Content-Type"
onChange={(selector) => update(check.id, { selector })}
/>
)}
</div>
<div className="grid grid-cols-[minmax(0,1fr)] @[28rem]:grid-cols-[8rem_minmax(0,1fr)] gap-2">
<Select
name={`operator-${check.id}`}
label="Comparison"
size="sm"
value={check.operator}
options={operators.filter(
(o) =>
check.target === "json" ||
(check.target === "status"
? o.value !== "contains" && o.value !== "exists"
: !["greater_than", "less_than"].includes(o.value)),
)}
onChange={(operator) =>
update(check.id, {
operator,
...(["greater_than", "less_than"].includes(operator)
? { expectedType: "number" }
: operator === "contains"
? { expectedType: "text" }
: {}),
})
}
/>
{check.operator !== "exists" && (
<div className="flex items-start gap-2 min-w-0">
{check.expectedType !== "null" && (
<div className="flex-1 min-w-0">
<PlainInput
label="Expected value"
size="sm"
defaultValue={check.expected}
forceUpdateKey={`${updateKey}:${check.target}`}
placeholder={check.expectedType === "boolean" ? "true or false" : "Value"}
onChange={(expected) => update(check.id, { expected })}
/>
</div>
)}
{check.target === "json" &&
!["greater_than", "less_than", "contains"].includes(check.operator) && (
<div className="w-24 shrink-0">
<Select
name={`type-${check.id}`}
label="Type"
size="sm"
value={check.expectedType}
options={[
{ value: "text", label: "Text" },
{ value: "number", label: "Number" },
{ value: "boolean", label: "Boolean" },
{ value: "null", label: "Null" },
]}
onChange={(expectedType) => update(check.id, { expectedType })}
/>
</div>
)}
</div>
)}
</div>
{validation.data?.[check.id] && (
<p className="text-xs text-danger" role="status">
{validation.data[check.id]}
</p>
)}
</div>
))}
{validation.data?.[""] && (
<p className="text-sm text-danger" role="status">
{validation.data[""]}
</p>
)}
{validation.isError && (
<p className="text-sm text-danger" role="status">
Unable to validate assertions.
</p>
)}
<Button
size="sm"
variant="border"
disabled={definition.version !== 1 || definition.checks.length >= 100}
onClick={() =>
save([
...definition.checks,
{
id: generateId(),
enabled: true,
target: "json",
selector: "",
operator: "equals",
expected: "",
expectedType: "text",
},
])
}
>
Add assertion
</Button>
{definition.checks.some((c) => c.target === "json") && (
<p className="text-xs text-text-subtle">
Use a JSONPath such as $.user.id or user.id. Comparisons select one value; Exists also
accepts multiple matches. Suggestions use the selected response, one level at a time.
Values are literal and types must match.
</p>
)}
<HttpAssertionPreview assertions={definition} response={activeResponse} />
</div>
);
}
@@ -42,12 +42,11 @@ import { Editor } from "./core/Editor/LazyEditor";
import { InlineCode } from "@yaakapp-internal/ui";
import { PlainInput } from "./core/PlainInput";
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
import { TabContent, Tabs } 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 { HttpAssertionsEditor } from "./HttpAssertionsEditor";
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
import { JsonBodyEditor } from "./JsonBodyEditor";
import { MarkdownEditor } from "./MarkdownEditor";
@@ -73,7 +72,6 @@ const TAB_HEADERS = "headers";
const TAB_AUTH = "auth";
const TAB_SETTINGS = "settings";
const TAB_DESCRIPTION = "description";
const TAB_ASSERTIONS = "assertions";
const TABS_STORAGE_KEY = "http_request_tabs";
// Derived from the identity-stable URL list so this only recomputes when a URL
@@ -167,13 +165,10 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
: 0;
}
const hasDescription = activeRequest.description.trim().length > 0;
const tabs = useMemo<TabItem[]>(
() => [
{
value: TAB_BODY,
menuLabel: "Body",
rightSlot: numParams > 0 ? <CountBadge count={numParams} /> : null,
options: {
value: activeRequest.bodyType,
@@ -265,17 +260,6 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
{
value: TAB_DESCRIPTION,
label: "Info",
rightSlot: hasDescription && <CountBadge count={true} />,
},
{
value: TAB_ASSERTIONS,
label: "Assertions",
rightSlot: (
<CountBadge
count={activeRequest.assertions?.checks.filter((c) => c.enabled).length ?? 0}
/>
),
hiddenByDefault: !activeRequest.assertions?.checks.length,
},
],
[
@@ -283,7 +267,6 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
authTab,
contentType,
handleContentTypeChange,
hasDescription,
headersTab,
numParams,
numSettingsOverrides,
@@ -323,7 +306,11 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
e.preventDefault(); // Prevent input onChange
await patchModel(activeRequest, patch);
tabsRef.current?.setActiveTab(TAB_PARAMS);
await setActiveTab({
storageKey: TABS_STORAGE_KEY,
activeTabKey: activeRequestId,
value: TAB_PARAMS,
});
// Wait for request to update, then refresh the UI
// TODO: Somehow make this deterministic
@@ -377,12 +364,8 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
tabs={tabs}
tabListClassName="mt-1 -mb-1.5"
storageKey={TABS_STORAGE_KEY}
visibilityStorageKey={[TABS_STORAGE_KEY, "visibility", activeRequest.workspaceId]}
activeTabKey={activeRequestId}
>
<TabContent value={TAB_ASSERTIONS}>
<HttpAssertionsEditor key={activeRequest.id} request={activeRequest} />
</TabContent>
<TabContent value={TAB_AUTH}>
<HttpAuthenticationEditor model={activeRequest} />
</TabContent>
@@ -2,7 +2,7 @@ import type { HttpResponse, HttpResponseEvent } from "@yaakapp-internal/models";
import { Banner, HStack, Icon, LoadingIcon, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { ComponentType, CSSProperties } from "react";
import { lazy, Suspense, useMemo, useRef } from "react";
import { lazy, Suspense, useMemo } from "react";
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
import { useCopyHttpResponse } from "../hooks/useCopyHttpResponse";
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
@@ -23,12 +23,11 @@ import { HttpResponseDurationTag } from "./core/HttpResponseDurationTag";
import { HttpStatusTag } from "./core/HttpStatusTag";
import { PillButton } from "./core/PillButton";
import { SizeTag } from "./core/SizeTag";
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
import type { TabItem } from "./core/Tabs/Tabs";
import { TabContent, Tabs } from "./core/Tabs/Tabs";
import { Tooltip } from "./core/Tooltip";
import { EmptyStateText } from "./EmptyStateText";
import { ErrorBoundary } from "./ErrorBoundary";
import { HttpAssertionResults } from "./HttpAssertionResults";
import { HttpResponseTimeline } from "./HttpResponseTimeline";
import { RecentHttpResponsesDropdown } from "./RecentHttpResponsesDropdown";
import { RequestBodyViewer } from "./RequestBodyViewer";
@@ -57,7 +56,6 @@ const TAB_BODY = "body";
const TAB_REQUEST = "request";
const TAB_HEADERS = "headers";
const TAB_COOKIES = "cookies";
const TAB_ASSERTIONS = "assertions";
const TAB_TIMELINE = "timeline";
export type TimelineViewMode = "timeline" | "text";
@@ -69,7 +67,6 @@ interface RedirectDropWarning {
export function HttpResponsePane({ style, className, activeRequestId }: Props) {
const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId);
const tabsRef = useRef<TabsRef>(null);
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
const [timelineViewMode, setTimelineViewMode] = useTimelineViewMode();
const contentType = getContentTypeFromHeaders(activeResponse?.headers ?? null);
@@ -86,53 +83,9 @@ 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 assertionReport = activeResponse?.assertionResults;
// Native sends save the definition before evaluation. Wait for actual outcomes,
// and leave the tab hidden when every check was disabled.
const hasAssertionResults =
activeResponse?.state === "closed" &&
assertionReport != null &&
(assertionReport.error != null ||
assertionReport.results.some((result) => result.outcome !== "skipped"));
const failedAssertions =
assertionReport?.results.filter((result) => result.outcome === "failed").length ?? 0;
const assertionErrors =
assertionReport?.results.filter(
(result) => result.outcome === "error" || result.outcome === "invalid",
).length ?? 0;
const assertionFailures = failedAssertions + assertionErrors;
const assertionFailureLabel = [
failedAssertions > 0
? `${failedAssertions} assertion${failedAssertions === 1 ? "" : "s"} failed`
: null,
assertionErrors > 0
? `${assertionErrors} assertion error${assertionErrors === 1 ? "" : "s"}`
: assertionReport?.error != null
? "Assertion error"
: null,
]
.filter(Boolean)
.join(" · ");
const assertionPasses =
assertionReport?.results.filter((result) => result.outcome === "passed").length ?? 0;
const assertionsHaveErrors = assertionFailures > 0 || assertionReport?.error != null;
const tabs = useMemo<TabItem[]>(
() => [
...(hasAssertionResults
? [
{
value: TAB_ASSERTIONS,
label: "Assertions",
rightSlot: (
<CountBadge
count={assertionsHaveErrors ? assertionFailures || true : assertionPasses}
color={assertionsHaveErrors ? "danger" : "success"}
/>
),
},
]
: []),
{
value: TAB_BODY,
label: "Response",
@@ -209,10 +162,6 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
activeResponse?.requestHeaders.length,
activeResponse?.state,
activeResponse?.status,
hasAssertionResults,
assertionFailures,
assertionPasses,
assertionsHaveErrors,
cookieCounts.sent,
cookieCounts.received,
copyResponse.mutate,
@@ -261,17 +210,6 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
<HStack space={2} className="w-full shrink-0">
{activeResponse.state !== "closed" && <LoadingIcon size="sm" />}
<HttpStatusTag showReason response={activeResponse} />
{hasAssertionResults && assertionsHaveErrors && (
<PillButton
color="danger"
className="font-sans"
title="View assertion results"
leftSlot={<Icon icon="alert_triangle" size="xs" />}
onClick={() => tabsRef.current?.setActiveTab(TAB_ASSERTIONS)}
>
{assertionFailureLabel}
</PillButton>
)}
<span>&bull;</span>
<HttpResponseDurationTag response={activeResponse} />
<span>&bull;</span>
@@ -344,18 +282,13 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
)}
{/* Show tabs if we have any data (headers, body, etc.) even if there's an error */}
<Tabs
ref={tabsRef}
tabs={tabs}
defaultValue={TAB_BODY}
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_ASSERTIONS}>
<HttpAssertionResults response={activeResponse} />
</TabContent>
<TabContent value={TAB_BODY}>
<ErrorBoundary name="Http Response Viewer">
<Suspense>
+112 -736
View File
@@ -1,763 +1,139 @@
import {
type ImportDestination,
type ImportPlan,
type ImportPlanItem,
type ImportSource,
type Workspace,
} from "@yaakapp-internal/models";
import { Banner, FormattedError, HStack, Icon, VStack } from "@yaakapp-internal/ui";
import { platform } from "@yaakapp-internal/platform";
import { Icon, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { type ComponentProps, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { errorMessage } from "../lib/errorMessage";
import { pluralize } from "../lib/pluralize";
import { useEffect, useRef, useState } from "react";
import { useLocalStorage } from "react-use";
import { CommercialUseBanner } from "./CommercialUseBanner";
import {
ImportSourceList,
type ImportSourceDetection,
type ImportSourcePath,
InlineButton,
toSourcePath,
} from "./ImportSourceList";
import type { CheckboxTreeNode } from "./core/CheckboxTree";
import { CheckboxTree } from "./core/CheckboxTree";
import { Chip } from "./core/Chip";
import { DialogFooter } from "./core/Dialog";
import { IconTooltip } from "./core/IconTooltip";
import { Select } from "./core/Select";
import { SegmentedControl } from "./core/SegmentedControl";
import { Button } from "./core/Button";
import { PlainInput } from "./core/PlainInput";
interface Props {
currentWorkspace: Workspace | null;
workspaces: Workspace[];
planSources: (sources: ImportSourcePath[], destination: ImportDestination) => Promise<ImportPlan>;
detectSource: (source: ImportSourcePath) => Promise<string>;
listSources: (workspaceId: string) => Promise<ImportSource[]>;
findSourcesForOrigin: (args: { filePath?: string; url?: string }) => Promise<ImportSource[]>;
commit: (plan: ImportPlan) => Promise<void>;
cancel: () => void;
onError: (err: unknown) => void;
importFile: (filePath: string) => Promise<void>;
importUrl: (url: string) => Promise<void>;
}
/**
* Loads the current workspace's linked sources before rendering the dialog, so the inner
* component can construct its initial state (prefilled path, destination) in one pass instead of
* patching it in with effects after the first paint.
* An absolute or relative path is unambiguously a file. Everything else is treated as a URL, so a
* bare host like `example.com/openapi.json` still works (the backend defaults it to https).
*/
export function ImportDataDialog(props: Props) {
const [initialSources, setInitialSources] = useState<ImportSource[] | null>(null);
const { currentWorkspace, listSources } = props;
useEffect(() => {
let cancelled = false;
const load = currentWorkspace == null ? Promise.resolve([]) : listSources(currentWorkspace.id);
load
.then((sources) => {
if (!cancelled) setInitialSources(sources);
})
.catch(() => {
if (!cancelled) setInitialSources([]);
});
return () => {
cancelled = true;
};
}, [currentWorkspace, listSources]);
if (initialSources == null) return null;
return <LoadedImportDataDialog {...props} initialSources={initialSources} />;
function isFilePath(value: string): boolean {
return (
value.startsWith("/") ||
value.startsWith("./") ||
value.startsWith("../") ||
value.startsWith("~/") ||
value.startsWith("\\\\") ||
/^[a-zA-Z]:[\\/]/.test(value)
);
}
function LoadedImportDataDialog({
currentWorkspace,
workspaces,
planSources,
detectSource,
findSourcesForOrigin,
commit,
cancel,
onError,
initialSources,
}: Props & { initialSources: ImportSource[] }) {
function fileName(path: string): string {
return path.split(/[/\\]/).at(-1) || path;
}
export function ImportDataDialog({ importFile, importUrl }: Props) {
const [isLoading, setIsLoading] = useState<boolean>(false);
const [sourceError, setSourceError] = useState<unknown>(null);
const [plan, setPlan] = useState<ImportPlan | null>(null);
const [items, setItems] = useState<ImportPlanItem[]>([]);
// null means no explicit choice yet, so the default below applies
const [destinationChoice, setDestinationChoice] = useState<"new" | "current" | "other" | null>(
null,
);
const [otherWorkspaceId, setOtherWorkspaceId] = useState<string | null>(null);
const [originSources, setOriginSources] = useState<ImportSource[]>(initialSources);
const initialPaths = useMemo(
() => [...new Set(initialSources.map((source) => source.origin))].map(toSourcePath),
[initialSources],
);
const [sources, setSources] = useState<ImportSourcePath[]>(initialPaths);
const knownPaths = useRef(new Set(initialPaths.map((source) => source.path)));
const [detections, setDetections] = useState<Record<string, ImportSourceDetection>>({});
const detect = useCallback(
(added: ImportSourcePath[]) => {
setDetections((prev) => ({
...prev,
...Object.fromEntries(added.map((s) => [s.path, { status: "loading" as const }])),
}));
for (const source of added) {
const settle = (detection: ImportSourceDetection) =>
setDetections((prev) =>
source.path in prev ? { ...prev, [source.path]: detection } : prev,
);
detectSource(source).then(
(importer) => settle({ status: "ok", importer }),
(err) => settle({ status: "error", message: errorMessage(err) }),
);
// A file path or a URL. Both inputs write here, so there is only ever one thing to import
const [source, setSource] = useLocalStorage<string | null>("importPathOrUrl", null);
const [forceUpdateKey, setForceUpdateKey] = useState<number>(0);
const [isHovering, setIsHovering] = useState<boolean>(false);
const ref = useRef<HTMLDivElement>(null);
const trimmedSource = source?.trim() ?? "";
const filePath = isFilePath(trimmedSource) ? trimmedSource : null;
const selectSource = (value: string) => {
setSource(value);
// Remount the input so it shows the path of the newly-picked file
setForceUpdateKey((k) => k + 1);
};
// Accept a file dropped anywhere on the dialog, the way SelectFile does for its button
useEffect(() => {
return platform.window.onDragDrop((event) => {
if (event.type === "over") {
const p = event.position;
const r = ref.current?.getBoundingClientRect();
if (r == null) return;
setIsHovering(p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom);
} else if (event.type === "drop" && isHovering) {
const p = event.paths[0];
if (p) selectSource(p);
setIsHovering(false);
} else {
setIsHovering(false);
}
},
[detectSource],
);
const allDetected = sources.every((source) => detections[source.path]?.status === "ok");
useEffect(() => {
detect(initialPaths);
}, [detect, initialPaths]);
});
}, [isHovering, setSource]);
const addSources = useCallback(
(added: ImportSourcePath[]) => {
setSourceError(null);
const fresh = added.filter(({ path }) => {
if (knownPaths.current.has(path)) return false;
knownPaths.current.add(path);
return true;
});
if (fresh.length === 0) return;
setSources((existing) => [...existing, ...fresh]);
detect(fresh);
},
[detect],
);
const removeSource = (path: string) => {
setSourceError(null);
knownPaths.current.delete(path);
setSources((current) => current.filter((source) => source.path !== path));
setDetections(({ [path]: _removed, ...rest }) => rest);
const handleSelectFile = async () => {
const selected = await platform.dialog.open({ title: "Select File", multiple: false });
if (selected == null) return;
selectSource(selected);
};
useEffect(() => {
let cancelled = false;
if (sources.length === 0) {
setOriginSources([]);
return;
}
Promise.all(
sources.map(({ path, kind }) =>
findSourcesForOrigin(kind === "url" ? { url: path } : { filePath: path }),
),
)
.then((found) => {
if (!cancelled) setOriginSources(found.flat());
})
.catch(() => {
if (!cancelled) setOriginSources([]);
});
return () => {
cancelled = true;
};
}, [sources, findSourcesForOrigin]);
// The one workspace these sources are linked to, if there is exactly one.
const linkedWorkspace = useMemo(() => {
const ids = [...new Set(originSources.map((s) => s.workspaceId))];
if (ids.length !== 1) return null;
return workspaces.find((w) => w.id === ids[0]) ?? null;
}, [originSources, workspaces]);
// A file linked to the current workspace defaults back into it, so re-importing doesn't
// accidentally create a duplicate workspace. A file linked elsewhere only gets a suggestion —
// silently targeting a workspace that is neither new nor current is too surprising. An
// explicit choice always wins.
const destinationKind =
destinationChoice ??
(linkedWorkspace != null && linkedWorkspace.id === currentWorkspace?.id ? "current" : "new");
const destinationWorkspaceId =
destinationKind === "current"
? (currentWorkspace?.id ?? null)
: destinationKind === "other"
? otherWorkspaceId
: null;
const destination = (): ImportDestination => {
if (destinationWorkspaceId == null) {
return { type: "new_workspace" };
}
return {
type: "existing_workspace",
workspaceId: destinationWorkspaceId,
};
};
const handlePreview = async () => {
const handleImport = async () => {
setIsLoading(true);
setSourceError(null);
try {
const nextPlan = await planSources(sources, destination());
setPlan(nextPlan);
setItems(nextPlan.items);
} catch (err) {
setSourceError(err);
if (filePath != null) {
await importFile(filePath);
} else {
await importUrl(trimmedSource);
}
} finally {
setIsLoading(false);
}
};
const handleCommit = async () => {
if (plan == null) return;
setIsLoading(true);
try {
await commit({ ...plan, items });
} catch (err) {
onError(err);
} finally {
setIsLoading(false);
}
};
return (
<VStack ref={ref} space={4} className="pb-4">
<CommercialUseBanner source="data-import" title="Importing work data?" />
// A folder row's checkbox carries everything beneath it, deletions included — the row labels
// say which of those are destructive. Checking anything also brings back the folders it needs
// to live in.
const toggleNode = (node: CheckboxTreeNode<TreeRow>, checked: boolean) => {
const targets = new Set(togglableItems(node).map((i) => i.modelId));
if (checked && node.data.kind === "item") {
const byId = new Map(items.map((i) => [i.modelId, i]));
for (const ancestor of ancestorsOf(node.data.item, byId)) {
if (isMissingFolder(ancestor)) targets.add(ancestor.modelId);
}
}
setItems((prev) => prev.map((i) => (targets.has(i.modelId) ? { ...i, selected: checked } : i)));
};
const resolveConflict = (modelId: string, resolution: "keep_mine" | "take_source") => {
setItems((prev) =>
prev.map((item) => (item.modelId === modelId ? { ...item, resolution } : item)),
);
};
// Deleting a folder takes its contents with it, so those rows have nothing left to decide.
const disabledIds = useMemo(() => {
const disabled = new Set<string>();
const byId = new Map(items.map((i) => [i.modelId, i]));
for (const item of items) {
if (item.action !== "delete") continue;
for (const parent of ancestorsOf(item, byId)) {
if (parent.action === "delete" && parent.selected) {
disabled.add(item.modelId);
}
}
}
return disabled;
}, [items]);
if (plan != null) {
const unchanged = items.filter((i) => i.action === "unchanged");
const footerNote =
unchanged.length > 0
? `${unchanged.length} ${pluralize("resource", unchanged.length)} unchanged`
: "";
const changeCount = items.filter((item) => {
if (disabledIds.has(item.modelId)) return false;
if (item.action === "conflict") return item.resolution === "take_source";
if (item.action === "unchanged") return false;
return item.selected;
}).length;
// The row's label carries what kind of destination it is, so the value can just be its name
const [destinationLabel, destinationValue] = ((): [string, string] => {
if (plan.destination.type === "new_workspace") {
const names = plan.resources.workspaces.map((w) => w.name).filter((n) => n !== "");
if (names.length === 0) return ["New workspace", "Untitled"];
return [
names.length === 1 ? "New workspace" : `${names.length} new workspaces`,
names.join(", "),
];
}
const { workspaceId } = plan.destination;
const name = workspaces.find((w) => w.id === workspaceId)?.name ?? "Unknown workspace";
return ["Destination", name];
})();
// The destination workspace roots the tree. It is not a plan item — commit always applies
// it — so its checkbox only aggregates the subtree.
const workspaceRoots: CheckboxTreeNode<TreeRow>[] = (() => {
const planDestination = plan.destination;
const existing =
planDestination.type === "existing_workspace"
? workspaces.find((w) => w.id === planDestination.workspaceId)
: null;
const destinations = existing ? [existing] : plan.resources.workspaces;
if (destinations.length === 1) {
const workspace = destinations[0]!;
return [
{
key: workspace.id,
data: {
kind: "destination",
label: workspace.name,
isNew: planDestination.type === "new_workspace",
},
children: buildItemTree(items),
},
];
}
const workspaceById = new Map(
[
...plan.resources.environments,
...plan.resources.folders,
...plan.resources.httpRequests,
...plan.resources.grpcRequests,
...plan.resources.websocketRequests,
].map((resource) => [resource.id, resource.workspaceId]),
);
return destinations.map((workspace) => ({
key: workspace.id,
data: {
kind: "destination",
label: workspace.name,
isNew: planDestination.type === "new_workspace",
},
children: buildItemTree(
items.filter((item) => workspaceById.get(item.modelId) === workspace.id),
),
}));
})();
return (
<>
<VStack space={4} className="pb-4">
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
<PreviewRow label="Detected format" value={plan.importer} />
<PreviewRow label={destinationLabel} value={destinationValue} />
</div>
{plan.warnings
.filter(
(warning, index, warnings) =>
warnings.findIndex(
(other) =>
other.title === warning.title &&
other.detail === warning.detail &&
other.level === warning.level,
) === index,
)
.map((warning) => (
<Banner
key={`${warning.level}:${warning.title}:${warning.detail}`}
color={warning.level === "warning" ? "warning" : "info"}
className="flex items-start gap-2.5"
>
<Icon
icon={warning.level === "warning" ? "alert_triangle" : "info"}
size="sm"
className="mt-0.5"
/>
<div className="min-w-0">
<div className="text-sm font-medium">{warning.title}</div>
<div className="text-xs text-text-subtle mt-0.5">{warning.detail}</div>
</div>
</Banner>
))}
{workspaceRoots.map((root) => (
<div key={root.key} className="rounded-lg border border-border-subtle px-3 py-2">
<CheckboxTree
node={root}
checked={nodeCheckedStatus}
onCheck={toggleNode}
isCheckboxDisabled={(n) => disabledIds.has(n.key)}
isCollapsedByDefault={(n) =>
(n.data.kind === "destination" && workspaceRoots.length > 1) ||
(n.data.kind === "item" && n.data.item.action === "ignored")
}
isRelevant={(n) =>
n.data.kind === "destination" ||
(n.data.kind === "item" && n.data.item.action !== "unchanged")
}
renderRow={(n) => (
<ImportTreeRow row={n.data} onResolveConflict={resolveConflict} />
)}
/>
<button
type="button"
onClick={handleSelectFile}
className={classNames(
"w-full rounded-lg border border-dashed px-4 py-6",
"flex flex-col items-center gap-1 text-center",
isHovering ? "border-notice bg-surface-highlight" : "border-border hover:border-text",
)}
>
<Icon icon="folder_input" className="text-text-subtlest w-8! h-8! mb-2" />
{/* Fixed height so the region doesn't resize between the empty and selected states */}
<div className="h-6 w-full flex items-center justify-center">
{filePath == null ? (
<div className="text-text">
<strong className="font-semibold">Choose a file</strong> or drag it here
</div>
))}
</VStack>
<DialogFooter
leftSlot={
footerNote !== "" ? (
<div className="text-xs text-text-subtle">{footerNote}</div>
) : undefined
}
actions={[
{
label: "Back",
disabled: isLoading,
onClick: () => {
setPlan(null);
setItems([]);
},
},
{
label: isLoading
? "Importing"
: changeCount > 0
? `Apply ${changeCount} ${changeCount === 1 ? "Change" : "Changes"}`
: "Done",
color: "primary",
isLoading,
onClick: handleCommit,
},
]}
/>
</>
);
}
const destinationPicker = (
<HStack
space={2}
alignItems="center"
className={classNames(workspaces.length === 0 && "hidden")}
>
<Select
name="import-destination-kind"
label="Import into"
labelPosition="left"
size="sm"
value={destinationKind}
onChange={setDestinationChoice}
options={[
{ value: "new", label: "New Workspace" },
...(currentWorkspace != null
? [{ value: "current" as const, label: "Current Workspace" }]
: []),
{ value: "other", label: "Other Workspace" },
]}
/>
{destinationKind === "other" && (
<Select
name="import-destination-workspace"
label="Workspace"
hideLabel
size="sm"
value={otherWorkspaceId ?? ""}
onChange={(id) => setOtherWorkspaceId(id === "" ? null : id)}
filterable
options={[
{ value: "", label: "Select a workspace" },
...workspaces
.filter((w) => w.id !== currentWorkspace?.id)
.map((w) => ({ value: w.id, label: w.name })),
]}
/>
)}
</HStack>
);
return (
<>
<VStack space={4} className="h-full pb-4">
<CommercialUseBanner source="data-import" title="Importing work data?" />
<ImportSourceList
sources={sources}
detections={detections}
disabled={isLoading}
onAdd={addSources}
onRemove={removeSource}
onError={setSourceError}
/>
{sourceError != null && (
<Banner color="warning">
<FormattedError>{errorMessage(sourceError)}</FormattedError>
</Banner>
)}
{linkedWorkspace != null && linkedWorkspace.id !== destinationWorkspaceId && (
<div className="text-xs text-text-subtle">
{sources.length === 1 ? "This source was" : "These sources were"} last imported into{" "}
<InlineButton
onClick={() => {
if (linkedWorkspace.id === currentWorkspace?.id) {
setDestinationChoice("current");
} else {
setDestinationChoice("other");
setOtherWorkspaceId(linkedWorkspace.id);
}
}}
>
{linkedWorkspace.name}
</InlineButton>
</div>
)}
</VStack>
<DialogFooter
leftSlot={destinationPicker}
actions={[
{ label: "Cancel", disabled: isLoading, onClick: cancel },
{
label: isLoading ? "Analyzing" : "Preview Import",
color: "primary",
disabled:
sources.length === 0 ||
!allDetected ||
isLoading ||
(destinationKind === "other" && otherWorkspaceId == null),
isLoading,
onClick: handlePreview,
},
]}
/>
</>
);
}
function ImportTreeRow({
row,
onResolveConflict,
}: {
row: TreeRow;
onResolveConflict: (modelId: string, resolution: "keep_mine" | "take_source") => void;
}) {
if (row.kind !== "item") {
return (
<>
{row.kind === "destination" && (
<Icon icon="box" size="sm" className="shrink-0 text-text-subtle" />
)}
<div className="truncate flex-1">{row.label}</div>
{row.kind === "destination" && row.isNew && (
<ActionChip label="new" help="Created by this import" color="success" />
)}
</>
);
}
const { item } = row;
const label = actionLabel(item);
return (
<>
<div className="truncate flex-1">{item.name}</div>
{item.action === "conflict" ? (
<div className="shrink-0">
<SegmentedControl
name={`conflict-${item.modelId}`}
label={`Resolve conflict for ${item.name}`}
hideLabel
size="2xs"
help={actionHelp(item)}
value={item.resolution ?? "keep_mine"}
onChange={(v) => onResolveConflict(item.modelId, v)}
options={[
{ value: "keep_mine", label: "Keep mine" },
{ value: "take_source", label: "Take source" },
]}
/>
) : (
<div className="text-text font-mono text-xs max-w-full truncate" title={filePath}>
{fileName(filePath)}
</div>
)}
</div>
) : (
label != null && (
<ActionChip label={label} help={actionHelp(item)} color={actionColor(item)} />
)
)}
</>
);
}
function ActionChip({
label,
help,
color,
}: {
label: string;
help: string | null;
color: ComponentProps<typeof Chip>["color"];
}) {
return (
<Chip color={color}>
{label}
{help != null && <IconTooltip content={help} iconSize="xs" />}
</Chip>
);
}
function actionColor(item: ImportPlanItem): ComponentProps<typeof Chip>["color"] {
switch (item.action) {
case "create":
return "success";
case "update":
return "info";
case "delete":
return "danger";
case "keep_local":
return item.selected ? "warning" : "default";
case "unchanged":
case "conflict":
case "ignored":
return "default";
}
}
function actionLabel(item: ImportPlanItem): string | null {
switch (item.action) {
case "create":
return "new";
case "update":
return "updated";
case "delete":
return "removed";
case "keep_local":
return "edited";
case "ignored":
return "ignored";
default:
return null;
}
}
function actionHelp(item: ImportPlanItem): string | null {
const help = (text: string) =>
item.changedFields.length > 0
? `${text} · ${item.changedFields.map(fieldLabel).join(", ")}`
: text;
switch (item.action) {
case "create":
return "Not in this workspace yet";
case "update":
return item.reason === "moved_into_ignored_folder"
? "Moved into a folder that isn't imported. Importing that folder brings it along"
: help("Changed in the source since the last import");
case "delete":
return "Gone from the source since the last import. Checking it deletes it here";
case "keep_local":
return help("Changed here since the last import. Checking it reverts to the source");
case "conflict":
return help("Changed here and in the source since the last import");
case "ignored":
return "Not in this workspace. Imports leave it alone until you check it";
default:
return null;
}
}
function fieldLabel(field: string): string {
return field.replace(/([A-Z])/g, " $1").toLowerCase();
}
/** Every plan item above `item`, nearest first. */
function ancestorsOf(item: ImportPlanItem, byId: Map<string, ImportPlanItem>): ImportPlanItem[] {
const ancestors: ImportPlanItem[] = [];
const seen = new Set<string>();
let parentId = item.parentId;
while (parentId != null && !seen.has(parentId)) {
seen.add(parentId);
const parent = byId.get(parentId);
if (parent == null) break;
ancestors.push(parent);
parentId = parent.parentId;
}
return ancestors;
}
/**
* A row of the preview tree. Most are plan items, but the destination workspace and the group the
* workspace's environments sit in are headings: they aggregate their children and decide nothing
* themselves.
*/
type TreeRow =
| { kind: "destination"; label: string; isNew: boolean }
| { kind: "group"; label: string }
| { kind: "item"; item: ImportPlanItem };
function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<TreeRow>[] {
const byId = new Map(items.map((i) => [i.modelId, i]));
const childrenOf = new Map<string, ImportPlanItem[]>();
const roots: ImportPlanItem[] = [];
for (const item of items) {
if (item.parentId != null && byId.has(item.parentId)) {
const siblings = childrenOf.get(item.parentId) ?? [];
siblings.push(item);
childrenOf.set(item.parentId, siblings);
} else {
roots.push(item);
}
}
const byKind = (list: ImportPlanItem[]) => [
...list.filter((i) => i.model === "environment"),
...list.filter((i) => i.model === "folder"),
...list.filter((i) => i.model !== "environment" && i.model !== "folder"),
];
const toNode = (item: ImportPlanItem, seen: Set<string>): CheckboxTreeNode<TreeRow> => ({
key: item.modelId,
data: { kind: "item", item },
children: seen.has(item.modelId)
? []
: byKind(childrenOf.get(item.modelId) ?? []).map((c) =>
toNode(c, new Set([...seen, item.modelId])),
),
});
// The workspace's environments have nothing to sit under — a sub-environment is a sibling of
// the base one, not its child — so a heading groups them into one thing to turn on and off.
const environments = roots.filter((i) => i.model === "environment");
const others = byKind(roots.filter((i) => i.model !== "environment"));
const nodes = others.map((r) => toNode(r, new Set()));
if (environments.length === 0) return nodes;
return [
{
key: "group:environments",
data: { kind: "group", label: "Variables" },
children: environments.map((e) => toNode(e, new Set())),
},
...nodes,
];
}
function collectRows(node: CheckboxTreeNode<TreeRow>): TreeRow[] {
return [node.data, ...node.children.flatMap(collectRows)];
}
/** A folder that isn't there yet, so anything inside it needs it brought in first. */
function isMissingFolder(item: ImportPlanItem): boolean {
return item.model === "folder" && (item.action === "create" || item.action === "ignored");
}
/**
* The plan item a row's checkbox decides, if it decides one. An unchanged resource has nothing to
* decide and a conflict is decided by its own control, so neither takes a checkbox — nor rides
* along with a parent's.
*/
function togglableItem(row: TreeRow): ImportPlanItem | null {
if (row.kind !== "item") return null;
const { item } = row;
return item.action === "unchanged" || item.action === "conflict" ? null : item;
}
function togglableItems(node: CheckboxTreeNode<TreeRow>): ImportPlanItem[] {
return collectRows(node)
.map(togglableItem)
.filter((i) => i != null);
}
function nodeCheckedStatus(node: CheckboxTreeNode<TreeRow>): boolean | "indeterminate" | "hidden" {
const covered = togglableItems(node);
if (covered.length === 0) return "hidden";
const selected = covered.filter((i) => i.selected).length;
if (selected === covered.length) return true;
if (selected === 0) return false;
return "indeterminate";
}
function PreviewRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-start justify-between gap-4 px-3 py-2 text-sm">
<span className="text-text-subtle">{label}</span>
<span className="text-right font-medium">{value}</span>
</div>
<div className="text-xs text-text-subtlest">
Supports OpenAPI, Swagger, Postman, Insomnia, and curl
</div>
</button>
<VStack space={2}>
<PlainInput
label="Or enter a file path or URL"
size="sm"
placeholder="https://example.com/openapi.json"
defaultValue={source ?? ""}
forceUpdateKey={String(forceUpdateKey)}
onChange={setSource}
/>
<Button
color="primary"
disabled={trimmedSource === "" || isLoading}
isLoading={isLoading}
size="sm"
onClick={handleImport}
>
{isLoading ? "Importing" : "Import"}
</Button>
</VStack>
</VStack>
);
}
@@ -1,271 +0,0 @@
import { platform } from "@yaakapp-internal/platform";
import { Icon, LoadingIcon } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { type KeyboardEvent, type ReactNode, useEffect, useRef, useState } from "react";
import { Button } from "./core/Button";
import { IconButton } from "./core/IconButton";
import { PathList, splitPath } from "./core/PathList";
import { PlainInput } from "./core/PlainInput";
export interface ImportSourcePath {
path: string;
kind?: "file" | "folder" | "url";
}
export type ImportSourceDetection =
| { status: "loading" }
| { status: "ok"; importer: string }
| { status: "error"; message: string };
interface Props {
sources: ImportSourcePath[];
detections: Record<string, ImportSourceDetection>;
disabled?: boolean;
onAdd: (sources: ImportSourcePath[]) => void;
onRemove: (path: string) => void;
onError: (err: unknown) => void;
}
/** A bare hostname is a URL; local paths need an absolute or explicit relative prefix. */
export function isImportFilePath(value: string): boolean {
return (
value.startsWith("/") ||
value.startsWith("./") ||
value.startsWith("../") ||
value.startsWith("~/") ||
value.startsWith("\\\\") ||
/^[a-zA-Z]:[\\/]/.test(value)
);
}
/** A typed or prefilled path is a URL unless it looks like a local path; kind is left for the host */
export function toSourcePath(path: string): ImportSourcePath {
return { path, kind: isImportFilePath(path) ? undefined : "url" };
}
export function ImportSourceList({
sources,
detections,
disabled,
onAdd,
onRemove,
onError,
}: Props) {
const ref = useRef<HTMLDivElement>(null);
const [isHovering, setIsHovering] = useState(false);
const [pathInput, setPathInput] = useState("");
const [showPathInput, setShowPathInput] = useState(false);
const pathInputRef = useRef<{ focus: () => void }>(null);
const trimmedPath = pathInput.trim();
const openPathInput = () => {
setShowPathInput(true);
// The add-row menu hands focus back to its trigger as it closes, on a timer, so focus after it
setTimeout(() => pathInputRef.current?.focus(), 50);
};
const closePathInput = () => {
setPathInput("");
setShowPathInput(false);
};
const addPath = () => {
if (!trimmedPath || disabled) return;
onAdd([toSourcePath(trimmedPath)]);
closePathInput();
};
useEffect(() => {
if (disabled) return;
return platform.window.onDragDrop((event) => {
if (event.type === "leave") {
setIsHovering(false);
return;
}
const rect = ref.current?.getBoundingClientRect();
const { x, y } = event.position;
const isOver =
rect != null && x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
if (event.type === "drop") {
if (isOver) onAdd(event.paths.map((path) => ({ path })));
setIsHovering(false);
} else {
setIsHovering(isOver);
}
});
}, [disabled, onAdd]);
const pick = async (directory: boolean) => {
try {
const selected = await platform.dialog.open({
title: directory ? "Select Import Folder" : "Select Import Files",
multiple: true,
directory,
});
if (selected == null) return;
onAdd(selected.map((path) => ({ path, kind: directory ? "folder" : "file" })));
} catch (err) {
onError(err);
}
};
const handlePathKeyDown = (event: KeyboardEvent) => {
if (event.key === "Enter") {
event.preventDefault();
addPath();
} else if (event.key === "Escape") {
event.stopPropagation();
closePathInput();
}
};
const pathInputEl = (
<PlainInput
ref={pathInputRef}
label="URL or file path"
hideLabel
size="sm"
autoFocus
disabled={disabled}
placeholder="Paste a URL or a file path"
onChange={setPathInput}
onKeyDownCapture={handlePathKeyDown}
rightSlot={
<div className="flex items-center gap-1 my-1 mr-1">
{trimmedPath !== "" && (
<Button size="xs" variant="border" onClick={addPath}>
Add
</Button>
)}
<IconButton size="xs" icon="x" title="Cancel" onClick={closePathInput} />
</div>
}
/>
);
if (sources.length === 0) {
return (
<div className="flex-1 flex flex-col gap-2">
<div
ref={ref}
className={classNames(
"flex-1 rounded-lg border min-h-28 flex flex-col items-center justify-center gap-2 px-4 py-5 text-center text-text-subtle",
isHovering ? "border-notice bg-surface-highlight" : "border-dashed border-border",
)}
>
<Icon icon="folder_input" className="text-text-subtlest w-10! h-10! mb-1" />
{isHovering ? (
<span>Drop to add</span>
) : (
<span>
Choose{" "}
<InlineButton disabled={disabled} onClick={() => pick(false)}>
files
</InlineButton>
, a{" "}
<InlineButton disabled={disabled} onClick={() => pick(true)}>
folder
</InlineButton>
, or a{" "}
<InlineButton disabled={disabled} onClick={openPathInput}>
URL
</InlineButton>
, or drag them here
</span>
)}
<span className="text-xs text-text-subtlest">
Supports OpenAPI, Swagger, Postman, Insomnia, Bruno, curl, and Yaak exports
</span>
</div>
{showPathInput && pathInputEl}
</div>
);
}
return (
<PathList
ref={ref}
hovering={isHovering}
disabled={disabled}
onRemove={onRemove}
addLabel="Add files, a folder, or a URL"
addHint="or drop them here"
addItems={[
{ label: "Files", icon: "file", onSelect: () => pick(false) },
{ label: "Folder", icon: "folder", onSelect: () => pick(true) },
{ label: "URL or file path", icon: "globe", onSelect: openPathInput },
]}
items={sources.map((source) => {
const detection = detections[source.path];
return {
path: source.path,
icon: sourceIcon(source),
failed: detection?.status === "error",
subtitle: <DetectionSubtitle path={source.path} detection={detection} />,
};
})}
>
{showPathInput && pathInputEl}
</PathList>
);
}
function DetectionSubtitle({
path,
detection,
}: {
path: string;
detection: ImportSourceDetection | undefined;
}) {
const { parent } = splitPath(path);
if (detection?.status === "loading") {
return (
<>
<LoadingIcon size="xs" />
Detecting format
</>
);
}
if (detection?.status === "error") return detection.message;
return (
<>
{detection?.status === "ok" && <span>{detection.importer}</span>}
{detection?.status === "ok" && parent !== "" && <span>·</span>}
{parent !== "" && <span className="truncate font-mono">{parent}</span>}
</>
);
}
function sourceIcon({ kind }: ImportSourcePath) {
switch (kind) {
case "url":
return "globe";
case "folder":
return "folder";
case "file":
return "file";
case undefined:
return "import";
}
}
export function InlineButton({
children,
disabled,
onClick,
}: {
children: ReactNode;
disabled?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
disabled={disabled}
className="underline decoration-current/40 underline-offset-3 hocus:decoration-current disabled:opacity-disabled"
onClick={onClick}
>
{children}
</button>
);
}
@@ -1,76 +0,0 @@
import { autocompletion, startCompletion } from "@codemirror/autocomplete";
import { EditorView } from "@codemirror/view";
import { useQueryClient } from "@tanstack/react-query";
import type { HttpResponse } from "@yaakapp-internal/models";
import { useMemo, useRef, useState } from "react";
import { rpc } from "../lib/rpc";
import { Input } from "./core/Input";
import type { InputProps } from "./core/Input";
import { jsonPathCompletion } from "./core/Editor/json/pathCompletion";
import type { JsonPathChildren, LoadJsonPathChildren } from "./core/Editor/json/pathCompletion";
type Props = Pick<InputProps, "defaultValue" | "onChange" | "forceUpdateKey" | "stateKey"> & {
response: HttpResponse | null;
};
export function JsonPathInput({ response, ...props }: Props) {
const client = useQueryClient();
const [sampled, setSampled] = useState(false);
const responseKey = response?.state === "closed" ? `${response.id}:${response.updatedAt}` : null;
const currentResponseKey = useRef(responseKey);
currentResponseKey.current = responseKey;
const load = useRef<LoadJsonPathChildren>(async () => null);
load.current = async (parent) => {
if (!response || !responseKey) return null;
try {
const result = await client.fetchQuery({
queryKey: ["json_path_children", response.id, response.updatedAt, parent],
queryFn: () =>
rpc<JsonPathChildren>("cmd_http_response_json_children", {
responseId: response.id,
parent,
}),
staleTime: Infinity,
gcTime: 60_000,
retry: false,
});
if (responseKey !== currentResponseKey.current) return null;
setSampled(result.truncated);
return result;
} catch {
return null;
} // Non-JSON bodies and incomplete expressions still allow normal typing.
};
// The editor installs extraExtensions once. A ref keeps the response current without rebuilding
// the input and losing focus/selection when a new response arrives.
const extensions = useMemo(
() => [
autocompletion({ override: [jsonPathCompletion((parent) => load.current(parent))] }),
EditorView.domEventHandlers({
focus: (_event, view) => {
startCompletion(view);
},
}),
],
[],
);
return (
<div className="min-w-0">
<Input
{...props}
label="JSONPath"
size="sm"
placeholder="$.user.id"
language="text"
autocompleteFunctions={false}
autocompleteVariables={false}
extraExtensions={extensions}
/>
{sampled && (
<p className="text-xs text-text-subtle mt-1">
Suggestions are sampled. You can enter any path.
</p>
)}
</div>
);
}
@@ -2,9 +2,7 @@ import type {
Folder,
GrpcRequest,
HttpRequest,
HttpVersion,
InheritedBoolSetting,
InheritedHttpVersionSetting,
InheritedIntSetting,
WebsocketRequest,
Workspace,
@@ -15,7 +13,6 @@ import {
modelSupportsSetting,
type RequestSettingDefinition,
SETTING_FOLLOW_REDIRECTS,
SETTING_HTTP_VERSION,
SETTING_REQUEST_MESSAGE_SIZE,
SETTING_REQUEST_TIMEOUT,
SETTING_SEND_COOKIES,
@@ -24,7 +21,6 @@ import {
} from "../lib/requestSettings";
import { Checkbox } from "./core/Checkbox";
import { PlainInput } from "./core/PlainInput";
import { Select } from "./core/Select";
import {
SettingOverrideRow,
SettingRow,
@@ -42,21 +38,37 @@ interface Props {
model: ModelWithSettings;
}
type ModelWithSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest;
type ModelWithSettings =
| Workspace
| Folder
| HttpRequest
| WebsocketRequest
| GrpcRequest;
type ModelWithHttpSettings = Workspace | Folder | HttpRequest;
type ModelWithTlsSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest;
type ModelWithCookieSettings = Workspace | Folder | HttpRequest | WebsocketRequest;
type ModelWithMessageSizeSettings = Workspace | Folder | WebsocketRequest | GrpcRequest;
type ModelWithTlsSettings =
| Workspace
| Folder
| HttpRequest
| WebsocketRequest
| GrpcRequest;
type ModelWithCookieSettings =
| Workspace
| Folder
| HttpRequest
| WebsocketRequest;
type ModelWithMessageSizeSettings =
| Workspace
| Folder
| WebsocketRequest
| GrpcRequest;
type BooleanSetting = boolean | InheritedBoolSetting;
type IntegerSetting = number | InheritedIntSetting;
type HttpVersionSetting = HttpVersion | InheritedHttpVersionSetting;
type CookieSettingsPatch = {
settingSendCookies?: ModelWithCookieSettings["settingSendCookies"];
settingStoreCookies?: ModelWithCookieSettings["settingStoreCookies"];
};
type HttpSettingsPatch = {
settingFollowRedirects?: ModelWithHttpSettings["settingFollowRedirects"];
settingHttpVersion?: ModelWithHttpSettings["settingHttpVersion"];
settingRequestTimeout?: ModelWithHttpSettings["settingRequestTimeout"];
};
type TlsSettingsPatch = {
@@ -66,7 +78,10 @@ type MessageSizeSettingsPatch = {
settingRequestMessageSize?: ModelWithMessageSizeSettings["settingRequestMessageSize"];
};
export function ModelSettingsEditor({ model, showSectionTitles = false }: Props) {
export function ModelSettingsEditor({
model,
showSectionTitles = false,
}: Props) {
const ancestors = useModelAncestors(model);
const supportsHttpSettings = modelSupportsHttpSettings(model);
const supportsCookieSettings = modelSupportsCookieSettings(model);
@@ -139,26 +154,12 @@ export function ModelSettingsEditor({ model, showSectionTitles = false }: Props)
}
/>
)}
{supportsHttpSettings && (
<HttpVersionSettingRow
settingDefinition={SETTING_HTTP_VERSION}
setting={model.settingHttpVersion}
inheritedValue={resolveInheritedValue(
ancestors,
SETTING_HTTP_VERSION.modelKey,
model.settingHttpVersion,
)}
onChange={(settingHttpVersion) =>
patchHttpSettings(model, {
settingHttpVersion,
})
}
/>
)}
</SettingsSection>
)}
{supportsCookieSettings && (
<SettingsSection title={supportsTlsSettings || showSectionTitles ? "Cookies" : null}>
<SettingsSection
title={supportsTlsSettings || showSectionTitles ? "Cookies" : null}
>
<BooleanSettingRow
settingDefinition={SETTING_SEND_COOKIES}
setting={model.settingSendCookies}
@@ -194,7 +195,7 @@ export function ModelSettingsEditor({ model, showSectionTitles = false }: Props)
}
export function countOverriddenSettings(model: ModelWithSettings) {
const settings: (BooleanSetting | IntegerSetting | HttpVersionSetting)[] = [];
const settings: (BooleanSetting | IntegerSetting)[] = [];
if (modelSupportsCookieSettings(model)) {
settings.push(model.settingSendCookies, model.settingStoreCookies);
@@ -203,22 +204,22 @@ export function countOverriddenSettings(model: ModelWithSettings) {
settings.push(model.settingValidateCertificates);
if (modelSupportsHttpSettings(model)) {
settings.push(
model.settingFollowRedirects,
model.settingRequestTimeout,
model.settingHttpVersion,
);
settings.push(model.settingFollowRedirects, model.settingRequestTimeout);
}
if (modelSupportsMessageSizeSettings(model)) {
settings.push(model.settingRequestMessageSize);
}
return settings.filter((setting) => isInheritedSetting(setting) && setting.enabled === true)
.length;
return settings.filter(
(setting) => isInheritedSetting(setting) && setting.enabled === true,
).length;
}
function patchCookieSettings(model: ModelWithCookieSettings, patch: Partial<CookieSettingsPatch>) {
function patchCookieSettings(
model: ModelWithCookieSettings,
patch: Partial<CookieSettingsPatch>,
) {
switch (model.model) {
case "workspace":
return patchModel(model, patch as Partial<Workspace>);
@@ -231,7 +232,10 @@ function patchCookieSettings(model: ModelWithCookieSettings, patch: Partial<Cook
}
}
function patchHttpSettings(model: ModelWithHttpSettings, patch: Partial<HttpSettingsPatch>) {
function patchHttpSettings(
model: ModelWithHttpSettings,
patch: Partial<HttpSettingsPatch>,
) {
switch (model.model) {
case "workspace":
return patchModel(model, patch as Partial<Workspace>);
@@ -242,7 +246,10 @@ function patchHttpSettings(model: ModelWithHttpSettings, patch: Partial<HttpSett
}
}
function patchTlsSettings(model: ModelWithTlsSettings, patch: Partial<TlsSettingsPatch>) {
function patchTlsSettings(
model: ModelWithTlsSettings,
patch: Partial<TlsSettingsPatch>,
) {
switch (model.model) {
case "workspace":
return patchModel(model, patch as Partial<Workspace>);
@@ -273,15 +280,21 @@ function patchMessageSizeSettings(
}
}
function modelSupportsHttpSettings(model: ModelWithSettings): model is ModelWithHttpSettings {
function modelSupportsHttpSettings(
model: ModelWithSettings,
): model is ModelWithHttpSettings {
return modelSupportsSetting(model, SETTING_REQUEST_TIMEOUT);
}
function modelSupportsCookieSettings(model: ModelWithSettings): model is ModelWithCookieSettings {
function modelSupportsCookieSettings(
model: ModelWithSettings,
): model is ModelWithCookieSettings {
return modelSupportsSetting(model, SETTING_SEND_COOKIES);
}
function modelSupportsTlsSettings(model: ModelWithSettings): model is ModelWithTlsSettings {
function modelSupportsTlsSettings(
model: ModelWithSettings,
): model is ModelWithTlsSettings {
return modelSupportsSetting(model, SETTING_VALIDATE_CERTIFICATES);
}
@@ -304,7 +317,11 @@ function BooleanSettingRow({
}) {
const inherited = isInheritedSetting(setting);
const overridden = inherited ? setting.enabled === true : false;
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
const value = inherited
? overridden
? setting.value
: inheritedValue
: setting;
if (!inherited) {
return (
@@ -335,63 +352,6 @@ function BooleanSettingRow({
);
}
const HTTP_VERSION_OPTIONS: { label: string; value: HttpVersion }[] = [
{ label: "Automatic", value: "auto" },
{ label: "HTTP/1.1", value: "http1" },
{ label: "HTTP/2", value: "http2" },
];
function HttpVersionSettingRow({
inheritedValue,
setting,
settingDefinition,
onChange,
}: {
inheritedValue: HttpVersion;
setting: HttpVersionSetting;
settingDefinition: RequestSettingDefinition<"settingHttpVersion">;
onChange: (setting: HttpVersionSetting) => void;
}) {
const inherited = isInheritedSetting(setting);
const overridden = inherited ? setting.enabled === true : false;
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
if (!inherited) {
return (
<SettingRow title={settingDefinition.title} description={settingDefinition.description}>
<Select
hideLabel
name={settingDefinition.modelKey}
label={settingDefinition.title}
size="sm"
value={value}
options={HTTP_VERSION_OPTIONS}
onChange={(value) => onChange(value)}
/>
</SettingRow>
);
}
return (
<SettingOverrideRow
title={settingDefinition.title}
description={settingDefinition.description}
overridden={overridden}
onResetOverride={() => onChange({ ...setting, enabled: false })}
>
<Select
hideLabel
name={settingDefinition.modelKey}
label={settingDefinition.title}
size="sm"
value={value}
options={HTTP_VERSION_OPTIONS}
onChange={(value) => onChange({ ...setting, enabled: true, value })}
/>
</SettingOverrideRow>
);
}
function IntegerSettingRow({
inheritedValue,
setting,
@@ -405,11 +365,18 @@ function IntegerSettingRow({
}) {
const inherited = isInheritedSetting(setting);
const overridden = inherited ? setting.enabled === true : false;
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
const value = inherited
? overridden
? setting.value
: inheritedValue
: setting;
if (!inherited) {
return (
<SettingRow title={settingDefinition.title} description={settingDefinition.description}>
<SettingRow
title={settingDefinition.title}
description={settingDefinition.description}
>
<NumberUnitInput
name={settingDefinition.modelKey}
label={settingDefinition.title}
@@ -462,13 +429,20 @@ function MessageSizeSettingRow({
}) {
const inherited = isInheritedSetting(setting);
const overridden = inherited ? setting.enabled === true : false;
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
const value = inherited
? overridden
? setting.value
: inheritedValue
: setting;
const displayValue = formatMegabytes(value);
const placeholder = "0";
if (!inherited) {
return (
<SettingRow title={settingDefinition.title} description={settingDefinition.description}>
<SettingRow
title={settingDefinition.title}
description={settingDefinition.description}
>
<MessageSizeInput
name={settingDefinition.modelKey}
label={settingDefinition.title}
@@ -593,18 +567,13 @@ function resolveInheritedValue(
key: BooleanWorkspaceSettingKey,
fallback: BooleanSetting,
): boolean;
function resolveInheritedValue(
ancestors: (Folder | Workspace)[],
key: "settingHttpVersion",
fallback: HttpVersionSetting,
): HttpVersion;
function resolveInheritedValue(
ancestors: (Folder | Workspace)[],
key: keyof WorkspaceSettings,
fallback: BooleanSetting | IntegerSetting | HttpVersionSetting,
fallback: BooleanSetting | IntegerSetting,
) {
for (const ancestor of ancestors) {
const setting = ancestor[key] as BooleanSetting | IntegerSetting | HttpVersionSetting;
const setting = ancestor[key] as BooleanSetting | IntegerSetting;
if (isInheritedSetting(setting)) {
if (setting.enabled === true) {
return setting.value;
@@ -620,7 +589,6 @@ function resolveInheritedValue(
type WorkspaceSettings = Pick<
Workspace,
| "settingFollowRedirects"
| "settingHttpVersion"
| "settingRequestMessageSize"
| "settingRequestTimeout"
| "settingSendCookies"
@@ -630,12 +598,14 @@ type WorkspaceSettings = Pick<
type BooleanWorkspaceSettingKey = Exclude<
keyof WorkspaceSettings,
"settingRequestTimeout" | "settingRequestMessageSize" | "settingHttpVersion"
"settingRequestTimeout" | "settingRequestMessageSize"
>;
function formatMegabytes(bytes: number) {
const megabytes = bytes / BYTES_PER_MB;
return Number.isInteger(megabytes) ? `${megabytes}` : megabytes.toFixed(3).replace(/\.?0+$/, "");
return Number.isInteger(megabytes)
? `${megabytes}`
: megabytes.toFixed(3).replace(/\.?0+$/, "");
}
function parseMegabytes(value: string) {
@@ -656,5 +626,9 @@ function isValidInteger(value: string) {
function isValidMegabytes(value: string) {
if (value === "") return true;
const megabytes = Number(value);
return Number.isFinite(megabytes) && megabytes >= 0 && megabytes <= MAX_MESSAGE_SIZE_MB;
return (
Number.isFinite(megabytes) &&
megabytes >= 0 &&
megabytes <= MAX_MESSAGE_SIZE_MB
);
}
-212
View File
@@ -1,212 +0,0 @@
import type { Color } from "@yaakapp-internal/plugins";
import { platform } from "@yaakapp-internal/platform";
import { settingsAtom } from "@yaakapp-internal/models";
import { HeaderSize, Heading, Icon, type IconProps, LoadingIcon } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import { useState } from "react";
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
import { showDialog } from "../lib/dialog";
import { importData } from "../lib/importData";
import {
createExampleWorkspace,
createFreshWorkspace,
type OnboardingChoice,
recordOnboardingChoice,
} from "../lib/onboarding";
import { showErrorToast } from "../lib/toast";
import { CloneGitRepositoryDialog } from "./CloneGitRepositoryDialog";
import { Button } from "./core/Button";
/** Shown instead of a workspace when there are none. */
export function Onboarding() {
const settings = useAtomValue(settingsAtom);
const { capabilities } = platform;
const canImport = capabilities.plugins;
const showAlreadyUsing = capabilities.localFiles || capabilities.git || canImport;
const [busy, setBusy] = useState<OnboardingChoice | null>(null);
const choose = (choice: OnboardingChoice, run: () => Promise<void> | void) => async () => {
recordOnboardingChoice(choice);
setBusy(choice);
try {
await run();
} catch (err) {
showErrorToast({
id: "onboarding-failed",
title: "Something went wrong",
message: String(err),
});
} finally {
setBusy(null);
}
};
return (
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full w-full">
<HeaderSize
data-tauri-drag-region
size="lg"
className="x-theme-appHeader bg-surface"
osType={platform.osType()}
hideWindowControls={settings.hideWindowControls}
useNativeTitlebar={settings.useNativeTitlebar}
interfaceScale={settings.interfaceScale}
/>
<div className="overflow-auto px-6 py-10 grid">
<div className="m-auto w-full max-w-lg flex flex-col gap-7">
<div className="flex flex-col gap-1.5">
<Heading>How would you like to get started?</Heading>
<p className="text-text-subtle">
{canImport
? "Bring over existing work, try a real API, or start fresh."
: "Try a real API, or start fresh."}
</p>
</div>
<div className="flex flex-col gap-2">
{canImport && (
<StartOption
color="primary"
icon="folder_input"
title="Migrate from another tool"
description="Postman, Insomnia, Bruno, OpenAPI, or curl"
busy={busy === "import"}
disabled={busy != null}
onClick={choose("import", () => importData.mutateAsync())}
/>
)}
<StartOption
color="info"
icon="flask"
title="Try Yaak with a real API"
description="Ready-made requests you can send right away"
busy={busy === "example"}
disabled={busy != null}
onClick={choose("example", createExampleWorkspace)}
/>
<StartOption
color="success"
icon="plus"
title="Start fresh"
description="An empty workspace for your first request"
busy={busy === "fresh"}
disabled={busy != null}
onClick={choose("fresh", createFreshWorkspace)}
/>
</div>
{showAlreadyUsing && (
<div className="pt-5 border-t border-dashed border-border-subtle flex flex-col items-start gap-2.5">
<span className="text-sm text-text-subtle">Already using Yaak?</span>
<div className="flex flex-wrap gap-2">
{capabilities.localFiles && (
<Button
size="xs"
variant="border"
color="secondary"
leftSlot={<Icon icon="folder_open" size="sm" />}
disabled={busy != null}
onClick={choose("open_folder", async () => {
const dir = await platform.dialog.open({
title: "Select Workspace Directory",
directory: true,
multiple: false,
});
if (dir == null) return;
await openWorkspaceFromSyncDir.mutateAsync(dir);
})}
>
Open folder
</Button>
)}
{capabilities.git && (
<Button
size="xs"
variant="border"
color="secondary"
leftSlot={<Icon icon="git_branch" size="sm" />}
disabled={busy != null}
onClick={choose("clone_git", () => {
showDialog({
id: "clone-git-repository",
size: "md",
title: "Clone Git Repository",
render: ({ hide }) => <CloneGitRepositoryDialog hide={hide} />,
});
})}
>
Clone repository
</Button>
)}
{canImport && (
<Button
size="xs"
variant="border"
color="secondary"
leftSlot={<Icon icon="import" size="sm" />}
disabled={busy != null}
onClick={choose("import_yaak", () => importData.mutateAsync())}
>
Import
</Button>
)}
</div>
</div>
)}
</div>
</div>
</div>
);
}
function StartOption({
color,
icon,
title,
description,
onClick,
busy,
disabled,
}: {
color: Color;
icon: IconProps["icon"];
title: string;
description: string;
onClick: () => void;
busy: boolean;
disabled: boolean;
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={classNames(
"group w-full text-left rounded-lg px-3 py-2.5 border border-border-subtle",
"grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3.5",
"enabled:hocus:bg-surface-highlight/50 enabled:hocus:border-border",
"outline-border-focus focus-visible:outline-2",
disabled && !busy && "opacity-disabled",
)}
>
{/* The banner theme tints this tile from the theme's own color for each option */}
<div
className={classNames(
`x-theme-banner--${color}`,
"size-10 rounded-md bg-surface-highlight grid place-items-center",
)}
>
{busy ? <LoadingIcon size="sm" /> : <Icon icon={icon} color={color} size="md" />}
</div>
<div className="min-w-0">
<div className="font-semibold text-text">{title}</div>
<div className="text-sm text-text-subtle">{description}</div>
</div>
<Icon
icon="chevron_right"
className="text-text-subtlest group-enabled:group-hover:text-text-subtle group-enabled:group-focus-visible:text-text-subtle"
/>
</button>
);
}
@@ -7,7 +7,6 @@ import { getRecentRequests } from "../hooks/useRecentRequests";
import { useRecentWorkspaces } from "../hooks/useRecentWorkspaces";
import { fireAndForget } from "../lib/fireAndForget";
import { router } from "../lib/router";
import { Onboarding } from "./Onboarding";
export function RedirectToLatestWorkspace() {
const workspaces = useAtomValue(workspacesAtom);
@@ -15,6 +14,10 @@ export function RedirectToLatestWorkspace() {
useEffect(() => {
if (workspaces.length === 0 || recentWorkspaces == null) {
console.log("No workspaces found to redirect to. Skipping.", {
workspaces,
recentWorkspaces,
});
return;
}
@@ -31,21 +34,11 @@ export function RedirectToLatestWorkspace() {
request_id: requestId,
};
// Whatever created the first workspace may have already navigated into it
const currentId = router.history.location.pathname.match(/^\/workspaces\/([^/]+)/)?.[1];
if (workspaces.some((w) => w.id === currentId)) return;
console.log("Redirecting to workspace", params, search);
await router.navigate({ to: "/workspaces/$workspaceId", params, search });
})(),
);
}, [recentWorkspaces, workspaces, workspaces.length]);
// The global models are loaded before the router mounts, so an empty list is a real
// first launch (or the last workspace was just deleted), not a store still loading
if (workspaces.length === 0) {
return <Onboarding />;
}
return null;
}
@@ -1,3 +1,4 @@
import { useSearch } from "@tanstack/react-router";
import { platform } from "@yaakapp-internal/platform";
import { useLicense } from "@yaakapp-internal/license";
import { pluginsAtom, settingsAtom } from "@yaakapp-internal/models";
@@ -19,8 +20,6 @@ import { SettingsProxy } from "./SettingsProxy";
import { SettingsTheme } from "./SettingsTheme";
interface Props {
tab?: SettingsTabWithSubtab | null;
/** Set when Settings is in a dialog rather than owning a window. */
hide?: () => void;
}
@@ -43,19 +42,25 @@ const tabs = [
TAB_LICENSE,
] as const;
export type SettingsTab = (typeof tabs)[number];
export type SettingsTabWithSubtab = SettingsTab | `${SettingsTab}:${string}`;
export default function Settings({ tab, hide }: Props) {
export default function Settings({ hide }: Props) {
const { tab: tabFromQuery } = useSearch({ from: "/workspaces/$workspaceId/settings" });
// Parse tab and subtab (e.g., "plugins:installed")
const [mainTab, subtab] = tab?.split(":") ?? [];
const [mainTab, subtab] = tabFromQuery?.split(":") ?? [];
const settings = useAtomValue(settingsAtom);
const plugins = useAtomValue(pluginsAtom);
const licenseCheck = useLicense();
// Close settings window on escape. In a dialog, the dialog handles Escape itself.
// Close settings window on escape
// TODO: Could this be put in a better place? Eg. in Rust key listener when creating the window
useKeyPressEvent("Escape", async () => {
if (hide == null) await platform.window.close();
if (hide != null) {
// It's being shown in a dialog, so close the dialog
hide();
} else {
// It's being shown in a window, so close the window
await platform.window.close();
}
});
return (
@@ -85,7 +90,7 @@ export default function Settings({ tab, hide }: Props) {
)}
<Tabs
layout="horizontal"
defaultValue={mainTab}
defaultValue={mainTab || tabFromQuery}
addBorders
tabListClassName="min-w-40 bg-surface x-theme-sidebar border-r border-border pl-3"
label="Settings"
@@ -124,7 +124,7 @@ export function SettingsHotkeys() {
<HotkeyRow
key={action}
action={action}
currentKeys={hotkeys[action] ?? []}
currentKeys={hotkeys[action]}
defaultKeys={defaultHotkeys[action]}
onSave={async (keys) => {
const newHotkeys = { ...settings.hotkeys };
+3 -7
View File
@@ -941,14 +941,10 @@ const sidebarTreeAtom = atom<
if (node.children != null) {
childItems.sort((a, b) => {
if (a.sortPriority !== b.sortPriority) {
return a.sortPriority - b.sortPriority;
if (a.sortPriority === b.sortPriority) {
return a.updatedAt > b.updatedAt ? 1 : -1;
}
// Keep equal-priority items in a stable order when they are edited.
if (a.createdAt !== b.createdAt) {
return a.createdAt > b.createdAt ? 1 : -1;
}
return a.id === b.id ? 0 : a.id > b.id ? 1 : -1;
return a.sortPriority - b.sortPriority;
});
for (const item of childItems) {
@@ -1,11 +1,11 @@
import type { Workspace } from "@yaakapp-internal/models";
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import { Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useState } from "react";
import { switchWorkspace } from "../commands/switchWorkspace";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { DialogFooter } from "./core/Dialog";
interface Props {
hide: () => void;
@@ -16,33 +16,45 @@ export function SwitchWorkspaceDialog({ hide, workspace }: Props) {
const settings = useAtomValue(settingsAtom);
const [remember, setRemember] = useState<boolean>(false);
const open = async (inNewWindow: boolean) => {
hide();
switchWorkspace.mutate({ workspaceId: workspace.id, inNewWindow });
if (remember) {
await patchModel(settings, { openWorkspaceNewWindow: inNewWindow });
}
};
return (
<VStack space={3}>
<p>
Where would you like to open <InlineCode>{workspace.name}</InlineCode>?
</p>
<HStack space={2} justifyContent="start" className="flex-row-reverse">
<Button
className="focus"
color="primary"
onClick={async () => {
hide();
switchWorkspace.mutate({ workspaceId: workspace.id, inNewWindow: false });
if (remember) {
await patchModel(settings, { openWorkspaceNewWindow: false });
}
}}
>
This Window
</Button>
<Button
className="focus"
color="secondary"
rightSlot={<Icon icon="external_link" />}
onClick={async () => {
hide();
switchWorkspace.mutate({ workspaceId: workspace.id, inNewWindow: true });
if (remember) {
await patchModel(settings, { openWorkspaceNewWindow: true });
}
}}
>
New Window
</Button>
</HStack>
{settings && (
<Checkbox checked={remember} title="Remember my choice" onChange={setRemember} />
<HStack justifyContent="end">
<Checkbox checked={remember} title="Remember my choice" onChange={setRemember} />
</HStack>
)}
<DialogFooter
inline
actions={[
{
label: "New Window",
rightSlot: <Icon icon="external_link" />,
onClick: () => open(true),
},
{ label: "This Window", color: "primary", autoFocus: true, onClick: () => open(false) },
]}
/>
</VStack>
);
}
@@ -11,7 +11,7 @@ import type { FnArg, Tokens } from "@yaakapp-internal/templates";
import { parseTemplate } from "@yaakapp-internal/templates";
import { HStack, InlineCode, LoadingIcon, useDebouncedValue } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useEffect, useId, useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
import { useRenderTemplate } from "../hooks/useRenderTemplate";
import { useTemplateFunctionConfig } from "../hooks/useTemplateFunctionConfig";
@@ -24,7 +24,7 @@ import { showDialog } from "../lib/dialog";
import { convertTemplateToInsecure } from "../lib/encryption";
import { jotaiStore } from "../lib/jotai";
import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
import { DialogFooter } from "./core/Dialog";
import { Button } from "./core/Button";
import { collectArgumentValues } from "./core/Editor/twig/util";
import { IconButton } from "./core/IconButton";
import { PlainInput } from "./core/PlainInput";
@@ -91,7 +91,6 @@ function InitializedTemplateFunctionDialog({
const previewType = ogPreviewType == null ? "live" : ogPreviewType;
const [showSecretsInPreview, toggleShowSecretsInPreview] = useToggle(false);
const [argValues, setArgValues] = useState<Record<string, string | boolean>>(initialArgValues);
const formId = useId();
const tokens: Tokens = useMemo(() => {
const argTokens: FnArg[] = Object.keys(argValues).map((name) => ({
@@ -161,14 +160,13 @@ function InitializedTemplateFunctionDialog({
return (
<form
id={formId}
className="grid grid-rows-[minmax(0,1fr)_auto] h-full"
className="grid grid-rows-[minmax(0,1fr)_auto_auto] h-full max-h-[90vh]"
onSubmit={(e) => {
e.preventDefault();
handleDone();
}}
>
<div className="overflow-y-auto h-full">
<div className="overflow-y-auto h-full px-6">
{name === "secure" ? (
<PlainInput
required
@@ -190,71 +188,75 @@ function InitializedTemplateFunctionDialog({
/>
)}
</div>
{previewType !== "none" ? (
<div className="w-full grid grid-cols-1 grid-rows-[auto_auto] border-t border-t-border pt-3">
<HStack space={0.5}>
<HStack className="text-sm text-text-subtle" space={1.5}>
Rendered Preview
{rendered.isLoading && <LoadingIcon size="xs" />}
</HStack>
<IconButton
size="xs"
iconSize="sm"
icon={showSecretsInPreview ? "lock" : "lock_open"}
title={showSecretsInPreview ? "Show preview" : "Hide preview"}
onClick={toggleShowSecretsInPreview}
className={classNames(
"ml-auto text-text-subtlest",
!dataContainsSecrets && "invisible",
)}
/>
</HStack>
<div className="relative w-full max-h-40">
<InlineCode
className={classNames(
"block whitespace-pre-wrap select-text! cursor-text max-h-40 overflow-auto hide-scrollbars border-text-subtlest!",
tooLarge && "italic text-danger",
)}
>
{rendered.error || tagText.error ? (
<em className="text-danger">
{`${rendered.error || tagText.error}`.replace(/^Render Error: /, "")}
</em>
) : dataContainsSecrets && !showSecretsInPreview ? (
<span className="italic text-text-subtle">
------ sensitive values hidden ------
</span>
) : tooLarge ? (
"too large to preview"
) : (
rendered.data || <>&nbsp;</>
)}
</InlineCode>
<div className="absolute right-0.5 top-0 bottom-0 flex items-center">
<div className="px-6 border-t border-t-border pt-3 pb-6 bg-surface-highlight w-full flex flex-col gap-4">
{previewType !== "none" ? (
<div className="w-full grid grid-cols-1 grid-rows-[auto_auto]">
<HStack space={0.5}>
<HStack className="text-sm text-text-subtle" space={1.5}>
Rendered Preview
{rendered.isLoading && <LoadingIcon size="xs" />}
</HStack>
<IconButton
size="xs"
icon="refresh"
className="text-text-subtle"
title="Refresh preview"
spin={rendered.isPending}
onClick={() => {
setRenderKey(new Date().toISOString());
}}
iconSize="sm"
icon={showSecretsInPreview ? "lock" : "lock_open"}
title={showSecretsInPreview ? "Show preview" : "Hide preview"}
onClick={toggleShowSecretsInPreview}
className={classNames(
"ml-auto text-text-subtlest",
!dataContainsSecrets && "invisible",
)}
/>
</HStack>
<div className="relative w-full max-h-40">
<InlineCode
className={classNames(
"block whitespace-pre-wrap select-text! cursor-text max-h-40 overflow-auto hide-scrollbars border-text-subtlest!",
tooLarge && "italic text-danger",
)}
>
{rendered.error || tagText.error ? (
<em className="text-danger">
{`${rendered.error || tagText.error}`.replace(/^Render Error: /, "")}
</em>
) : dataContainsSecrets && !showSecretsInPreview ? (
<span className="italic text-text-subtle">
------ sensitive values hidden ------
</span>
) : tooLarge ? (
"too large to preview"
) : (
rendered.data || <>&nbsp;</>
)}
</InlineCode>
<div className="absolute right-0.5 top-0 bottom-0 flex items-center">
<IconButton
size="xs"
icon="refresh"
className="text-text-subtle"
title="Refresh preview"
spin={rendered.isPending}
onClick={() => {
setRenderKey(new Date().toISOString());
}}
/>
</div>
</div>
</div>
) : (
<span />
)}
<div className="flex justify-stretch w-full grow gap-2 *:flex-1">
{templateFunction.data.name === "secure" && (
<Button variant="border" color="secondary" onClick={setupOrConfigureEncryption}>
Reveal Encryption Key
</Button>
)}
<Button type="submit" color="primary">
Save
</Button>
</div>
) : (
<span />
)}
<DialogFooter
actions={[
...(templateFunction.data.name === "secure"
? [{ label: "Reveal Encryption Key", onClick: setupOrConfigureEncryption }]
: []),
{ label: "Save", color: "primary", form: formId },
]}
/>
</div>
</form>
);
}
@@ -270,6 +272,7 @@ TemplateFunctionDialog.show = (
id: `template-function-${Math.random()}`, // Allow multiple at once
size: "md",
className: "h-240",
noPadding: true,
title: <InlineCode>{fn.name}(…)</InlineCode>,
description: fn.description,
render: ({ hide }) => {
@@ -110,8 +110,6 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
[activeRequest.url, activeRequest.urlParameters, handleRenamePathPlaceholder],
);
const hasDescription = activeRequest.description.trim().length > 0;
const tabs = useMemo<TabItem[]>(() => {
return [
{
@@ -133,10 +131,9 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
{
value: TAB_DESCRIPTION,
label: "Info",
rightSlot: hasDescription && <CountBadge count={true} />,
},
];
}, [authTab, hasDescription, headersTab, numSettingsOverrides, urlParameterPairs.length]);
}, [authTab, headersTab, numSettingsOverrides, urlParameterPairs.length]);
const { activeResponse } = usePinnedHttpResponse(activeRequestId);
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
+2 -2
View File
@@ -40,7 +40,6 @@ import { ErrorBoundary } from "./ErrorBoundary";
import { FolderLayout } from "./FolderLayout";
import { GrpcConnectionLayout } from "./GrpcConnectionLayout";
import { HttpRequestLayout } from "./HttpRequestLayout";
import { RedirectToLatestWorkspace } from "./RedirectToLatestWorkspace";
import Sidebar from "./Sidebar";
import { SidebarActions } from "./SidebarActions";
import { WebsocketRequestLayout } from "./WebsocketRequestLayout";
@@ -67,8 +66,9 @@ export function Workspace() {
return { background };
}, [activeEnvironment?.color]);
// We're loading still
if (workspaces.length === 0) {
return <RedirectToLatestWorkspace />;
return null;
}
const header = (
@@ -1,114 +0,0 @@
import { Icon } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { ReactNode } from "react";
import { useState } from "react";
import type { CheckboxProps } from "./Checkbox";
import { Checkbox } from "./Checkbox";
export interface CheckboxTreeNode<T> {
key: string;
data: T;
children: CheckboxTreeNode<T>[];
}
interface Props<T> {
node: CheckboxTreeNode<T>;
depth?: number;
/** Return "hidden" to render row alignment space instead of a checkbox */
checked: (node: CheckboxTreeNode<T>) => CheckboxProps["checked"] | "hidden";
onCheck: (node: CheckboxTreeNode<T>, checked: boolean) => void;
checkboxTitle?: (node: CheckboxTreeNode<T>) => string;
isCheckboxDisabled?: (node: CheckboxTreeNode<T>) => boolean;
/** An irrelevant row is hidden unless one of its descendants is relevant */
isRelevant: (node: CheckboxTreeNode<T>) => boolean;
/** A node that starts collapsed, so a large subtree doesn't crowd out the rest */
isCollapsedByDefault?: (node: CheckboxTreeNode<T>) => boolean;
renderRow: (node: CheckboxTreeNode<T>) => ReactNode;
onSelectRow?: (node: CheckboxTreeNode<T>) => void;
canSelectRow?: (node: CheckboxTreeNode<T>) => boolean;
isRowSelected?: (node: CheckboxTreeNode<T>) => boolean;
}
export function CheckboxTree<T>(props: Props<T>) {
const { node, depth = 0 } = props;
const [collapsed, setCollapsed] = useState<boolean>(
() => props.isCollapsedByDefault?.(node) ?? false,
);
if (!hasRelevantNode(node, props.isRelevant)) return null;
const checked = props.checked(node);
const selected = props.isRowSelected?.(node) ?? false;
const selectable = props.onSelectRow != null && (props.canSelectRow?.(node) ?? true);
const hasVisibleChildren = node.children.some((c) => hasRelevantNode(c, props.isRelevant));
const rowContent = (
<div className="flex-1 min-w-0 flex items-center gap-1 px-1 py-0.5 text-left">
{props.renderRow(node)}
</div>
);
return (
<div
className={classNames(
depth > 0 && "pl-4 ml-2 border-l border-dashed border-border-subtle relative",
)}
>
<div
className={classNames(
"relative flex gap-1 w-full h-xs items-center",
selected ? "text-text" : "text-text-subtle",
)}
>
{selected && (
<div className="absolute left-[-100vw] right-0 top-0 bottom-0 bg-surface-active opacity-30 -z-10" />
)}
{hasVisibleChildren ? (
<button
type="button"
aria-label={collapsed ? "Expand" : "Collapse"}
aria-expanded={!collapsed}
className="shrink-0 text-text-subtlest hocus:text-text"
onClick={() => setCollapsed((v) => !v)}
>
<Icon size="sm" icon={collapsed ? "chevron_right" : "chevron_down"} />
</button>
) : (
<span aria-hidden className="w-4 shrink-0" />
)}
{checked === "hidden" ? (
<span aria-hidden className="w-4 mr-0.5 shrink-0" />
) : (
<Checkbox
checked={checked}
title={props.checkboxTitle?.(node) ?? "Toggle"}
hideLabel
disabled={props.isCheckboxDisabled?.(node)}
onChange={(checked) => props.onCheck(node, checked)}
/>
)}
{selectable ? (
<button
type="button"
className="flex-1 min-w-0 flex text-left"
onClick={() => props.onSelectRow?.(node)}
>
{rowContent}
</button>
) : (
rowContent
)}
</div>
{!collapsed &&
node.children.map((child) => (
<CheckboxTree key={child.key} {...props} node={child} depth={depth + 1} />
))}
</div>
);
}
function hasRelevantNode<T>(
node: CheckboxTreeNode<T>,
isRelevant: (node: CheckboxTreeNode<T>) => boolean,
): boolean {
return isRelevant(node) || node.children.some((c) => hasRelevantNode(c, isRelevant));
}
-32
View File
@@ -1,32 +0,0 @@
import type { Color } from "@yaakapp-internal/plugins";
import classNames from "classnames";
import type { ReactNode } from "react";
interface Props {
color?: Color | "default";
className?: string;
children: ReactNode;
}
/** A small tinted label, like a Gmail or Trello label */
export function Chip({ color = "default", className, children }: Props) {
return (
<span
className={classNames(
className,
"text-xs font-medium rounded-md px-2 py-0.5 bg-current/10 border border-current/10 shrink-0 whitespace-nowrap",
"inline-flex items-center gap-1",
color === "default" && "text-text-subtle",
color === "primary" && "text-primary",
color === "secondary" && "text-secondary",
color === "info" && "text-info",
color === "success" && "text-success",
color === "notice" && "text-notice",
color === "warning" && "text-warning",
color === "danger" && "text-danger",
)}
>
{children}
</span>
);
}
+12 -17
View File
@@ -1,8 +1,9 @@
import type { Color } from "@yaakapp-internal/plugins";
import { HStack } from "@yaakapp-internal/ui";
import type { FormEvent } from "react";
import { useId, useState } from "react";
import { useState } from "react";
import { CopyIconButton } from "../CopyIconButton";
import { DialogFooter } from "./Dialog";
import { Button } from "./Button";
import { PlainInput } from "./PlainInput";
export interface ConfirmProps {
@@ -21,7 +22,6 @@ export function Confirm({
color = "primary",
}: ConfirmProps) {
const [confirm, setConfirm] = useState<string>("");
const formId = useId();
const handleHide = () => {
onResult(false);
onHide();
@@ -38,7 +38,7 @@ export function Confirm({
};
return (
<form id={formId} className="flex flex-col" onSubmit={handleSuccess}>
<form className="flex flex-col" onSubmit={handleSuccess}>
{requireTyping && (
<PlainInput
autoFocus
@@ -61,19 +61,14 @@ export function Confirm({
}
/>
)}
<DialogFooter
inline
actions={[
{ label: "Cancel", onClick: handleHide },
{
label: confirmText ?? "Confirm",
color,
form: formId,
disabled: !didConfirm,
autoFocus: !requireTyping,
},
]}
/>
<HStack space={2} justifyContent="start" className="mt-2 mb-4 flex-row-reverse">
<Button type="submit" color={color} disabled={!didConfirm}>
{confirmText ?? "Confirm"}
</Button>
<Button onClick={handleHide} variant="border">
Cancel
</Button>
</HStack>
</form>
);
}
+9 -80
View File
@@ -1,24 +1,20 @@
import type { DialogSize } from "@yaakapp-internal/plugins";
import { Heading, HStack, Overlay } from "@yaakapp-internal/ui";
import { Heading, Overlay } from "@yaakapp-internal/ui";
import classNames from "classnames";
import * as m from "motion/react-m";
import type { ReactNode } from "react";
import { createContext, useContext, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { Button, type ButtonProps } from "./Button";
import { useMemo } from "react";
import { IconButton } from "./IconButton";
export interface DialogProps {
children: ReactNode;
open: boolean;
onClose?: () => void;
/** Block dismissal from the backdrop, Escape key, and built-in close button. */
disableClose?: boolean;
disableBackdropClose?: boolean;
title?: ReactNode;
description?: ReactNode;
className?: string;
size?: DialogSize;
/** Hide the built-in close button without changing backdrop or Escape behavior. */
hideX?: boolean;
noPadding?: boolean;
noScroll?: boolean;
@@ -31,7 +27,7 @@ export function Dialog({
size = "full",
open,
onClose,
disableClose,
disableBackdropClose,
title,
description,
hideX,
@@ -40,14 +36,13 @@ export function Dialog({
vAlign = "center",
}: DialogProps) {
const titleId = useMemo(() => Math.random().toString(36).slice(2), []);
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
const descriptionId = useMemo(
() => (description ? Math.random().toString(36).slice(2) : undefined),
[description],
);
return (
<Overlay open={open} onClose={disableClose ? undefined : onClose} portalName="dialog">
<Overlay open={open} onClose={disableBackdropClose ? undefined : onClose} portalName="dialog">
<div
role="dialog"
className={classNames(
@@ -63,7 +58,7 @@ export function Dialog({
// NOTE: We handle Escape on the element itself so that it doesn't close multiple
// dialogs and can be intercepted by children if needed.
if (e.key === "Escape") {
if (!disableClose) onClose?.();
onClose?.();
e.stopPropagation();
e.preventDefault();
}
@@ -74,7 +69,7 @@ export function Dialog({
animate={{ top: 0, scale: 1 }}
className={classNames(
className,
"grid grid-rows-[auto_auto_minmax(0,1fr)_auto]",
"grid grid-rows-[auto_auto_minmax(0,1fr)]",
"grid-cols-1", // must be here for inline code blocks to correctly break words
"relative bg-surface pointer-events-auto",
"rounded-lg",
@@ -111,13 +106,11 @@ export function Dialog({
!noScroll && "overflow-y-auto overflow-x-hidden",
)}
>
<DialogFooterSlot.Provider value={footerEl}>{children}</DialogFooterSlot.Provider>
{children}
</div>
<div ref={setFooterEl} className="contents" />
{/*Put close at the end so that it's the last thing to be tabbed to*/}
{!disableClose && !hideX && (
{!hideX && (
<div className="ml-auto absolute right-1 top-1">
<IconButton
className="opacity-70 hover:opacity-100"
@@ -134,67 +127,3 @@ export function Dialog({
</Overlay>
);
}
const DialogFooterSlot = createContext<HTMLDivElement | null>(null);
export interface DialogFooterAction {
label: string;
onClick?: () => void;
/** Submit the form with this id, which can be anywhere in the dialog body */
form?: string;
color?: ButtonProps["color"];
variant?: ButtonProps["variant"];
disabled?: boolean;
isLoading?: boolean;
autoFocus?: boolean;
leftSlot?: ReactNode;
rightSlot?: ReactNode;
}
/**
* The action row at the bottom of a dialog. Render it anywhere in the dialog's content and it is
* placed below the scrolling body, outside its padding. `inline` drops the divider for small
* dialogs whose body never scrolls.
*/
export function DialogFooter({
actions,
leftSlot,
inline,
}: {
actions: DialogFooterAction[];
leftSlot?: ReactNode;
inline?: boolean;
}) {
const slot = useContext(DialogFooterSlot);
const footer = (
<footer
className={classNames(
"px-6 flex items-center gap-3",
inline ? "pt-1 pb-4" : "py-3 border-t border-border-subtle",
)}
>
{leftSlot != null && <div className="mr-auto min-w-0">{leftSlot}</div>}
<HStack space={2} justifyContent="end" className="ml-auto shrink-0">
{actions.map((action, i) => (
<Button
// Labels change with state ("Cloning...", "Apply 3 Changes"), so they can't be keys
key={i}
type={action.form != null ? "submit" : "button"}
form={action.form}
color={action.color ?? "secondary"}
variant={action.variant ?? (action.color == null ? "border" : "solid")}
disabled={action.disabled}
isLoading={action.isLoading}
autoFocus={action.autoFocus}
leftSlot={action.leftSlot}
rightSlot={action.rightSlot}
onClick={action.onClick}
>
{action.label}
</Button>
))}
</HStack>
</footer>
);
return slot == null ? footer : createPortal(footer, slot);
}
@@ -601,8 +601,6 @@ function getExtensions({
EditorView.contentAttributes.of({
autocapitalize: "off",
autocorrect: "off",
// Keeps macOS Writing Tools from offering to write code for us
writingsuggestions: "false",
}),
EditorView.domEventHandlers({
focus: () => {
@@ -1,29 +1,9 @@
import type { Diagnostic } from "@codemirror/lint";
import type { EditorView } from "@codemirror/view";
import { type ParseError, parse, printParseErrorCode } from "jsonc-parser";
import { parse as jsonLintParse } from "@prantlf/jsonlint";
const TEMPLATE_SYNTAX_REGEX = /\$\{\[[\s\S]*?]}/g;
// jsonc-parser reports error codes, so these are the words the editor shows for them
const MESSAGES: Record<string, string> = {
InvalidSymbol: "Invalid symbol",
InvalidNumberFormat: "Invalid number format",
PropertyNameExpected: "Property name expected",
ValueExpected: "Value expected",
ColonExpected: "Colon expected",
CommaExpected: "Comma expected",
CloseBraceExpected: "Closing brace expected",
CloseBracketExpected: "Closing bracket expected",
EndOfFileExpected: "End of file expected",
InvalidCommentToken: "Comments are not allowed",
UnexpectedEndOfComment: "Unexpected end of comment",
UnexpectedEndOfString: "Unexpected end of string",
UnexpectedEndOfNumber: "Unexpected end of number",
InvalidUnicode: "Invalid unicode sequence",
InvalidEscapeCharacter: "Invalid escape character",
InvalidCharacter: "Invalid character",
};
interface JsonLintOptions {
allowComments?: boolean;
allowTrailingCommas?: boolean;
@@ -31,30 +11,34 @@ interface JsonLintOptions {
export function jsonParseLinter(options?: JsonLintOptions) {
return (view: EditorView): Diagnostic[] => {
const doc = view.state.doc.toString();
if (doc.trim() === "") return [];
try {
const doc = view.state.doc.toString();
// We need lint to not break on stuff like {"foo:" ${[ ... ]}} so we'll replace all template
// syntax with repeating `1` characters, so it's valid JSON and the position is still correct.
const escapedDoc = doc.replace(TEMPLATE_SYNTAX_REGEX, (m) => "1".repeat(m.length));
jsonLintParse(escapedDoc, {
mode: (options?.allowComments ?? true) ? "cjson" : "json",
ignoreTrailingCommas: options?.allowTrailingCommas ?? false,
});
// oxlint-disable-next-line no-explicit-any
} catch (err: any) {
if (!("location" in err)) {
return [];
}
// We need lint to not break on stuff like {"foo:" ${[ ... ]}} so we'll replace all template
// syntax with repeating `1` characters, so it's valid JSON and the position is still correct.
const escapedDoc = doc.replace(TEMPLATE_SYNTAX_REGEX, (m) => "1".repeat(m.length));
const errors: ParseError[] = [];
parse(escapedDoc, errors, {
allowTrailingComma: options?.allowTrailingCommas ?? false,
disallowComments: !(options?.allowComments ?? true),
});
// Later errors are mostly consequences of the first one, so only that one is shown
const error = errors[0];
if (error == null) return [];
return [
{
from: error.offset,
to: error.offset + error.length,
severity: "error",
message: MESSAGES[printParseErrorCode(error.error)] ?? "Invalid JSON",
},
];
// const line = location?.start?.line;
// const column = location?.start?.column;
if (err.location.start.offset) {
return [
{
from: err.location.start.offset,
to: err.location.start.offset,
severity: "error",
message: err.message,
},
];
}
}
return [];
};
}
@@ -1,41 +0,0 @@
import { syntaxTree } from "@codemirror/language";
import type { Extension } from "@codemirror/state";
import type { EditorView, ViewUpdate } from "@codemirror/view";
import { ViewPlugin } from "@codemirror/view";
import type { JsonPathSegment } from "./jsonPath";
import { jsonPathSegmentsAt } from "./jsonPath";
export interface BreadcrumbUpdate {
/** Path to the cursor, or `null` when the document isn't JSON. */
segments: JsonPathSegment[] | null;
}
/**
* Reports the JSON path under the cursor when the editor is created (it may restore a
* cached selection), and whenever the selection, document, or parsed tree changes. The
* tree matters because large documents parse in the background, so a path read early
* can be wrong until the parser reaches the cursor.
*
* The callback is not fired on blur: moving focus to the filter box (by clicking
* a crumb) must not erase the breadcrumb that was just acted on.
*/
export function jsonBreadcrumbExtension(onUpdate: (update: BreadcrumbUpdate) => void): Extension {
const report = (view: EditorView) => {
onUpdate({ segments: jsonPathSegmentsAt(view.state, view.state.selection.main.head) });
};
return ViewPlugin.define((view) => {
report(view);
return {
update(update: ViewUpdate) {
if (
update.selectionSet ||
update.docChanged ||
syntaxTree(update.startState) !== syntaxTree(update.state)
) {
report(update.view);
}
},
};
});
}
@@ -1,136 +0,0 @@
import { EditorState } from "@codemirror/state";
import { jsonc } from "@shopify/lang-jsonc";
import { forceParsing } from "@codemirror/language";
import { describe, expect, test } from "vite-plus/test";
import { jsonPathSegmentsAt, jsonPathToSegments, segmentsToJsonPath } from "./jsonPath";
function stateFor(doc: string): EditorState {
const state = EditorState.create({ doc, extensions: [jsonc()] });
// Ensure the whole doc is parsed so resolveInner sees a complete tree.
forceParsing({ state } as never, doc.length, 5000);
return state;
}
/** JSONPath produced when the cursor sits just after the first occurrence of `needle`. */
function pathAt(doc: string, needle: string): string {
const pos = doc.indexOf(needle) + needle.length;
const segs = jsonPathSegmentsAt(stateFor(doc), pos);
if (segs == null) throw new Error("no JSON tree");
return segmentsToJsonPath(segs);
}
/** JSONPath produced when the cursor sits just before the first occurrence of `needle`. */
function pathBefore(doc: string, needle: string): string {
const pos = doc.indexOf(needle);
const segs = jsonPathSegmentsAt(stateFor(doc), pos);
if (segs == null) throw new Error("no JSON tree");
return segmentsToJsonPath(segs);
}
describe("jsonPathSegmentsAt", () => {
test("nested object keys", () => {
const doc = `{ "address": { "geo": { "lat": "-37" } } }`;
expect(pathAt(doc, `-37`)).toBe(`$.address.geo.lat`);
});
test("array element index", () => {
const doc = `{ "featured": [ { "id": 1 }, { "id": 2 } ] }`;
expect(pathAt(doc, `2`)).toBe(`$.featured[1].id`);
});
test("root array element", () => {
const doc = `[ { "id": 1 }, { "id": 2 } ]`;
expect(pathAt(doc, `"id": 2`)).toBe(`$[1].id`);
});
test("key containing a dot uses bracket-quote notation", () => {
const doc = `{ "sort": { "link": { "filter": { "category.id": [ "100" ] } } } }`;
expect(pathAt(doc, `"100"`)).toBe(`$.sort.link.filter["category.id"][0]`);
});
test("cursor on a property name resolves that key", () => {
const doc = `{ "outer": { "inner": 5 } }`;
expect(pathAt(doc, `"inner"`)).toBe(`$.outer.inner`);
});
test("deeply nested arrays and objects", () => {
const doc = `{ "a": [ { "b": [ 10, 20, 30 ] } ] }`;
expect(pathAt(doc, `30`)).toBe(`$.a[0].b[2]`);
});
test("root scalar yields the root path", () => {
expect(pathAt(`"hello"`, `hello`)).toBe(`$`);
});
test("caret just before a key still resolves that key", () => {
// The character to the left is the object's whitespace, not the property.
const doc = `[\n {\n "userId": 1,\n "body": "x"\n }\n]`;
expect(pathBefore(doc, `"body"`)).toBe(`$[0].body`);
});
test("caret in a line's indentation resolves that line's member", () => {
// Caret in the blank indentation of the `title` line (structurally the
// object's whitespace) reads as being on `title`, not a neighbour.
const doc = `[\n {\n "userId": 1,\n "id": 1,\n "title": "x"\n }\n]`;
const pos = doc.indexOf(`"title"`) - 2; // two spaces into the title line
const segs = jsonPathSegmentsAt(stateFor(doc), pos);
expect(segs == null ? null : segmentsToJsonPath(segs)).toBe(`$[0].title`);
});
test("caret at the end of an array element keeps the element index", () => {
// The caret sits after `}` where the left token is the object's close brace.
const doc = `[\n { "id": 1 },\n { "id": 2 }\n]`;
const pos = doc.indexOf(`},`) + 1;
const segs = jsonPathSegmentsAt(stateFor(doc), pos);
expect(segs == null ? null : segmentsToJsonPath(segs)).toBe(`$[0]`);
});
test("non-JSON documents return null", () => {
const state = EditorState.create({ doc: `<xml/>` });
expect(jsonPathSegmentsAt(state, 3)).toBeNull();
});
});
describe("segmentsToJsonPath", () => {
test("empty segment list is the root", () => {
expect(segmentsToJsonPath([])).toBe(`$`);
});
test("partial path via count", () => {
const segs = [
{ kind: "key", key: "a" },
{ kind: "index", index: 2 },
{ kind: "key", key: "b" },
] as const;
expect(segmentsToJsonPath([...segs], 2)).toBe(`$.a[2]`);
});
});
describe("jsonPathToSegments", () => {
test("round-trips paths built by segmentsToJsonPath", () => {
for (const path of ["$", "$[0]", "$[0].id", '$.data["user.name"][12]', '$["say \\"hi\\""]']) {
const segments = jsonPathToSegments(path);
expect(segments).not.toBeNull();
expect(segmentsToJsonPath(segments ?? [])).toBe(path);
}
});
test("returns null for paths that don't name a single location", () => {
for (const path of [
"$..id",
"$[*]",
"$.items[?(@.id)]",
"$[0:2]",
"items[0]",
"$.a b",
'$["\\q"]',
'$["unterminated]',
'$["line\nbreak"]',
"$[01]",
"$[9007199254740993]",
"$.$",
]) {
expect(jsonPathToSegments(path)).toBeNull();
}
});
});
@@ -1,102 +0,0 @@
import { syntaxTree } from "@codemirror/language";
import type { EditorState } from "@codemirror/state";
import type { SyntaxNode } from "@lezer/common";
import type { JsonPathSegment } from "@yaakapp-internal/lib/jsonPath";
export { jsonPathToSegments } from "@yaakapp-internal/lib/jsonPath";
export type { JsonPathSegment } from "@yaakapp-internal/lib/jsonPath";
// Lezer node names from the JSONC grammar (@shopify/lang-jsonc). A JSON value is
// exactly one of these; everything else in the tree is punctuation or a comment.
const VALUE_NODES = new Set(["Object", "Array", "String", "Number", "True", "False", "Null"]);
// The nodes with children that a caret can land "between" — its whitespace and
// punctuation belong to the container, not to any value inside it.
const CONTAINER_NODES = new Set(["Object", "Array", "JsoncText"]);
/** Read a `PropertyName` (a quoted JSON string) back to its raw key. */
function keyFromPropertyName(node: SyntaxNode, state: EditorState): string {
const raw = state.doc.sliceString(node.from, node.to);
try {
return JSON.parse(raw) as string;
} catch {
// A half-typed or malformed key still deserves a best-effort label.
return raw.replace(/^"|"$/g, "");
}
}
/** Index of `child` among the value elements of its parent `Array` node. */
function indexInArray(array: SyntaxNode, child: SyntaxNode): number {
let idx = 0;
for (let c = array.firstChild; c != null; c = c.nextSibling) {
if (c.from === child.from && c.to === child.to) return idx;
if (VALUE_NODES.has(c.name)) idx++;
}
return idx;
}
/**
* The path from the document root to the member on the caret's line, or `null`
* when the document has no JSON tree (a different language, or empty). An empty
* array means the caret sits at the root value itself.
*
* A caret exactly on a token uses that token. A caret in a line's whitespace
* resolves to the enclosing object or array, so it's re-anchored to the first
* non-whitespace character on that line — in pretty-printed JSON each member
* starts its own line, so that's the member's own token. This tracks "the line
* I'm on" without guessing between neighbouring keys.
*
* From the anchor, each ancestor contributes a segment: a `Property` its key, a
* value directly inside an `Array` its index. Collected deepest-first, reversed.
*/
export function jsonPathSegmentsAt(state: EditorState, pos: number): JsonPathSegment[] | null {
const tree = syntaxTree(state);
if (tree.topNode.name !== "JsoncText") return null;
let node: SyntaxNode = tree.resolveInner(pos, -1);
if (CONTAINER_NODES.has(node.name)) {
const line = state.doc.lineAt(pos);
const indent = line.text.length - line.text.trimStart().length;
if (indent < line.text.length) {
node = tree.resolveInner(line.from + indent, 1);
}
}
const segments: JsonPathSegment[] = [];
let cur: SyntaxNode | null = node;
while (cur != null) {
const parent: SyntaxNode | null = cur.parent;
if (cur.name === "Property") {
const nameNode = cur.getChild("PropertyName");
if (nameNode != null) {
segments.push({ kind: "key", key: keyFromPropertyName(nameNode, state) });
}
} else if (parent != null && parent.name === "Array" && VALUE_NODES.has(cur.name)) {
segments.push({ kind: "index", index: indexInArray(parent, cur) });
}
cur = parent;
}
segments.reverse();
return segments;
}
const BARE_KEY = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
/** A single segment as JSONPath, choosing dot vs. bracket form for a key. */
function segmentToJsonPath(segment: JsonPathSegment): string {
if (segment.kind === "index") return `[${segment.index}]`;
// jsonpath-plus interprets a bare `$` as its root operator.
if (BARE_KEY.test(segment.key) && segment.key !== "$") return `.${segment.key}`;
// Anything with a dot, space, quote, etc. must be a quoted bracket accessor.
// JSON.stringify gives correct double-quoting and escaping, matching the
// convention the JSONPath filter box already displays.
return `[${JSON.stringify(segment.key)}]`;
}
/**
* Build a JSONPath expression for the first `count` segments (all of them by
* default). `segmentsToJsonPath([])` is the root, `$`.
*/
export function segmentsToJsonPath(segments: JsonPathSegment[], count = segments.length): string {
return "$" + segments.slice(0, count).map(segmentToJsonPath).join("");
}
@@ -1,93 +0,0 @@
import { CompletionContext } from "@codemirror/autocomplete";
import { EditorState } from "@codemirror/state";
import type { EditorView } from "@codemirror/view";
import { describe, expect, it, vi } from "vite-plus/test";
import { jsonPathCompletion, jsonPathCompletionTarget } from "./pathCompletion";
import type { JsonPathChildren } from "./pathCompletion";
const root: JsonPathChildren = {
children: [
{ label: "users", selector: ".users", kind: "array" },
{ label: "total", selector: ".total", kind: "number" },
{ label: "a.b", selector: '["a.b"]', kind: "text" },
{ label: "a,b", selector: '["a,b"]', kind: "text" },
],
truncated: false,
};
function context(text: string, explicit = false, pos = text.length) {
return new CompletionContext(EditorState.create({ doc: text }), pos, explicit);
}
describe("progressive JSONPath completion", () => {
it.each([
["", "$", ""],
["$", "$", ""],
["$.", "$", "."],
["us", "$", "us"],
["users.na", "$.users", ".na"],
["$.users[", "$.users", "["],
["$.users[0].na", "$.users[0]", ".na"],
["$.users[*].", "$.users[*]", "."],
['$["a.b"].', '$["a.b"]', "."],
['$["a.b', "$", '["a.b'],
["$['a.b", "$", "['a.b"],
['$["a,b', "$", '["a,b'],
['$.users[?(@.name == "a.b")].', '$.users[?(@.name == "a.b")]', "."],
])("finds the next level in %s", (text, parent, fragment) => {
expect(jsonPathCompletionTarget(text)).toEqual({ parent, fragment });
});
it("loads only the parent, filters the typed prefix, and inserts a complete escaped path", async () => {
const load = vi.fn(async () => root);
const complete = jsonPathCompletion(load);
const result = await complete(context("us"));
expect(load).toHaveBeenCalledExactlyOnceWith("$");
expect(result?.options.map((o) => o.label)).toEqual(["users"]);
const quoted = await complete(context('$["a.b'));
const option = quoted!.options[0]!;
const dispatch = vi.fn();
expect(option.label).toBe("a.b");
if (typeof option.apply !== "function") throw new Error("Expected a completion function");
option.apply({ dispatch } as unknown as EditorView, option, 0, 6);
expect(dispatch.mock.calls[0]?.[0].changes).toEqual({ from: 0, to: 6, insert: '$["a.b"]' });
expect((await complete(context("$['a.b")))?.options.map((o) => o.label)).toEqual(["a.b"]);
});
it("explores a completed container without turning the path into an invalid draft", async () => {
const array: JsonPathChildren = {
children: [
{ label: "[*]", selector: "[*]", kind: "all items" },
{ label: "[0]", selector: "[0]", kind: "object" },
],
truncated: false,
};
const load = vi.fn(async (parent: string) => (parent === "$.users" ? array : root));
const result = await jsonPathCompletion(load)(context("$.users", true));
expect(load).toHaveBeenCalledExactlyOnceWith("$.users");
expect(result?.options.map((o) => o.label)).toEqual(["[*]", "[0]"]);
expect(
(await jsonPathCompletion(load)(context("$.users[0")))?.options.map((o) => o.label),
).toEqual(["[0]"]);
});
it("does not suggest descendants at the root or duplicate the current scalar path", async () => {
const load = vi.fn(async (parent: string) =>
parent === "$" ? root : { children: [], truncated: false },
);
const result = await jsonPathCompletion(load)(context(""));
expect(result?.options.map((o) => o.label)).toEqual(["users", "total", "a.b", "a,b"]);
expect(await jsonPathCompletion(load)(context("$.total", true))).toBeNull();
});
it("leaves complex expressions, mid-path editing, unavailable data, and stale results alone", async () => {
const load = vi.fn(async () => root);
expect(await jsonPathCompletion(load)(context("$.users[?(@.na"))).toBeNull();
expect(await jsonPathCompletion(load)(context("$..na"))).toBeNull();
expect(await jsonPathCompletion(load)(context("$.users[0].name", false, 5))).toBeNull();
expect(load).not.toHaveBeenCalled();
expect(await jsonPathCompletion(async () => null)(context("$"))).toBeNull();
const stale = context("$.");
Object.defineProperty(stale, "aborted", { get: () => true });
expect(await jsonPathCompletion(load)(stale)).toBeNull();
});
});
@@ -1,119 +0,0 @@
import type { CompletionContext, CompletionResult } from "@codemirror/autocomplete";
import { startCompletion } from "@codemirror/autocomplete";
import type { RpcSchema } from "@yaakapp-internal/rpc-schema";
export type JsonPathChildren = RpcSchema["cmd_http_response_json_children"][1];
export type LoadJsonPathChildren = (parent: string) => Promise<JsonPathChildren | null>;
export function normalizeJsonPath(value: string): string {
const text = value.trim();
if (!text) return "$";
return text.startsWith("$") ? text : text.startsWith("[") ? `$${text}` : `$.${text}`;
}
/** Locate the unfinished child accessor, leaving complete JSONPath expressions to Rust. */
export function jsonPathCompletionTarget(
text: string,
): { parent: string; fragment: string } | null {
text = text.trim();
if (text.length > 1024) return null;
if (!text || text === "$") return { parent: "$", fragment: "" };
let quote = "";
let escaped = false;
let depth = 0;
let boundary = -1;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (quote) {
if (escaped) escaped = false;
else if (c === "\\") escaped = true;
else if (c === quote) quote = "";
continue;
}
if (depth > 0 && (c === '"' || c === "'")) quote = c;
else if (c === "[") {
if (depth === 0) boundary = i;
depth++;
} else if (c === "]") {
if (--depth < 0) return null;
} else if (depth === 0 && c === ".") boundary = i;
}
if (depth === 0 && text.endsWith("]")) return { parent: normalizeJsonPath(text), fragment: "" };
if (boundary === -1) return text.startsWith("$") ? null : { parent: "$", fragment: text };
const fragment = text.slice(boundary);
// Don't try to complete inside filters, unions, or recursive descent.
if (
depth > 1 ||
fragment.startsWith("[?") ||
fragment.startsWith("[(") ||
text[boundary - 1] === "." ||
fragment.startsWith("[...") ||
(fragment.includes(",") && !fragment.startsWith('["') && !fragment.startsWith("['"))
)
return null;
return { parent: normalizeJsonPath(text.slice(0, boundary)), fragment };
}
function matches(child: JsonPathChildren["children"][number], fragment: string): boolean {
if (!fragment) return true;
if (!fragment.startsWith("[")) return child.label.startsWith(fragment.replace(/^\./, ""));
if (child.selector.startsWith("[") && !child.selector.startsWith('["'))
return child.selector.startsWith(fragment);
const quoted = JSON.stringify(child.label);
const accessor = fragment.startsWith("['")
? `['${quoted.slice(1, -1).replace(/\\"/g, '"').replace(/'/g, "\\'")}']`
: `[${quoted}]`;
return accessor.startsWith(fragment);
}
/** Complete a path progressively. Discovery is cached by response + parent in the caller. */
export function jsonPathCompletion(load: LoadJsonPathChildren) {
return async (context: CompletionContext): Promise<CompletionResult | null> => {
// Never replace later path segments while the user edits the middle of an expression.
if (context.pos !== context.state.doc.length) return null;
context.addEventListener("abort", () => {}, { onDocChange: true });
const text = context.state.doc.toString();
let target = jsonPathCompletionTarget(text);
if (!target) return null;
let children: JsonPathChildren | null = null;
if (context.explicit && text.trim()) {
// Accepting a container or explicitly opening completion explores its children, without
// inserting an unfinished dot/bracket (the container itself may be the assertion target).
const parent = normalizeJsonPath(text);
children = await load(parent);
if (context.aborted) return null;
if (children?.children.length) target = { parent, fragment: "" };
else children = null;
}
children ??= await load(target.parent);
if (context.aborted || !children) return null;
const parent = target.parent;
const options = children.children
.filter((child) => matches(child, target.fragment))
.filter((child) => `${parent}${child.selector}` !== normalizeJsonPath(text))
.map((child) => {
const path = `${parent}${child.selector}`;
return {
label: child.label,
displayLabel: child.selector.startsWith('["') ? child.selector : child.label,
detail: child.kind,
type: child.selector.startsWith("[") ? "variable" : "property",
apply(
view: Parameters<typeof startCompletion>[0],
_completion: unknown,
from: number,
to: number,
) {
view.dispatch({
changes: { from, to, insert: path },
selection: { anchor: from + path.length },
userEvent: "input.complete",
});
if (["object", "array", "all items", "mixed"].includes(child.kind))
startCompletion(view);
},
};
});
return options.length ? { from: 0, to: context.pos, options, filter: false } : null;
};
}
@@ -1,202 +0,0 @@
import { SearchQuery } from "@codemirror/search";
import { EditorState } from "@codemirror/state";
import { describe, expect, test } from "vite-plus/test";
import {
currentMatch,
literalSearch,
MAX_COUNT,
MatchCounter,
normalizeDoc,
normalizeSearch,
scanNormalized,
scanQuery,
} from "./searchMatchCount";
type QueryConfig = ConstructorParameters<typeof SearchQuery>[0];
const stateOf = (doc: string) => EditorState.create({ doc });
/**
* The matches the counter finds, having checked them against the search panel's own cursor.
*
* The cursor decides which ranges the editor highlights and which one `find next` lands on, so
* a count that doesn't agree with it is a wrong count, however fast it was to produce.
*/
function matchesOf(doc: string, config: QueryConfig) {
const state = stateOf(doc);
const query = new SearchQuery(config);
const matches = new MatchCounter().matches(state, query);
expect(matches).toEqual(scanQuery(state, query));
return matches;
}
const countOf = (doc: string, config: QueryConfig) => matchesOf(doc, config).length;
describe("counting", () => {
test("counts every match, whatever the case", () => {
expect(countOf("one Two three two", { search: "two" })).toBe(2);
expect(countOf("one Two three two", { search: "two", caseSensitive: true })).toBe(1);
});
test("skips matches overlapping an earlier one", () => {
expect(countOf("aaaaa", { search: "aa" })).toBe(2);
expect(countOf("ababa", { search: "aba" })).toBe(1);
});
test("treats a query as text, not as a pattern", () => {
expect(countOf("a.b axb", { search: "a.b" })).toBe(1);
});
test("unquotes escapes unless the query is literal", () => {
expect(countOf("one\ntwo\nthree", { search: "\\n" })).toBe(2);
expect(countOf("one\\ntwo", { search: "\\n", literal: true })).toBe(1);
});
test("counts regexp and whole word queries through the cursor", () => {
expect(literalSearch(new SearchQuery({ search: "a", regexp: true }))).toBe(null);
expect(literalSearch(new SearchQuery({ search: "a", wholeWord: true }))).toBe(null);
expect(countOf("a1 b2 c3", { search: "[a-z]\\d", regexp: true })).toBe(3);
expect(countOf("cat cats cat", { search: "cat", wholeWord: true })).toBe(2);
});
test("stops counting at the cap", () => {
expect(countOf("x".repeat(MAX_COUNT + 100), { search: "x" })).toBe(MAX_COUNT + 1);
});
test("reports where the matches are", () => {
expect(matchesOf("ab..ab", { search: "ab" })).toEqual([
{ from: 0, to: 2 },
{ from: 4, to: 6 },
]);
});
test("finds nothing to match with an empty needle", () => {
expect(scanNormalized(normalizeDoc("abc", false), "")).toEqual([]);
});
});
describe("normalization", () => {
test("finds what a character decomposes into", () => {
// The é is one character holding an `e`, and the match covers the whole of it
expect(matchesOf("café", { search: "e" })).toEqual([{ from: 3, to: 4 }]);
expect(matchesOf("file", { search: "fi" })).toEqual([{ from: 0, to: 1 }]);
expect(matchesOf("a…b", { search: "..." })).toEqual([{ from: 1, to: 2 }]);
expect(countOf("one two", { search: "one two" })).toBe(1);
expect(countOf("full width", { search: "full" })).toBe(1);
});
test("matches a decomposed query against composed text, and the reverse", () => {
expect(countOf("café", { search: "café" })).toBe(1);
expect(countOf("café", { search: "café" })).toBe(1);
expect(countOf("café", { search: "café" })).toBe(1);
});
test("keeps offsets straight after an expansion", () => {
expect(matchesOf("é.é.end", { search: "end" })).toEqual([{ from: 4, to: 7 }]);
expect(matchesOf("fififi stop", { search: "stop" })).toEqual([{ from: 4, to: 8 }]);
});
test("normalizes the query whole, the document by character", () => {
expect(normalizeSearch("CAFÉ", false)).toBe("café");
expect(normalizeSearch("CAFÉ", true)).toBe("CAFÉ");
// Whole-string NFKD would fold this to a final sigma, which the cursor never does
expect(normalizeDoc("ΟΔΟΣ", false).text).toBe("οδοσ");
});
test("leaves a document that normalizes to itself untouched", () => {
const { text, expansions } = normalizeDoc("plain 日本 🎉 text", false);
expect(text).toBe("plain 日本 🎉 text");
expect(expansions).toEqual([]);
});
});
describe("current match", () => {
const matches = [
{ from: 0, to: 2 },
{ from: 4, to: 6 },
{ from: 8, to: 10 },
];
test("counts from one, and reports 0 off a match", () => {
expect(currentMatch(matches, { from: 4, to: 6 })).toBe(2);
expect(currentMatch(matches, { from: 8, to: 10 })).toBe(3);
expect(currentMatch(matches, { from: 5, to: 5 })).toBe(2);
expect(currentMatch(matches, { from: 2, to: 3 })).toBe(0);
expect(currentMatch(matches, { from: 4, to: 7 })).toBe(0);
expect(currentMatch([], { from: 0, to: 0 })).toBe(0);
});
test("moving the selection doesn't scan again", () => {
const state = stateOf("a1 b2 c3");
const query = new SearchQuery({ search: "\\d", regexp: true });
const counter = new MatchCounter();
const found = counter.matches(state, query);
// The document a selection-only transaction leaves behind is the one already scanned
const moved = state.update({ selection: { anchor: 4, head: 5 } }).state;
expect(counter.matches(moved, query)).toBe(found);
expect(currentMatch(found, moved.selection.main)).toBe(2);
});
});
/**
* The mapping from normalized offsets back to document offsets is the part of this that can go
* quietly wrong, and only on input nobody thinks to write a case for. So generate the input.
*/
describe("against the cursor, on awkward text", () => {
const ALPHABET = [
..."abcABC .\\\n".split(""),
"é",
"é",
"fi",
"…",
" ",
"İ",
"Σ",
"ς",
"日",
"🎉",
"Ⅻ",
"f",
"①",
"́",
];
/** Seeded, so a failure is the same failure next run */
function random(seed: number) {
let state = seed;
return () => {
state = (state * 1664525 + 1013904223) >>> 0;
return state / 2 ** 32;
};
}
for (const caseSensitive of [false, true]) {
test(`agrees on every generated document (caseSensitive: ${caseSensitive})`, () => {
const next = random(caseSensitive ? 20260831 : 7);
for (let round = 0; round < 400; round++) {
const doc = Array.from(
{ length: 2 + Math.floor(next() * 60) },
() => ALPHABET[Math.floor(next() * ALPHABET.length)]!,
).join("");
// Half the queries are lifted out of the document, so matches are actually found
const start = Math.floor(next() * doc.length);
const search =
next() < 0.5
? doc.slice(start, start + 1 + Math.floor(next() * 3))
: Array.from(
{ length: 1 + Math.floor(next() * 2) },
() => ALPHABET[Math.floor(next() * ALPHABET.length)]!,
).join("");
if (search === "") continue;
const state = stateOf(doc);
const query = new SearchQuery({ search, caseSensitive });
const where = `doc=${JSON.stringify(doc)} search=${JSON.stringify(search)}`;
expect(new MatchCounter().matches(state, query), where).toEqual(scanQuery(state, query));
}
});
}
});
@@ -1,232 +1,7 @@
import { getSearchQuery, type SearchQuery, searchPanelOpen } from "@codemirror/search";
import type { EditorState, Extension, Text } from "@codemirror/state";
import { getSearchQuery, searchPanelOpen } from "@codemirror/search";
import type { Extension } from "@codemirror/state";
import { type EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
/** Matches are counted no further than this, since an exact total stops being useful long before */
export const MAX_COUNT = 9999;
/** What normalizing rewrites: anything outside ASCII, plus the case it folds */
const REWRITTEN = /\P{ASCII}|[A-Z]+/gu;
const REWRITTEN_CASE_SENSITIVE = /\P{ASCII}/gu;
export interface Match {
from: number;
to: number;
}
/** A character whose normalized form is a different length, shifting every offset past it */
interface Expansion {
normFrom: number;
normTo: number;
docFrom: number;
docTo: number;
}
/** A document as SearchCursor compares it, with what's needed to get back to real offsets */
export interface NormalizedDoc {
text: string;
expansions: Expansion[];
}
/**
* Rewrites a document the way SearchCursor does — NFKD, then a case fold unless the search is
* case-sensitive — in a single pass rather than one call per code point.
*
* The cursor spends 90% of its time asking ICU about one character at a time, which is what
* makes counting matches in a large response slow. Doing it a character at a time still matters
* for the result, since it keeps NFKD from reordering marks across characters, so the
* granularity stays and only the repeated work goes: each distinct character is normalized once
* and the answer reused, and ASCII runs never reach ICU at all.
*/
export function normalizeDoc(text: string, caseSensitive: boolean): NormalizedDoc {
const rewritten = new Map<string, string>();
const expansions: Expansion[] = [];
let shift = 0;
const normalized = text.replace(
caseSensitive ? REWRITTEN_CASE_SENSITIVE : REWRITTEN,
(chunk: string, at: number) => {
// An ASCII run only ever folds case, which can't change its length
if (chunk.charCodeAt(0) < 0x80) return chunk.toLowerCase();
let out = rewritten.get(chunk);
if (out === undefined) {
out = chunk.normalize("NFKD");
if (!caseSensitive) out = out.toLowerCase();
rewritten.set(chunk, out);
}
if (out.length !== chunk.length) {
expansions.push({
normFrom: at + shift,
normTo: at + shift + out.length,
docFrom: at,
docTo: at + chunk.length,
});
shift += out.length - chunk.length;
}
return out;
},
);
return { text: normalized, expansions };
}
/** The query as SearchCursor compares it, which it normalizes whole rather than by character */
export function normalizeSearch(search: string, caseSensitive: boolean): string {
const normalized = search.normalize("NFKD");
return caseSensitive ? normalized : normalized.toLowerCase();
}
/** Every occurrence of `needle`, skipping matches that overlap an earlier one */
export function scanNormalized(doc: NormalizedDoc, needle: string): Match[] {
const matches: Match[] = [];
if (needle === "") return matches;
const { text } = doc;
let pos = text.indexOf(needle);
while (pos >= 0) {
let end = pos + needle.length;
// However the query was cut, a match ends on a whole code point, as the cursor's do
if (isLowSurrogate(text.charCodeAt(end))) end++;
matches.push({ from: docStart(doc, pos), to: docEnd(doc, end) });
if (matches.length > MAX_COUNT) break;
pos = text.indexOf(needle, resumeAfter(doc, end));
}
return matches;
}
/** The same through the query's own cursor, which handles regexps and whole words */
export function scanQuery(state: EditorState, query: SearchQuery): Match[] {
const matches: Match[] = [];
const cursor = query.getCursor(state);
for (let result = cursor.next(); !result.done; result = cursor.next()) {
matches.push({ from: result.value.from, to: result.value.to });
if (matches.length > MAX_COUNT) break;
}
return matches;
}
const isLowSurrogate = (code: number) => code >= 0xdc00 && code <= 0xdfff;
/** The last character expansion beginning at or before `offset`, if there is one */
function expansionAt({ expansions }: NormalizedDoc, offset: number): Expansion | null {
let low = 0;
let high = expansions.length - 1;
let found: Expansion | null = null;
while (low <= high) {
const mid = (low + high) >> 1;
if (expansions[mid]!.normFrom <= offset) {
found = expansions[mid]!;
low = mid + 1;
} else {
high = mid - 1;
}
}
return found;
}
function docStart(doc: NormalizedDoc, offset: number): number {
const expansion = expansionAt(doc, offset);
if (expansion == null) return offset;
// A match starting inside a character's expansion starts at the character
return offset < expansion.normTo
? expansion.docFrom
: offset - (expansion.normTo - expansion.docTo);
}
/**
* Where scanning picks up after a match ending at `offset`.
*
* The cursor moves through the document a character at a time, so once a match ends inside a
* character's expansion the rest of that expansion is behind it — "…" holds three dots but only
* ever counts as one match of ".".
*/
function resumeAfter(doc: NormalizedDoc, offset: number): number {
const expansion = expansionAt(doc, offset);
return expansion != null && offset > expansion.normFrom && offset < expansion.normTo
? expansion.normTo
: offset;
}
function docEnd(doc: NormalizedDoc, offset: number): number {
const expansion = expansionAt(doc, offset);
if (expansion == null) return offset;
if (offset <= expansion.normFrom) return expansion.docFrom;
// A match ending inside a character's expansion covers the whole character
return offset < expansion.normTo
? expansion.docTo
: offset - (expansion.normTo - expansion.docTo);
}
/** Position of the match holding the selection, counting from one, or 0 when it isn't on one */
export function currentMatch(matches: Match[], selection: { from: number; to: number }): number {
let index = 0;
for (const match of matches) {
index++;
if (match.from <= selection.from && match.to >= selection.to) return index;
}
return 0;
}
/** The text a plain query looks for, or null when only the cursor can answer it */
export function literalSearch(query: SearchQuery): string | null {
if (query.regexp || query.wholeWord || query.test != null) return null;
// Mirrors SearchQuery's own unquoting, which the published type doesn't expose
return query.literal
? query.search
: query.search.replace(/\\([nrt\\])/g, (_, ch) =>
ch === "n" ? "\n" : ch === "r" ? "\r" : ch === "t" ? "\t" : "\\",
);
}
/**
* Finds the matches for the search panel, keeping the normalized document and the matches it
* last found, so neither moving the selection nor typing another character starts over.
*/
export class MatchCounter {
private doc: { doc: Text; caseSensitive: boolean; normalized: NormalizedDoc } | null = null;
private last: { doc: Text; query: SearchQuery; matches: Match[] } | null = null;
matches(state: EditorState, query: SearchQuery): Match[] {
const last = this.last;
if (last != null && last.doc === state.doc && last.query.eq(query)) {
return last.matches;
}
const matches = this.scan(state, query);
this.last = { doc: state.doc, query, matches };
return matches;
}
private scan(state: EditorState, query: SearchQuery): Match[] {
const search = literalSearch(query);
if (search == null) return scanQuery(state, query);
const doc = this.normalizedDoc(state.doc, query.caseSensitive);
return scanNormalized(doc, normalizeSearch(search, query.caseSensitive));
}
private normalizedDoc(doc: Text, caseSensitive: boolean): NormalizedDoc {
const cached = this.doc;
if (cached != null && cached.doc === doc && cached.caseSensitive === caseSensitive) {
return cached.normalized;
}
const normalized = normalizeDoc(doc.toString(), caseSensitive);
this.doc = { doc, caseSensitive, normalized };
return normalized;
}
}
/**
* A CodeMirror extension that displays the total number of search matches
* inside the built-in search panel.
@@ -235,7 +10,6 @@ export function searchMatchCount(): Extension {
return ViewPlugin.fromClass(
class {
private countEl: HTMLElement | null = null;
private counter = new MatchCounter();
constructor(private view: EditorView) {
this.updateCount();
@@ -264,21 +38,38 @@ export function searchMatchCount(): Extension {
}
this.ensureCountEl();
if (this.countEl == null) return;
if (!query.search) {
this.countEl.textContent = "0/0";
if (this.countEl) {
this.countEl.textContent = "0/0";
}
return;
}
const matches = this.counter.matches(state, query);
if (matches.length > MAX_COUNT) {
this.countEl.textContent = `${MAX_COUNT}+`;
} else if (matches.length === 0) {
this.countEl.textContent = "0/0";
} else {
const current = currentMatch(matches, state.selection.main);
this.countEl.textContent = `${current}/${matches.length}`;
const selection = state.selection.main;
let count = 0;
let currentIndex = 0;
const MAX_COUNT = 9999;
const cursor = query.getCursor(state);
for (let result = cursor.next(); !result.done; result = cursor.next()) {
count++;
const match = result.value;
if (match.from <= selection.from && match.to >= selection.to) {
currentIndex = count;
}
if (count > MAX_COUNT) break;
}
if (this.countEl) {
if (count > MAX_COUNT) {
this.countEl.textContent = `${MAX_COUNT}+`;
} else if (count === 0) {
this.countEl.textContent = "0/0";
} else if (currentIndex > 0) {
this.countEl.textContent = `${currentIndex}/${count}`;
} else {
this.countEl.textContent = `0/${count}`;
}
}
}
@@ -1,155 +0,0 @@
/* oxlint-disable no-template-curly-in-string */
import { CompletionContext } from "@codemirror/autocomplete";
import { json } from "@codemirror/lang-json";
import { EditorState, type Extension, type TransactionSpec } from "@codemirror/state";
import type { EditorView } from "@codemirror/view";
import { describe, expect, test } from "vite-plus/test";
import { singleLineExtensions } from "../singleLine";
import { twigCompletion, type TwigCompletionOption } from "./completion";
import { parser } from "./twig";
const options: TwigCompletionOption[] = [
{ name: "someVar", label: "someVar", type: "variable", value: "value", onClick: () => {} },
{ name: "uuid.v4", label: "uuid.v4", type: "function", args: [], value: null, onClick: () => {} },
];
const source = twigCompletion({ options });
// Both keyboard acceptance and clicking a suggestion invoke Completion.apply.
function complete(input: string, label: string, extensions: Extension = []) {
const cursor = input.indexOf("|");
if (cursor < 0) throw new Error("Missing cursor marker");
let state = EditorState.create({
doc: input.slice(0, cursor) + input.slice(cursor + 1),
selection: { anchor: cursor },
extensions,
});
const result = source(new CompletionContext(state, cursor, false));
const completion = result?.options.find((option) => option.label === label);
expect(completion).toBeDefined();
const view = {
get state() {
return state;
},
dispatch(spec: TransactionSpec) {
state = state.update(spec).state;
},
} as EditorView;
if (typeof completion?.apply !== "function" || !result) throw new Error("Missing apply");
completion.apply(view, completion, result.from, cursor);
return state;
}
function expectValidTag(state: EditorState, expected: string) {
expect(state.doc.toString()).toBe(expected);
const tags: string[] = [];
parser.parse(expected).iterate({
enter(node) {
expect(node.type.isError).toBe(false);
if (node.name === "Tag") tags.push(expected.slice(node.from, node.to));
},
});
expect(tags).toHaveLength(1);
}
describe("twig completion", () => {
test.each(["${[\nsome|", "${[ \n\n\tsome|", "${[\nsome|\n ]}", "${[\n |"])(
"completes across multiline whitespace: %s",
(input) => expectValidTag(complete(input, "someVar"), "${[ someVar ]}"),
);
test.each(["${[\nuuid.v|", "${[\n uuid.v|()\n ]}"])(
"completes a function across multiline whitespace: %s",
(input) => expectValidTag(complete(input, "uuid.v4"), "${[ uuid.v4() ]}"),
);
test("preserves surrounding lines and literal pipes", () => {
expect(complete("before\n${[\nsome|\n]}\na | b", "someVar").doc.toString()).toBe(
"before\n${[ someVar ]}\na | b",
);
});
test("does not search past non-whitespace for an opener or closer", () => {
expect(complete("prefix\nsome|\nother ]}", "someVar").doc.toString()).toBe(
"prefix\n${[ someVar ]}\nother ]}",
);
expect(complete("${[some|\nother ]}", "someVar").doc.toString()).toBe(
"${[ someVar ]}\nother ]}",
);
});
test.each(["${[|", "${[ |", "${[ |"])(
"offers completion immediately after a template opener: %s",
(input) => {
expectValidTag(complete(input, "someVar"), "${[ someVar ]}");
},
);
test.each(["$", "{", "${", '{"value": "'])(
"does not automatically offer empty-name completions after %s",
(doc) => {
const state = EditorState.create({ doc });
expect(source(new CompletionContext(state, doc.length, false))).toBeNull();
},
);
test.each(["uuid|", "${[uuid|", "${[ uuid| ]}"])(
"completes a namespace before wrapping the final function: %s",
(input) => {
const namespace = complete(input, "uuid.*");
expect(namespace.doc.toString()).toBe(input.replace("uuid|", "uuid."));
const cursor = namespace.selection.main.head;
const next = namespace.sliceDoc(0, cursor) + "|" + namespace.sliceDoc(cursor);
expectValidTag(complete(next, "uuid.v4"), "${[ uuid.v4() ]}");
},
);
test.each(["some|", "${[some|", "${[ some|", "${[ someVar|", "${[\tsome|"])(
"completes a variable in a single-line value: %s",
(input) => {
const state = complete(input, "someVar", singleLineExtensions());
expectValidTag(state, "${[ someVar ]}");
expect(state.selection.main.head).toBe(state.doc.length);
},
);
test.each(["${[some|]}", "${[ some| ]}", "${[someVar|]}"])(
"reuses closing delimiters: %s",
(input) => {
expectValidTag(complete(input, "someVar"), "${[ someVar ]}");
},
);
test.each(["uuid.v|", "${[uuid.v|", "${[ uuid.v| ]}", "${[uuid.v|()]}"])(
"completes a function: %s",
(input) => {
expectValidTag(complete(input, "uuid.v4"), "${[ uuid.v4() ]}");
},
);
test("preserves surrounding JSON and its closing punctuation", () => {
expectValidTag(
complete('{"value":"${[some|","other":1}', "someVar", json()),
'{"value":"${[ someVar ]}","other":1}',
);
});
test("preserves surrounding text and other complete tags", () => {
expect(complete("${[ someVar ]} ${[some|]} trailing", "someVar").doc.toString()).toBe(
"${[ someVar ]} ${[ someVar ]} trailing",
);
});
test("does not consume closing punctuation without a template opener", () => {
expect(complete("[some|]}", "someVar").doc.toString()).toBe("[${[ someVar ]}]}");
});
test("invalidates cached suggestions when typing delimiters or a namespace separator", () => {
const state = EditorState.create({ doc: "some" });
const result = source(new CompletionContext(state, 4, false));
expect(result?.validFor).toBeInstanceOf(RegExp);
const validFor = result?.validFor as RegExp;
expect(validFor.test("someVar")).toBe(true);
expect(validFor.test("some ${[some")).toBe(false);
expect(validFor.test("uuid.")).toBe(false);
});
});
@@ -1,6 +1,5 @@
import type { Completion, CompletionContext } from "@codemirror/autocomplete";
import { startCompletion } from "@codemirror/autocomplete";
import type { EditorState } from "@codemirror/state";
import type { TemplateFunction } from "@yaakapp-internal/plugins";
const openTag = "${[ ";
@@ -37,22 +36,6 @@ export interface TwigCompletionConfig {
const MIN_MATCH_NAME = 1;
// Only inspect adjacent whitespace, including newlines, rather than copying the
// entire document to look for a delimiter in large request bodies.
function skipWhitespace(state: EditorState, pos: number, direction: -1 | 1) {
while (direction === -1 ? pos > 0 : pos < state.doc.length) {
const from = direction === -1 ? pos - 1 : pos;
if (!/\s/.test(state.sliceDoc(from, from + 1))) break;
pos += direction;
}
return pos;
}
function findOpenTag(state: EditorState, from: number) {
const end = skipWhitespace(state, from, -1);
return end >= 3 && state.sliceDoc(end - 3, end) === "${[" ? end - 3 : null;
}
export function twigCompletion({ options }: TwigCompletionConfig) {
return function completions(context: CompletionContext) {
const toStartOfName = context.matchBefore(/[\w_.]*/);
@@ -61,8 +44,7 @@ export function twigCompletion({ options }: TwigCompletionConfig) {
if (toMatch === null) return null;
const matchLen = toMatch.to - toMatch.from;
const hasOpenTag = findOpenTag(context.state, toMatch.from) !== null;
if (!context.explicit && !hasOpenTag && toMatch.from > 0 && matchLen < MIN_MATCH_NAME) {
if (!context.explicit && toMatch.from > 0 && matchLen < MIN_MATCH_NAME) {
return null;
}
@@ -101,16 +83,6 @@ export function twigCompletion({ options }: TwigCompletionConfig) {
detail: o.type,
type: o.type === "variable" ? "variable" : "function",
apply: (view, _completion, from, to) => {
// Keep the completion range name-only for filtering, but replace a manually
// typed opener when accepting (through either the keyboard or the menu).
const opener = findOpenTag(view.state, from);
if (opener !== null) {
from = opener;
const hasParentheses =
o.type === "function" && view.state.sliceDoc(to, to + 2) === "()";
const closer = skipWhitespace(view.state, to + (hasParentheses ? 2 : 0), 1);
if (view.state.sliceDoc(closer, closer + 2) === "]}") to = closer + 2;
}
const insert = openTag + inner + closeTag;
view.dispatch({
changes: { from, to, insert: insert },
@@ -131,8 +103,7 @@ export function twigCompletion({ options }: TwigCompletionConfig) {
return {
matchLen,
// Delimiters change the replacement range; dots change namespace options.
validFor: /^\w*$/,
validFor: () => true, // Not really sure why this is all it needs
from: toMatch.from,
options: sortedCompletions,
};
+1 -5
View File
@@ -1,5 +1,4 @@
import { Link as RouterLink } from "@tanstack/react-router";
import { platform } from "@yaakapp-internal/platform";
import classNames from "classnames";
import type { HTMLAttributes } from "react";
import { appInfo } from "../../lib/appInfo";
@@ -34,10 +33,7 @@ export function Link({ href, children, noUnderline, className, ...other }: Props
href={finalHref}
target="_blank"
rel={isYaakLink ? undefined : "noopener noreferrer"}
onClick={(e) => {
e.preventDefault();
void platform.openUrl(finalHref);
}}
onClick={(e) => e.preventDefault()}
className={className}
{...other}
>
@@ -113,15 +113,7 @@ function toPairData({ commitName: _commitName, ...pair }: EditablePairWithId): P
/** 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({
export function PairEditor({
allowFileValues,
allowMultilineValues,
className,
@@ -1,124 +0,0 @@
import { Icon, type IconProps } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { forwardRef, type ReactNode } from "react";
import { Dropdown, type DropdownItem } from "./Dropdown";
import { IconButton } from "./IconButton";
export interface PathListItem {
path: string;
icon: IconProps["icon"];
/** Replaces the parent-path line under the name */
subtitle?: ReactNode;
failed?: boolean;
}
interface Props {
items: PathListItem[];
disabled?: boolean;
onRemove: (path: string) => void;
addItems: DropdownItem[];
addLabel: string;
addHint?: string;
hovering?: boolean;
/** Rows rendered between the items and the add row, such as an inline input */
children?: ReactNode;
}
/** Selected files, folders, or URLs as cards, with a dashed add row that opens a menu. */
export const PathList = forwardRef<HTMLDivElement, Props>(function PathList(
{ items, disabled, onRemove, addItems, addLabel, addHint, hovering, children },
ref,
) {
return (
<div
ref={ref}
className={classNames(
"flex flex-col gap-1.5 rounded-lg -m-1.5 p-1.5",
hovering && "bg-surface-highlight",
)}
>
{items.map((item) => {
const { name, parent } = splitPath(item.path);
return (
<div
key={item.path}
className={classNames(
"flex items-center gap-3 rounded-md pl-3 pr-1.5 py-2",
item.failed ? "bg-danger/10" : "bg-surface-highlight/50",
)}
>
<Icon
icon={item.icon}
className={classNames("shrink-0", item.failed ? "text-danger" : "text-text-subtle")}
/>
<div className="min-w-0 flex-1" title={item.path}>
<div className="truncate text-sm font-semibold">{name}</div>
<div
className={classNames(
"flex items-center gap-1.5 truncate text-xs",
item.failed ? "text-danger" : "text-text-subtlest",
)}
>
{item.subtitle ??
(parent !== "" && <span className="truncate font-mono">{parent}</span>)}
</div>
</div>
<IconButton
size="xs"
icon="trash"
iconColor="secondary"
title={`Remove ${name}`}
disabled={disabled}
className="shrink-0"
onClick={() => onRemove(item.path)}
/>
</div>
);
})}
{children}
<Dropdown items={addItems}>
<button
type="button"
disabled={disabled}
className={classNames(
"h-sm w-full flex items-center gap-2 rounded-md border border-dashed px-3 text-sm text-text-subtle",
"hocus:border-border-focus hocus:text-text disabled:opacity-disabled",
hovering ? "border-notice" : "border-border",
)}
>
<Icon icon="plus" size="sm" className="text-text-subtlest" />
{hovering ? (
"Drop to add"
) : (
<>
<span>{addLabel}</span>
{addHint != null && (
<span className="ml-auto text-xs text-text-subtlest">{addHint}</span>
)}
</>
)}
</button>
</Dropdown>
</div>
);
});
/** The last segment as the name and everything before it, shortened, as the parent. */
export function splitPath(path: string): { name: string; parent: string } {
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(path)) {
try {
const url = new URL(path);
const segments = url.pathname.split("/").filter(Boolean);
const name = segments.at(-1) ?? url.host;
const parent = segments.length > 0 ? `${url.host}/${segments.slice(0, -1).join("/")}` : "";
return { name, parent: parent.replace(/\/$/, "") };
} catch {
return { name: path, parent: "" };
}
}
const parts = path.split(/[/\\]/).filter(Boolean);
const name = parts.at(-1) ?? path;
const dirs = parts.slice(0, -1);
const parent = dirs.length > 3 ? `…/${dirs.slice(-3).join("/")}` : dirs.join("/");
return { name, parent };
}
@@ -205,7 +205,6 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
autoComplete="off"
autoCapitalize="off"
autoCorrect="off"
writingsuggestions="false"
inputMode={inputMode}
onChange={(e) => handleChange(e.target.value)}
onPaste={(e) => onPaste?.(e.clipboardData.getData("Text"))}
+14 -9
View File
@@ -1,9 +1,10 @@
import type { FormInput, JsonPrimitive } from "@yaakapp-internal/plugins";
import { HStack } from "@yaakapp-internal/ui";
import type { FormEvent } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { generateId } from "../../lib/generateId";
import { DynamicForm } from "../DynamicForm";
import { DialogFooter } from "./Dialog";
import { Button } from "./Button";
export interface PromptProps {
inputs: FormInput[];
@@ -47,15 +48,19 @@ export function Prompt({
const id = `prompt.form.${useRef(generateId()).current}`;
return (
<form id={id} className="grid grid-cols-[minmax(0,1fr)] mb-2" onSubmit={handleSubmit}>
<form
className="grid grid-rows-[auto_auto] grid-cols-[minmax(0,1fr)] gap-4 mb-4"
onSubmit={handleSubmit}
>
<DynamicForm inputs={inputs} onChange={setValue} data={value} stateKey={id} />
<DialogFooter
inline
actions={[
{ label: cancelText || "Cancel", onClick: onCancel },
{ label: confirmText || "Done", color: "primary", form: id },
]}
/>
<HStack space={2} justifyContent="end">
<Button onClick={onCancel} variant="border" color="secondary">
{cancelText || "Cancel"}
</Button>
<Button type="submit" color="primary">
{confirmText || "Done"}
</Button>
</HStack>
</form>
);
}
@@ -6,7 +6,6 @@ import { useStateWithDeps } from "../../hooks/useStateWithDeps";
import { generateId } from "../../lib/generateId";
import { Button } from "./Button";
import { IconButton, type IconButtonProps } from "./IconButton";
import { IconTooltip } from "./IconTooltip";
import { Label } from "./Label";
interface Props<T extends string> {
@@ -37,15 +36,11 @@ export function SegmentedControl<T extends string>({
const containerRef = useRef<HTMLDivElement>(null);
const id = useRef(`input-${generateId()}`);
// A visually hidden label has nowhere to show the help, so the last option carries it
const inlineHelp =
hideLabel && help ? <IconTooltip tabIndex={-1} content={help} iconSize="xs" /> : null;
return (
<div className="w-full grid">
<Label
htmlFor={id.current}
help={hideLabel ? undefined : help}
help={help}
visuallyHidden={hideLabel}
className={classNames(labelClassName)}
>
@@ -83,10 +78,9 @@ export function SegmentedControl<T extends string>({
}
}}
>
{options.map((o, i) => {
{options.map((o) => {
const isSelected = selectedValue === o.value;
const isActive = value === o.value;
const rightSlot = i === options.length - 1 ? inlineHelp : null;
if (o.icon == null) {
return (
<Button
@@ -101,7 +95,6 @@ export function SegmentedControl<T extends string>({
isActive && "text-text!",
"focus:ring-1 focus:ring-border-focus",
)}
rightSlot={rightSlot}
onClick={() => onChange(o.value)}
>
{o.label}
@@ -124,7 +117,6 @@ export function SegmentedControl<T extends string>({
)}
title={o.label}
icon={o.icon}
rightSlot={rightSlot}
onClick={() => onChange(o.value)}
/>
);
+2 -6
View File
@@ -119,13 +119,9 @@ export function Select<T extends string>({
)}
>
<Button
className={classNames(
"w-full text-sm font-mono",
disabled && "border-dotted",
isInvalidSelection && "border-danger",
)}
className="w-full text-sm font-mono"
justify="start"
variant="input"
variant="border"
size={size}
leftSlot={leftSlot}
disabled={disabled}
+67 -300
View File
@@ -10,7 +10,7 @@ import {
useSensors,
} from "@dnd-kit/core";
import classNames from "classnames";
import type { CSSProperties, ReactNode, Ref } from "react";
import type { ReactNode, Ref } from "react";
import {
forwardRef,
memo,
@@ -27,19 +27,11 @@ import { fireAndForget } from "../../../lib/fireAndForget";
import { ErrorBoundary } from "../../ErrorBoundary";
import type { ButtonProps } from "../Button";
import { Button } from "../Button";
import { Dropdown } from "../Dropdown";
import { IconButton } from "../IconButton";
import { Icon } from "@yaakapp-internal/ui";
import type { RadioDropdownProps } from "../RadioDropdown";
import { RadioDropdown } from "../RadioDropdown";
import { useFittingTabs } from "./useFittingTabs";
import { MIN_TAB_GAP_SCALE, MIN_TAB_PADDING_SCALE } from "./fitTabs";
export type TabItem = {
/** Stable label for the visibility menu, including tabs with dropdown labels. */
menuLabel?: string;
hiddenByDefault?: boolean;
} & (
export type TabItem =
| {
value: string;
label: string;
@@ -52,8 +44,7 @@ export type TabItem = {
options: Omit<RadioDropdownProps, "children">;
leftSlot?: ReactNode;
rightSlot?: ReactNode;
}
);
};
interface TabsStorage {
order: string[];
@@ -81,8 +72,6 @@ interface Props {
storageKey?: string | string[];
/** Key to identify which context this tab belongs to (e.g., request ID). Used for per-context active tab persistence. */
activeTabKey?: string;
/** Enables show/hide controls, scoped independently of tab order and selection. */
visibilityStorageKey?: string | string[];
}
export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
@@ -98,7 +87,6 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
layout = "vertical",
storageKey,
activeTabKey,
visibilityStorageKey,
}: Props,
forwardedRef: Ref<TabsRef>,
) {
@@ -120,33 +108,10 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
const savedOrder = storage.order;
const visibilityKey = visibilityStorageKey ?? ["tabs", "default_visibility"];
const { value: visibility, set: setVisibility } = useKeyValue<Record<string, boolean>>({
namespace: "no_sync",
key: visibilityKey,
fallback: {},
});
const { value: wrapPreference, set: setWrapPreference } = useKeyValue<boolean>({
namespace: "no_sync",
key: [...(Array.isArray(visibilityKey) ? visibilityKey : [visibilityKey]), "wrap"],
fallback: false,
});
const isVisible = useCallback(
(tab: TabItem) =>
!("hidden" in tab && tab.hidden) &&
(!visibilityStorageKey || (visibility?.[tab.value] ?? !tab.hiddenByDefault)),
[visibility, visibilityStorageKey],
);
// Get the active tab value - prefer storage (if activeTabKey), then defaultValue, then first tab
const storedActiveTab = activeTabKey ? storage?.activeTabs?.[activeTabKey] : undefined;
const [internalValue, setInternalValue] = useState<string | undefined>(undefined);
const requestedValue = storedActiveTab ?? internalValue ?? defaultValue ?? originalTabs[0]?.value;
// Dynamic tabs can disappear when switching responses or other contexts.
// Keep the saved preference, but display an available tab until it returns.
const value = originalTabs.some((t) => t.value === requestedValue && isVisible(t))
? requestedValue
: originalTabs.find(isVisible)?.value;
const value = storedActiveTab ?? internalValue ?? defaultValue ?? originalTabs[0]?.value;
// Helper to normalize storage (handle migration from old format)
const normalizeStorage = useCallback(
@@ -158,9 +123,6 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
// Handle tab change - update internal state, storage if we have a key, and call prop callback
const onChangeValue = useCallback(
async (newValue: string) => {
if (visibilityStorageKey) {
await setVisibility((s) => ({ ...s, [newValue]: true }));
}
setInternalValue(newValue);
if (storageKey && activeTabKey) {
await setStorage((s) => {
@@ -173,15 +135,7 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
}
onChangeValueProp?.(newValue);
},
[
storageKey,
activeTabKey,
setStorage,
onChangeValueProp,
normalizeStorage,
visibilityStorageKey,
setVisibility,
],
[storageKey, activeTabKey, setStorage, onChangeValueProp, normalizeStorage],
);
// Expose imperative methods via ref
@@ -245,18 +199,6 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
}, [originalTabs, savedOrder, storageKey]);
const tabs = storageKey ? orderedTabs : originalTabs;
const responsive = !!visibilityStorageKey && layout === "vertical";
const wrapTabs = responsive && !!wrapPreference;
const { viewportRef, measurementsRef, fittedTabs } = useFittingTabs(
responsive && !wrapTabs,
value,
);
const fitsInStrip = useCallback(
(tab: TabItem) =>
isVisible(tab) && (fittedTabs == null || fittedTabs.some((t) => t.value === tab.value)),
[isVisible, fittedTabs],
);
const overflowTabs = tabs.filter((tab) => isVisible(tab) && !fitsInStrip(tab));
// Update tabs when value changes
useEffect(() => {
@@ -348,168 +290,69 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
const tabButtons = useMemo(() => {
const items: ReactNode[] = [];
tabs.forEach((t, i) => {
if (!fitsInStrip(t)) {
if ("hidden" in t && t.hidden) {
return;
}
const next = tabs[i + 1];
const fitted = fittedTabs?.find((tab) => tab.value === t.value);
const isActive = t.value === value;
const showDropMarkerBefore = hoveredIndex === i;
if (showDropMarkerBefore) {
items.push(
<div
key={`marker-${t.value}`}
className={classNames("relative", layout === "vertical" ? "w-0" : "h-0")}
>
<DropMarker orientation={layout === "vertical" ? "vertical" : "horizontal"} />
</div>,
);
}
items.push(
<TabButton
key={t.value}
tab={t}
isActive={t.value === value}
isActive={isActive}
addBorders={addBorders}
layout={layout}
reorderable={reorderable}
isDragging={isDragging?.value === t.value}
onChangeValue={onChangeValue}
width={fitted?.width}
spacingCompression={fitted?.spacingCompression}
wrap={wrapTabs}
dropBefore={hoveredIndex === i}
// A drop after the last fitted tab still inserts into the full saved order.
dropAfter={hoveredIndex === i + 1 && (!next || !fitsInStrip(next))}
/>,
);
});
return items;
}, [
tabs,
value,
addBorders,
layout,
reorderable,
isDragging,
onChangeValue,
hoveredIndex,
fitsInStrip,
fittedTabs,
wrapTabs,
]);
}, [tabs, value, addBorders, layout, reorderable, isDragging, onChangeValue, hoveredIndex]);
const tabStrip = (
const tabList = (
<div
ref={viewportRef}
role="tablist"
aria-label={label}
className={classNames(
!visibilityStorageKey && tabListClassName,
tabListClassName,
addBorders && layout === "horizontal" && "pl-3 -ml-1",
addBorders && layout === "vertical" && "ml-0 mb-2",
"flex items-center hide-scrollbars",
layout === "horizontal" && "h-full overflow-auto p-2",
layout === "vertical" &&
(responsive ? "relative overflow-hidden py-1" : "overflow-x-auto overflow-y-visible"),
layout === "vertical" && "overflow-x-auto overflow-y-visible ",
// Give space for button focus states within overflow boundary.
!responsive && !addBorders && layout === "vertical" && "py-1 pl-3 -ml-5 pr-1",
!addBorders && layout === "vertical" && "py-1 pl-3 -ml-5 pr-1",
)}
>
<div
className={classNames(
layout === "horizontal" && "flex flex-col w-full pb-3 mb-auto",
layout === "vertical" && "flex flex-row shrink-0 w-full",
wrapTabs && "flex-wrap gap-y-1",
)}
>
{tabButtons}
</div>
{responsive && !wrapTabs && (
<div
ref={measurementsRef}
aria-hidden
inert
className="absolute invisible pointer-events-none flex w-max"
>
{tabs.filter(isVisible).map((tab) => (
<div key={tab.value} data-measure-tab={tab.value} className="flex shrink-0">
<TabButton
tab={tab}
isActive={tab.value === value}
addBorders={addBorders}
layout={layout}
reorderable={false}
isDragging={false}
/>
</div>
))}
</div>
)}
</div>
);
// Keep the menu outside the scroll viewport so tabs cannot paint behind or past it.
const tabList = visibilityStorageKey ? (
<div
className={classNames(
tabListClassName,
"flex shrink-0 min-w-0 min-h-0",
layout === "vertical" ? "items-start" : "flex-col",
)}
>
<div className="min-w-0 min-h-0 flex-1">{tabStrip}</div>
<div
className={classNames(
"shrink-0 pl-1",
layout === "vertical" && "flex items-center h-md my-1",
{hoveredIndex === tabs.length && (
<div className={classNames("relative", layout === "vertical" ? "w-0" : "h-0")}>
<DropMarker orientation={layout === "vertical" ? "vertical" : "horizontal"} />
</div>
)}
>
<Dropdown
items={[
...(overflowTabs.length > 0
? [
{ type: "separator" as const, label: "More tabs" },
...overflowTabs.map((tab) => ({
label: tab.menuLabel ?? ("label" in tab ? tab.label : tab.value),
icon: tab.value === value ? ("check" as const) : ("empty" as const),
rightSlot: tab.rightSlot,
onSelect: () => onChangeValue(tab.value),
})),
{ type: "separator" as const },
]
: []),
{
label: "Show tabs",
submenu: [
{ type: "separator", label: "In this workspace" },
...tabs
.filter((t) => !("hidden" in t && t.hidden))
.map((tab) => ({
label: tab.menuLabel ?? ("label" in tab ? tab.label : tab.value),
icon: isVisible(tab)
? ("check_square_checked" as const)
: ("check_square_unchecked" as const),
disabled: isVisible(tab) && tabs.filter(isVisible).length <= 1,
keepOpenOnSelect: true,
onSelect: async () => {
if (!isVisible(tab)) {
await onChangeValue(tab.value);
} else {
if (tab.value === value) {
const next = tabs.find((t) => t.value !== tab.value && isVisible(t));
if (next) await onChangeValue(next.value);
}
await setVisibility((s) => ({ ...s, [tab.value]: false }));
}
},
})),
],
},
{
label: "Wrap tabs",
hidden: layout !== "vertical",
icon: wrapTabs ? "check_square_checked" : "check_square_unchecked",
keepOpenOnSelect: true,
onSelect: () => setWrapPreference((wrap) => !wrap),
},
]}
>
<IconButton icon="ellipsis" title="More tabs and visibility" size="sm" />
</Dropdown>
</div>
</div>
) : (
tabStrip
);
return (
@@ -518,13 +361,9 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
className={classNames(
className,
"tabs-container",
"h-full",
// Size wrapped rows at the resolved pane width instead of using an intrinsic grid track.
responsive
? "flex flex-col min-h-0 [&>.tab-content]:flex-1 [&>.tab-content]:min-h-0"
: "grid",
"h-full grid",
layout === "horizontal" && "grid-rows-1 grid-cols-[auto_minmax(0,1fr)]",
!responsive && layout === "vertical" && "grid-rows-[auto_minmax(0,1fr)] grid-cols-1",
layout === "vertical" && "grid-rows-[auto_minmax(0,1fr)] grid-cols-1",
)}
>
{reorderable ? (
@@ -570,11 +409,6 @@ interface TabButtonProps {
isDragging: boolean;
onChangeValue?: (value: string) => void;
overlay?: boolean;
width?: number;
spacingCompression?: number;
wrap?: boolean;
dropBefore?: boolean;
dropAfter?: boolean;
}
function TabButton({
@@ -586,26 +420,35 @@ function TabButton({
isDragging,
onChangeValue,
overlay = false,
width,
spacingCompression = 0,
wrap = false,
dropBefore = false,
dropAfter = false,
}: TabButtonProps) {
const constrained = width !== undefined;
const {
attributes,
listeners,
setNodeRef: setDraggableRef,
} = useDraggable({
id: tab.value,
disabled: !reorderable,
// The button inside handles focus
attributes: { tabIndex: -1 },
});
const { setNodeRef: setDroppableRef } = useDroppable({
id: tab.value,
disabled: !reorderable,
});
const handleSetWrapperRef = useCallback(
(n: HTMLDivElement | null) => {
if (reorderable) {
setDraggableRef(n);
setDroppableRef(n);
}
},
[reorderable, setDraggableRef, setDroppableRef],
);
const btnProps: Partial<ButtonProps> = {
color: "custom",
justify: layout === "horizontal" ? "start" : "center",
innerClassName: classNames(
"tab-button-label",
constrained && "w-auto! shrink-0 overflow-visible! text-clip!",
wrap && "whitespace-normal! overflow-visible! text-clip! [overflow-wrap:anywhere]",
),
style: constrained
? {
paddingInline: `calc(var(--spacing) * ${2 * (1 - spacingCompression * (1 - MIN_TAB_PADDING_SCALE))})`,
}
: undefined,
onClick: isActive
? undefined
: (e: React.MouseEvent) => {
@@ -614,8 +457,7 @@ function TabButton({
},
className: classNames(
"flex items-center rounded-sm whitespace-nowrap",
"ml-px",
!constrained && "px-2!",
"px-2! ml-px",
"outline-hidden",
"ring-none",
"focus-visible-or-class:outline-2",
@@ -629,9 +471,6 @@ function TabButton({
layout === "horizontal" && "min-w-40",
isDragging && "opacity-50",
overlay && "opacity-80",
constrained && "w-[calc(100%_-_1px)]",
wrap && "max-w-[calc(100%_-_1px)]! h-auto! min-h-md",
(constrained || wrap) && "[&>div:not(.tab-button-label)]:shrink-0",
),
};
@@ -648,9 +487,6 @@ function TabButton({
onChange={tab.options.onChange}
>
<Button
title={
(constrained || wrap) && typeof option?.label === "string" ? option.label : undefined
}
leftSlot={tab.leftSlot}
rightSlot={
<div className="flex items-center">
@@ -675,91 +511,22 @@ function TabButton({
);
}
return (
<Button
title={(constrained || wrap) && "label" in tab ? tab.label : undefined}
leftSlot={tab.leftSlot}
rightSlot={tab.rightSlot}
{...btnProps}
>
<Button leftSlot={tab.leftSlot} rightSlot={tab.rightSlot} {...btnProps}>
{"label" in tab && tab.label ? tab.label : tab.value}
</Button>
);
})();
const wrapperClassName = classNames(
"relative shrink-0",
layout === "vertical" && (constrained ? "min-w-0" : "mr-2"),
wrap && "min-w-0 max-w-[calc(100%_-_0.5rem)]",
);
const wrapperStyle: CSSProperties = {
width,
paddingRight: constrained
? `calc(var(--spacing) * ${2 * (1 - spacingCompression * (1 - MIN_TAB_GAP_SCALE))})`
: undefined,
};
// Anchor markers to their tabs so they follow the tab when it wraps onto another row.
const content = (
<>
{dropBefore && (
<DropMarker
key="before"
orientation={layout === "vertical" ? "vertical" : "horizontal"}
className={layout === "vertical" ? "left-0" : "top-0"}
/>
)}
{buttonContent}
{dropAfter && (
<DropMarker
key="after"
orientation={layout === "vertical" ? "vertical" : "horizontal"}
className={layout === "vertical" ? "right-0" : "bottom-0"}
/>
)}
</>
);
return reorderable && !overlay ? (
<DraggableTab value={tab.value} className={wrapperClassName} style={wrapperStyle}>
{content}
</DraggableTab>
) : (
<div className={wrapperClassName} style={wrapperStyle}>
{content}
</div>
);
}
// Measurement copies and drag overlays must not register duplicate draggable IDs.
function DraggableTab({
value,
className,
style,
children,
}: {
value: string;
className: string;
style?: CSSProperties;
children: ReactNode;
}) {
const {
attributes,
listeners,
setNodeRef: setDraggableRef,
} = useDraggable({
id: value,
attributes: { tabIndex: -1 },
});
const { setNodeRef: setDroppableRef } = useDroppable({ id: value });
const setRef = useCallback(
(node: HTMLDivElement | null) => {
setDraggableRef(node);
setDroppableRef(node);
},
[setDraggableRef, setDroppableRef],
);
// Apply drag handlers to wrapper, not button
const wrapperProps = reorderable && !overlay ? { ...attributes, ...listeners } : {};
return (
<div ref={setRef} className={className} style={style} {...attributes} {...listeners}>
{children}
<div
ref={handleSetWrapperRef}
className={classNames("relative", layout === "vertical" && "mr-2")}
{...wrapperProps}
>
{buttonContent}
</div>
);
}
@@ -1,113 +0,0 @@
import { describe, expect, test } from "vite-plus/test";
import { fitTabs } from "./fitTabs";
const tabs = [
{ value: "body", width: 80, compactWidth: 66 },
{ value: "headers", width: 100, compactWidth: 86 },
{ value: "info", width: 60, compactWidth: 46 },
{ value: "assertions", width: 120, compactWidth: 106 },
];
describe("responsive request tabs", () => {
test("restores the saved order when the pane has room for every tab", () => {
expect(fitTabs(tabs, 500, "assertions")).toEqual(
tabs.map(({ value, width }) => ({ value, width, spacingCompression: 0 })),
);
});
test("tightens gaps and padding without taking any space from labels", () => {
expect(fitTabs(tabs, 332, "assertions")).toEqual([
{ value: "body", width: 73, spacingCompression: 0.5 },
{ value: "headers", width: 93, spacingCompression: 0.5 },
{ value: "info", width: 53, spacingCompression: 0.5 },
{ value: "assertions", width: 113, spacingCompression: 0.5 },
]);
expect(fitTabs(tabs, 304, "assertions")).toEqual(
tabs.map(({ value, compactWidth }) => ({
value,
width: compactWidth,
spacingCompression: 1,
})),
);
});
test("moves whole tabs into overflow when compact spacing is not enough", () => {
expect(fitTabs(tabs, 258, "assertions")).toEqual([
{ value: "body", width: 66, spacingCompression: 1 },
{ value: "headers", width: 86, spacingCompression: 1 },
{ value: "assertions", width: 106, spacingCompression: 1 },
]);
expect(fitTabs(tabs, 257, "assertions").map((tab) => tab.value)).toEqual([
"body",
"assertions",
]);
});
test("keeps an active tab from the end visible without reordering it", () => {
expect(fitTabs(tabs, 172, "assertions")).toEqual([
{ value: "body", width: 66, spacingCompression: 1 },
{ value: "assertions", width: 106, spacingCompression: 1 },
]);
expect(fitTabs(tabs, 170, "headers").map((tab) => tab.value)).toEqual(["body", "headers"]);
});
test("puts even the active tab into overflow if it cannot fit on its own", () => {
expect(fitTabs(tabs, 40, "assertions")).toEqual([]);
expect(fitTabs(tabs, 0, "assertions")).toEqual([]);
expect(fitTabs(tabs, 90, "assertions")).toEqual([
{ value: "body", width: 80, spacingCompression: 0 },
]);
});
test("recalculates after a dropdown label or badge changes width", () => {
const longLabel = [{ value: "body", width: 200, compactWidth: 186 }, ...tabs.slice(1)];
const result = fitTabs(longLabel, 300, "body");
expect(result.map((tab) => tab.value)).toEqual(["body", "headers"]);
expect(result.reduce((sum, tab) => sum + tab.width, 0)).toBeCloseTo(300);
const largeBadge = [{ value: "body", width: 240, compactWidth: 226 }, ...tabs.slice(1)];
expect(fitTabs(largeBadge, 280, "body").map((tab) => tab.value)).toEqual(["body"]);
});
test("does not skip a long tab to squeeze later tabs into the leading group", () => {
expect(fitTabs(tabs, 200, "assertions").map((tab) => tab.value)).toEqual([
"body",
"assertions",
]);
expect(fitTabs(tabs, 160).map((tab) => tab.value)).toEqual(["body", "headers"]);
});
test("handles tabs with no spacing to compress", () => {
const shortTabs = tabs.filter((tab) => tab.value === "body" || tab.value === "info");
expect(fitTabs(shortTabs, 126, "body")).toEqual([
{ value: "body", width: 73, spacingCompression: 0.5 },
{ value: "info", width: 53, spacingCompression: 0.5 },
]);
const fixedTabs = shortTabs.map((tab) => ({ ...tab, compactWidth: tab.width }));
expect(fitTabs(fixedTabs, 126, "body")).toEqual([
{ value: "body", width: 80, spacingCompression: 0 },
]);
expect(fitTabs(fixedTabs, 40, "body")).toEqual([]);
});
test("stays within the pane and each label's bounds throughout resizing", () => {
for (let width = 0; width <= 500; width++) {
for (const active of tabs) {
const result = fitTabs(tabs, width, active.value);
expect(result.some((tab) => tab.value === active.value)).toBe(width >= active.compactWidth);
expect(result.reduce((sum, tab) => sum + tab.width, 0)).toBeLessThanOrEqual(width + 0.001);
for (const tab of result) {
const original = tabs.find((t) => t.value === tab.value)!;
expect(tab.width).toBeGreaterThanOrEqual(original.compactWidth);
expect(tab.width).toBeLessThanOrEqual(original.width);
expect(tab.spacingCompression).toBeGreaterThanOrEqual(0);
expect(tab.spacingCompression).toBeLessThanOrEqual(1);
// All removed width must come from spacing, at every pane size.
expect(original.width - tab.width).toBeCloseTo(
tab.spacingCompression * (original.width - original.compactWidth),
);
}
}
}
});
});
@@ -1,38 +0,0 @@
export const MIN_TAB_PADDING_SCALE = 0.5;
export const MIN_TAB_GAP_SCALE = 0.25;
export interface TabWidth {
value: string;
width: number;
/** Full label and badges, with the smallest padding and gap. */
compactWidth: number;
}
/** Tighten spacing to fit whole tabs, keeping every visible label intact. */
export function fitTabs(
tabs: TabWidth[],
availableWidth: number,
activeValue?: string,
): { value: string; width: number; spacingCompression: number }[] {
const available = Math.max(0, availableWidth);
// Even the active tab goes into overflow if it cannot fit on its own.
const active = tabs.find((tab) => tab.value === activeValue && tab.compactWidth <= available);
let remaining = available - (active?.compactWidth ?? 0);
const fitted = new Set(active ? [active.value] : []);
for (const tab of tabs) {
if (tab === active) continue;
if (tab.compactWidth > remaining) break;
fitted.add(tab.value);
remaining -= tab.compactWidth;
}
const visible = tabs.filter((tab) => fitted.has(tab.value));
const naturalWidth = visible.reduce((sum, tab) => sum + tab.width, 0);
const deficit = Math.max(0, naturalWidth - available);
const spareSpacing = visible.reduce((sum, tab) => sum + tab.width - tab.compactWidth, 0);
const spacingCompression = spareSpacing > 0 ? Math.min(1, deficit / spareSpacing) : 0;
return visible.map((tab) => ({
value: tab.value,
spacingCompression,
width: tab.width - spacingCompression * (tab.width - tab.compactWidth),
}));
}
@@ -1,55 +0,0 @@
import { useLayoutEffect, useRef, useState } from "react";
import { fitTabs, MIN_TAB_GAP_SCALE, MIN_TAB_PADDING_SCALE } from "./fitTabs";
export function useFittingTabs(enabled: boolean, activeValue?: string) {
const viewportRef = useRef<HTMLDivElement>(null);
const measurementsRef = useRef<HTMLDivElement>(null);
const [fittedTabs, setFittedTabs] = useState<ReturnType<typeof fitTabs> | null>(null);
// Reobserve after render: tab labels, badges and available tabs can all change.
useLayoutEffect(() => {
const viewport = viewportRef.current;
const measurements = measurementsRef.current;
if (!enabled || !viewport || !measurements) return;
const measure = () => {
const widths = Array.from(measurements.children, (child) => {
const width = child.getBoundingClientRect().width;
const button = child.querySelector("button");
const buttonStyle = button ? getComputedStyle(button) : null;
const padding = buttonStyle
? Number.parseFloat(buttonStyle.paddingLeft) + Number.parseFloat(buttonStyle.paddingRight)
: 0;
const gap = child.firstElementChild
? Number.parseFloat(getComputedStyle(child.firstElementChild).marginRight)
: 0;
const compactWidth =
width - padding * (1 - MIN_TAB_PADDING_SCALE) - gap * (1 - MIN_TAB_GAP_SCALE);
return {
value: child.getAttribute("data-measure-tab") ?? "",
width,
compactWidth,
};
});
const next = fitTabs(widths, viewport.clientWidth, activeValue);
setFittedTabs((previous) =>
previous?.length === next.length &&
previous.every(
(tab, i) =>
tab.value === next[i]?.value &&
tab.width === next[i]?.width &&
tab.spacingCompression === next[i]?.spacingCompression,
)
? previous
: next,
);
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(viewport);
for (const child of measurements.children) observer.observe(child);
return () => observer.disconnect();
});
return { viewportRef, measurementsRef, fittedTabs: enabled ? fittedTabs : null };
}
@@ -0,0 +1,43 @@
import { HStack, VStack } from "@yaakapp-internal/ui";
import { useState } from "react";
import { Button } from "../core/Button";
import { Select } from "../core/Select";
interface Props {
branches: string[];
onCancel: () => void;
onSelect: (branch: string) => void;
selectText: string;
}
export function BranchSelectionDialog({ branches, onCancel, onSelect, selectText }: Props) {
const [branch, setBranch] = useState<string>("__NONE__");
return (
<VStack
className="mb-4"
as="form"
space={4}
onSubmit={(e) => {
e.preventDefault();
onSelect(branch);
}}
>
<Select
name="branch"
hideLabel
label="Branch"
value={branch}
options={branches.map((b) => ({ label: b, value: b }))}
onChange={setBranch}
/>
<HStack space={2} justifyContent="end">
<Button onClick={onCancel} variant="border" color="secondary">
Cancel
</Button>
<Button type="submit" color="primary">
{selectText}
</Button>
</HStack>
</VStack>
);
}
@@ -1,4 +1,4 @@
import type { GitStatus, GitStatusEntry } from "@yaakapp-internal/git";
import type { GitStatusEntry } from "@yaakapp-internal/git";
import { useGit } from "@yaakapp-internal/git";
import type {
Environment,
@@ -9,7 +9,8 @@ import type {
Workspace,
} from "@yaakapp-internal/models";
import { Banner, HStack, Icon, InlineCode, SplitLayout } from "@yaakapp-internal/ui";
import { type ComponentProps, useCallback, useMemo, useState } from "react";
import classNames from "classnames";
import { useCallback, useMemo, useState } from "react";
import { modelToYaml } from "../../lib/diffYaml";
import { trackFeatureUsage } from "../../lib/featureFeedback";
import { resolvedModelName } from "../../lib/resolvedModelName";
@@ -18,11 +19,8 @@ import { showErrorToast } from "../../lib/toast";
import { sync } from "../../init/sync";
import { CommercialUseBanner } from "../CommercialUseBanner";
import { Button } from "../core/Button";
import { Chip } from "../core/Chip";
import type { CheckboxProps } from "../core/Checkbox";
import { Checkbox } from "../core/Checkbox";
import type { CheckboxTreeNode } from "../core/CheckboxTree";
import { CheckboxTree } from "../core/CheckboxTree";
import { DiffViewer } from "../core/Editor/DiffViewer";
import { Input } from "../core/Input";
import { Separator } from "../core/Separator";
@@ -45,7 +43,10 @@ interface CommitTreeNode {
export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
const callbacks = useGitCallbacks(syncDir);
const [{ status }, { commit, commitAndPush, add, unstage, restore }] = useGit(syncDir, callbacks);
const [{ status }, { commit, commitAndPush, add, unstage, restore }] = useGit(
syncDir,
callbacks,
);
const [isPushing, setIsPushing] = useState(false);
const [commitError, setCommitError] = useState<string | null>(null);
const [message, setMessage] = useState<string>("");
@@ -142,15 +143,6 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
return next(workspace, []);
}, [workspace, internalEntries]);
const treeNode: CheckboxTreeNode<CommitTreeNode> | null = useMemo(() => {
const toTreeNode = (n: CommitTreeNode): CheckboxTreeNode<CommitTreeNode> => ({
key: n.status.relaPath + n.status.status + n.status.staged,
data: n,
children: n.children.map(toTreeNode),
});
return tree == null ? null : toTreeNode(tree);
}, [tree]);
const checkNode = useCallback(
(treeNode: CommitTreeNode) => {
const checked = nodeCheckedStatus(treeNode);
@@ -198,7 +190,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
[restore],
);
if (tree == null || treeNode == null) {
if (tree == null) {
return null;
}
@@ -229,18 +221,12 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
style={innerStyle}
className="h-full overflow-y-auto pb-3 pr-0.5 transform-cpu"
>
<CheckboxTree
node={treeNode}
checked={(n) => nodeCheckedStatus(n.data)}
onCheck={(n) => checkNode(n.data)}
checkboxTitle={(n) =>
nodeCheckedStatus(n.data) ? "Unstage change" : "Stage change"
}
isRelevant={(n) => n.data.status.status !== "current"}
canSelectRow={(n) => n.data.status.status !== "current"}
onSelectRow={(n) => handleSelectChild(n.data.status)}
isRowSelected={(n) => selectedEntry?.relaPath === n.data.status.relaPath}
renderRow={(n) => <CommitTreeRow node={n.data} />}
<TreeNodeChildren
node={tree}
depth={0}
onCheck={checkNode}
onSelect={handleSelectChild}
selectedPath={selectedEntry?.relaPath ?? null}
/>
{externalEntries.find((e) => e.status !== "current") && (
<>
@@ -258,7 +244,10 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
</div>
)}
secondSlot={({ style: innerStyle }) => (
<div style={innerStyle} className="grid grid-rows-[minmax(0,1fr)_auto] gap-3 pb-2">
<div
style={innerStyle}
className="grid grid-rows-[minmax(0,1fr)_auto] gap-3 pb-2"
>
<Input
className="text-base! font-sans rounded-md"
placeholder="Commit message..."
@@ -312,30 +301,96 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
);
}
function CommitTreeRow({ node }: { node: CommitTreeNode }) {
function TreeNodeChildren({
node,
depth,
onCheck,
onSelect,
selectedPath,
}: {
node: CommitTreeNode | null;
depth: number;
onCheck: (node: CommitTreeNode, checked: boolean) => void;
onSelect: (entry: GitStatusEntry) => void;
selectedPath: string | null;
}) {
if (node === null) return null;
if (!isNodeRelevant(node)) return null;
const checked = nodeCheckedStatus(node);
const isSelected = selectedPath === node.status.relaPath;
return (
<>
{node.model.model !== "http_request" &&
node.model.model !== "grpc_request" &&
node.model.model !== "websocket_request" ? (
<Icon
color="secondary"
icon={
node.model.model === "folder"
? "folder"
: node.model.model === "environment"
? "variable"
: "house"
}
<div
className={classNames(
depth > 0 && "pl-4 ml-2 border-l border-dashed border-border-subtle relative",
)}
>
<div
className={classNames(
"relative flex gap-1 w-full h-xs items-center",
isSelected ? "text-text" : "text-text-subtle",
)}
>
{isSelected && (
<div className="absolute left-[-100vw] right-0 top-0 bottom-0 bg-surface-active opacity-30 -z-10" />
)}
<Checkbox
checked={checked}
title={checked ? "Unstage change" : "Stage change"}
hideLabel
onChange={(checked) => onCheck(node, checked)}
/>
) : (
<span aria-hidden className="w-4" />
)}
<div className="truncate flex-1">{resolvedModelName(node.model)}</div>
{node.status.status !== "current" && (
<Chip color={statusColor(node.status.status)}>{node.status.status}</Chip>
)}
</>
<button
type="button"
className={classNames("flex-1 min-w-0 flex items-center gap-1 px-1 py-0.5 text-left")}
onClick={() => node.status.status !== "current" && onSelect(node.status)}
>
{node.model.model !== "http_request" &&
node.model.model !== "grpc_request" &&
node.model.model !== "websocket_request" ? (
<Icon
color="secondary"
icon={
node.model.model === "folder"
? "folder"
: node.model.model === "environment"
? "variable"
: "house"
}
/>
) : (
<span aria-hidden className="w-4" />
)}
<div className="truncate flex-1">{resolvedModelName(node.model)}</div>
{node.status.status !== "current" && (
<InlineCode
className={classNames(
"py-0 bg-transparent w-24 text-center shrink-0",
node.status.status === "modified" && "text-info",
node.status.status === "untracked" && "text-success",
node.status.status === "removed" && "text-danger",
)}
>
{node.status.status}
</InlineCode>
)}
</button>
</div>
{node.children.map((childNode) => {
return (
<TreeNodeChildren
key={childNode.status.relaPath + childNode.status.status + childNode.status.staged}
node={childNode}
depth={depth + 1}
onCheck={onCheck}
onSelect={onSelect}
selectedPath={selectedPath}
/>
);
})}
</div>
);
}
@@ -367,9 +422,16 @@ function ExternalTreeNode({
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] gap-1 w-full items-center">
<Icon color="secondary" icon="file_code" />
<div className="truncate">{displayPath}</div>
<Chip className="ml-auto" color={statusColor(entry.status)}>
<InlineCode
className={classNames(
"py-0 ml-auto bg-transparent w-24 text-center",
entry.status === "modified" && "text-info",
entry.status === "untracked" && "text-success",
entry.status === "removed" && "text-danger",
)}
>
{entry.status}
</Chip>
</InlineCode>
</div>
}
/>
@@ -433,6 +495,15 @@ function setCheckedAndChildren(
if (toUnstage.length > 0) unstage({ relaPaths: toUnstage });
}
function isNodeRelevant(node: CommitTreeNode): boolean {
if (node.status.status !== "current") {
return true;
}
// Recursively check children
return node.children.some((c) => isNodeRelevant(c));
}
function DiffPanel({
entry,
onDiscardChanges,
@@ -455,28 +526,13 @@ function DiffPanel({
size="2xs"
variant="border"
onClick={() => onDiscardChanges(entry)}
>
Discard Changes
</Button>
>Discard Changes</Button>
</div>
<DiffViewer original={prevYaml ?? ""} modified={nextYaml ?? ""} className="flex-1 min-h-0" />
<DiffViewer
original={prevYaml ?? ""}
modified={nextYaml ?? ""}
className="flex-1 min-h-0"
/>
</div>
);
}
function statusColor(status: GitStatus): ComponentProps<typeof Chip>["color"] {
switch (status) {
case "modified":
return "info";
case "untracked":
return "success";
case "removed":
return "danger";
case "conflict":
return "warning";
case "current":
case "renamed":
case "type_change":
return "default";
}
}
+18 -16
View File
@@ -1,8 +1,8 @@
import type { DivergedStrategy } from "@yaakapp-internal/git";
import { InlineCode } from "@yaakapp-internal/ui";
import { HStack, InlineCode } from "@yaakapp-internal/ui";
import { useState } from "react";
import { showDialog } from "../../lib/dialog";
import { DialogFooter } from "../core/Dialog";
import { Button } from "../core/Button";
import { RadioCards } from "../core/RadioCards";
type Resolution = "force_reset" | "merge";
@@ -34,7 +34,7 @@ function DivergedDialog({ remote, branch, onResult, onHide }: DivergedDialogProp
};
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4 mb-4">
<p className="text-text-subtle">
Your local branch has diverged from{" "}
<InlineCode>
@@ -59,18 +59,18 @@ function DivergedDialog({ remote, branch, onResult, onHide }: DivergedDialogProp
},
]}
/>
<DialogFooter
inline
actions={[
{ label: "Cancel", onClick: handleCancel },
{
label: selected != null ? resolutionLabel[selected] : "Select an option",
color: selected === "force_reset" ? "danger" : "primary",
disabled: selected == null,
onClick: handleSubmit,
},
]}
/>
<HStack space={2} justifyContent="start" className="flex-row-reverse">
<Button
color={selected === "force_reset" ? "danger" : "primary"}
disabled={selected == null}
onClick={handleSubmit}
>
{selected != null ? resolutionLabel[selected] : "Select an option"}
</Button>
<Button variant="border" onClick={handleCancel}>
Cancel
</Button>
</HStack>
</div>
);
}
@@ -86,8 +86,10 @@ export async function promptDivergedStrategy({
showDialog({
id: "git-diverged",
title: "Branches Diverged",
hideX: true,
size: "sm",
disableClose: true,
disableBackdropClose: true,
onClose: () => resolve("cancel"),
render: ({ hide }) =>
DivergedDialog({
remote,
@@ -1,32 +0,0 @@
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, test, vi } from "vite-plus/test";
import { CsvViewerInner } from "./CsvViewer";
vi.mock("@yaakapp-internal/ui", () => ({
Table: ({ children }: { children: ReactNode }) => <table>{children}</table>,
TableBody: ({ children }: { children: ReactNode }) => <tbody>{children}</tbody>,
TableCell: ({ children }: { children: ReactNode }) => <td>{children}</td>,
TableHead: ({ children }: { children: ReactNode }) => <thead>{children}</thead>,
TableHeaderCell: ({ children }: { children: ReactNode }) => <th>{children}</th>,
TableRow: ({ children }: { children: ReactNode }) => <tr>{children}</tr>,
}));
describe("CsvViewer", () => {
test("renders columns that extend beyond the first row", () => {
const markup = renderToStaticMarkup(
<CsvViewerInner
text={[
"startDate,2026-02-03T00:00-03:00",
"endDate,2026-02-03T23:59:59-03:00",
"id,Fecha de inicio,Nombre,Estado,Perfil de puesto,ID de sucursal,Sucursal,Fecha de fin,ID de usuario",
"391118210,2026-02-03 12:58:55,atencion1,Disponible,ATD,3549,sucursal,2026-02-03 12:59:08,42041",
].join("\n")}
/>,
);
expect(markup).toContain("ID de usuario");
expect(markup).toContain("42041");
expect(markup.match(/<td>/g)).toHaveLength(20);
});
});
@@ -26,33 +26,27 @@ export function CsvViewer({ text, className }: Props) {
export function CsvViewerInner({ text, className }: { text: string | null; className?: string }) {
const parsed = useMemo(() => {
if (text == null) return null;
return Papa.parse<string[]>(text, { skipEmptyLines: true });
return Papa.parse<Record<string, string>>(text, { header: true, skipEmptyLines: true });
}, [text]);
if (parsed === null) return null;
const header = parsed.data[0] ?? [];
const rows = parsed.data.slice(1);
const columnCount = parsed.data.reduce((count, row) => Math.max(count, row.length), 0);
const columnIndexes = Array.from({ length: columnCount }, (_, index) => index);
return (
<div className="overflow-auto h-full">
<Table className={classNames(className, "text-sm")}>
<TableHead>
<TableRow>
{columnIndexes.map((columnIndex) => (
<TableHeaderCell key={columnIndex}>{header[columnIndex] ?? ""}</TableHeaderCell>
{parsed.meta.fields?.map((field) => (
<TableHeaderCell key={field}>{field}</TableHeaderCell>
))}
</TableRow>
</TableHead>
<TableBody>
{rows.map((row, i) => (
{parsed.data.map((row, i) => (
// oxlint-disable-next-line react/no-array-index-key
<TableRow key={i}>
{row.map((cell, columnIndex) => (
// oxlint-disable-next-line react/no-array-index-key
<TableCell key={columnIndex}>{cell}</TableCell>
{parsed.meta.fields?.map((key) => (
<TableCell key={key}>{row[key] ?? ""}</TableCell>
))}
</TableRow>
))}
@@ -3,15 +3,18 @@ import "react-pdf/dist/Page/AnnotationLayer.css";
import "./PdfViewer.css";
import type { PDFDocumentProxy } from "pdfjs-dist";
import { useMemo, useRef, useState } from "react";
import { Document, Page, pdfjs } from "react-pdf";
import { Document, Page } from "react-pdf";
import { useContainerSize } from "@yaakapp-internal/ui";
import { fireAndForget } from "../../lib/fireAndForget";
// Document can start loading during render, so configure its worker synchronously.
// Vite bundles the installed PDF.js worker as a local asset, including offline builds.
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/build/pdf.worker.min.mjs",
import.meta.url,
).toString();
fireAndForget(
import("react-pdf").then(({ pdfjs }) => {
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/build/pdf.worker.min.mjs",
import.meta.url,
).toString();
}),
);
interface Props {
/** A URL for the body the host already stored. */
@@ -56,8 +59,6 @@ export function PdfViewer({ bodyUrl, data }: Props) {
return (
<div ref={containerRef} className="w-full h-full overflow-y-auto">
<Document
// Keep the copied data stable: suspending the initial mount discards useMemo.
suspense={false}
file={src}
options={options}
onLoadSuccess={onDocumentLoadSuccess}
@@ -1,192 +0,0 @@
import { Icon } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import type { JsonPathSegment } from "../core/Editor/json/jsonPath";
import { IconButton } from "../core/IconButton";
import { Tooltip } from "../core/Tooltip";
interface Props {
segments: JsonPathSegment[];
appliedFilter: string | null;
/**
* How many leading segments make up the applied filter. `null` when unfiltered, or
* when the filter isn't a plain path and is shown as raw text instead.
*/
appliedDepth: number | null;
filterError: boolean;
/** Called with the number of leading segments to filter to. `0` clears the filter. */
onSelect: (count: number) => void;
}
/**
* Shows where the cursor sits inside a response (`$ > sort > [0] > id`). An applied
* plain-path filter is the start of the trail, so any crumb moves it. While filtered,
* the bar is tinted so a lingering filter is hard to miss.
*/
export function ResponseBreadcrumbBar({
segments,
appliedFilter,
appliedDepth,
filterError,
onSelect,
}: Props) {
const scrollRef = useRef<HTMLDivElement>(null);
const [overflow, setOverflow] = useState({ left: false, right: false });
const measure = useCallback(() => {
const el = scrollRef.current;
if (el == null) return;
const maxScroll = el.scrollWidth - el.clientWidth;
setOverflow({
left: el.scrollLeft > 1,
right: el.scrollLeft < maxScroll - 1,
});
}, []);
// Keep the deepest crumb (where the cursor is) in view as the path changes.
useLayoutEffect(() => {
const el = scrollRef.current;
if (el == null) return;
el.scrollLeft = el.scrollWidth;
measure();
}, [segments, measure]);
useEffect(() => {
const el = scrollRef.current;
if (el == null) return;
const observer = new ResizeObserver(measure);
observer.observe(el);
return () => observer.disconnect();
}, [measure]);
const scrollBy = useCallback((direction: -1 | 1) => {
const el = scrollRef.current;
if (el == null) return;
el.scrollBy({ left: direction * Math.max(120, el.clientWidth * 0.6), behavior: "smooth" });
}, []);
const isFiltered = appliedFilter != null;
return (
<div className="rounded bg-surface shadow pointer-events-auto">
<div
className={classNames(
"flex items-stretch h-6 rounded border overflow-hidden font-mono text-xs text-text-subtle select-none",
!isFiltered && "border-border-subtle",
isFiltered && !filterError && "border-primary/40 bg-primary/10",
filterError && "border-danger/40 bg-danger/10",
)}
>
{isFiltered && appliedDepth == null ? (
<span
className={classNames(
"flex items-center min-w-0 truncate px-2",
filterError ? "text-danger" : "text-primary",
)}
>
{appliedFilter}
</span>
) : (
<Crumb
label="$"
tooltip="Clear filter"
isApplied={isFiltered}
onClick={appliedDepth != null && appliedDepth > 0 ? () => onSelect(0) : null}
/>
)}
{overflow.left && (
<IconButton
size="xs"
icon="chevron_left"
title="Scroll breadcrumbs left"
iconColor="secondary"
onClick={() => scrollBy(-1)}
className="shrink-0 h-auto!"
/>
)}
<div
ref={scrollRef}
onScroll={measure}
className="flex items-stretch min-w-0 overflow-x-auto hide-scrollbars whitespace-nowrap"
>
{segments.map((segment, i) => {
const count = i + 1;
const isApplied = appliedDepth != null && count <= appliedDepth;
const isClickable = (!isFiltered || appliedDepth != null) && count !== appliedDepth;
return (
<div key={i} className="flex items-stretch shrink-0">
<Icon
icon="chevron_right"
size="xs"
className="text-text-subtlest shrink-0 self-center"
/>
<Crumb
label={segment.kind === "index" ? `[${segment.index}]` : segment.key}
tooltip={
segment.kind === "index"
? `Filter to element ${segment.index}`
: `Filter to ${segment.key}`
}
isApplied={isApplied}
onClick={isClickable ? () => onSelect(count) : null}
/>
</div>
);
})}
</div>
{overflow.right && (
<IconButton
size="xs"
icon="chevron_right"
title="Scroll breadcrumbs right"
iconColor="secondary"
onClick={() => scrollBy(1)}
className="shrink-0 h-auto!"
/>
)}
</div>
</div>
);
}
function Crumb({
label,
tooltip,
isApplied,
onClick,
}: {
label: string;
tooltip: string;
isApplied: boolean;
onClick: (() => void) | null;
}) {
const outerClassName = classNames("flex items-center px-0.5 py-0.5", isApplied && "text-primary");
const innerClassName = "flex items-center h-full px-1 rounded-sm";
if (onClick == null) {
return (
<span className={classNames(outerClassName, "shrink-0")}>
<span className={innerClassName}>{label}</span>
</span>
);
}
return (
<Tooltip content={tooltip} className="shrink-0 items-stretch!">
<button
type="button"
onClick={onClick}
className={classNames(outerClassName, "group/crumb focus-visible:outline-none")}
>
<span
className={classNames(
innerClassName,
"transition-colors group-hover/crumb:text-text group-hover/crumb:bg-surface-highlight",
"group-focus-visible/crumb:ring-1 group-focus-visible/crumb:ring-border-focus",
)}
>
{label}
</span>
</button>
</Tooltip>
);
}
@@ -1,18 +1,18 @@
import type { ReactNode } from "react";
import { Children, useCallback, useMemo, useState } from "react";
import { Children, useCallback, useMemo } from "react";
import { Banner, HStack, Icon, InlineCode } from "@yaakapp-internal/ui";
import { useFormatText } from "../../hooks/useFormatText";
import type { ResponseFilterApi } from "../../hooks/useResponseFilter";
import { Button } from "../core/Button";
import type { EditorProps } from "../core/Editor/Editor";
import { jsonBreadcrumbExtension } from "../core/Editor/json/breadcrumbExtension";
import type { JsonPathSegment } from "../core/Editor/json/jsonPath";
import { jsonPathToSegments, segmentsToJsonPath } from "../core/Editor/json/jsonPath";
import { hyperlink } from "../core/Editor/hyperlink/extension";
import { Editor } from "../core/Editor/LazyEditor";
import { IconButton } from "../core/IconButton";
import { Input } from "../core/Input";
import { ResponseBreadcrumbBar } from "./ResponseBreadcrumbBar";
import { RecentFiltersDropdown } from "./RecentFiltersDropdown";
const extraExtensions = [hyperlink];
interface Props {
text: string;
language: EditorProps["language"];
@@ -38,43 +38,12 @@ export function TextViewer({
filter,
filterResult,
}: Props) {
// Track the JSON path under the cursor to drive the breadcrumb bar. Selection
// works even in this read-only editor, so it updates as the user clicks around.
const [breadcrumbSegments, setBreadcrumbSegments] = useState<JsonPathSegment[]>([]);
const handleBreadcrumbUpdate = useCallback(
({ segments }: { segments: JsonPathSegment[] | null }) =>
setBreadcrumbSegments((prev) => {
const next = segments ?? [];
return segmentsToJsonPath(prev) === segmentsToJsonPath(next) ? prev : next;
}),
[],
);
const extraExtensions = useMemo(
() =>
language === "json"
? [hyperlink, jsonBreadcrumbExtension(handleBreadcrumbUpdate)]
: [hyperlink],
[language, handleBreadcrumbUpdate],
);
const canFilter =
filter != null && (language === "json" || language === "xml" || language === "html");
const isSearching = filter?.isSearching ?? false;
const appliedFilter = filter?.appliedFilter ?? null;
const resultError = filterResult?.error ?? false;
// Filter output is always an array of matches, so under a plain-path filter the
// cursor's leading index is that single match and the rest extends the filter.
const appliedSegments =
appliedFilter != null && language === "json" ? jsonPathToSegments(appliedFilter) : null;
const pathSegments =
appliedSegments != null
? [...appliedSegments, ...breadcrumbSegments.slice(1)]
: breadcrumbSegments;
const showBreadcrumbs =
filter != null &&
(appliedFilter != null || (language === "json" && breadcrumbSegments.length > 0));
const handleFilterKeyDown = useCallback(
(e: KeyboardEvent) => {
if (filter == null) return;
@@ -186,7 +155,16 @@ export function TextViewer({
}
return (
<div className="relative h-full w-full">
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full w-full">
{appliedFilter && filter != null ? (
<AppliedFilterBar
filter={appliedFilter}
error={resultError}
onClear={() => filter.replaceFilter("")}
/>
) : (
<span />
)}
<Editor
readOnly
className={className}
@@ -196,23 +174,45 @@ export function TextViewer({
extraExtensions={extraExtensions}
stateKey={stateKey}
/>
{showBreadcrumbs && (
<div className="absolute top-0 right-3 max-w-[70%] pointer-events-none">
<ResponseBreadcrumbBar
segments={language === "json" ? pathSegments : []}
appliedFilter={appliedFilter}
appliedDepth={appliedSegments?.length ?? null}
filterError={resultError}
onSelect={(count) =>
filter.replaceFilter(count === 0 ? "" : segmentsToJsonPath(pathSegments, count))
}
/>
</div>
)}
</div>
);
}
/**
* Shows what's actually filtering the body, which the filter box below can't convey
* once it holds an edited expression that hasn't been applied yet.
*/
function AppliedFilterBar({
filter,
error,
onClear,
}: {
filter: string;
error: boolean;
onClear: () => void;
}) {
return (
<Banner color={error ? "danger" : "info"} className="py-1! mb-2! text-sm">
<HStack space={2} className="min-w-0">
<Icon icon="filter" size="xs" className="shrink-0 opacity-70" />
<span className="truncate min-w-0" title={filter}>
Response filtered by <InlineCode>{filter}</InlineCode>
{error && " (invalid expression)"}
</span>
<Button
size="2xs"
variant="border"
color={error ? "danger" : "info"}
className="ml-auto shrink-0"
onClick={onClear}
>
Clear
</Button>
</HStack>
</Banner>
);
}
/** Convert \uXXXX to actual Unicode characters */
function decodeUnicodeLiterals(text: string): string {
return text.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => {
+1
View File
@@ -23,6 +23,7 @@ export function useExportData() {
id: "export-data",
title: "Export Data",
size: "md",
noPadding: true,
render: ({ hide }) => (
<ExportDataDialog
onHide={hide}
+12 -26
View File
@@ -112,12 +112,9 @@ export const hotkeysAtom = atom((get) => {
// Merge default hotkeys with custom hotkeys from settings
// Custom hotkeys override defaults for the same action
// An empty array means the hotkey is intentionally disabled
const merged: Partial<Record<HotkeyAction, string[]>> = {};
for (const action of hotkeyActions) {
merged[action] = defaultHotkeys[action];
}
const merged: Record<HotkeyAction, string[]> = { ...defaultHotkeys };
for (const [action, keys] of Object.entries(customHotkeys)) {
if (action in merged && Array.isArray(keys)) {
if (action in defaultHotkeys && Array.isArray(keys)) {
merged[action as HotkeyAction] = keys;
}
}
@@ -125,7 +122,7 @@ export const hotkeysAtom = atom((get) => {
});
/** Helper function to get current hotkeys from the store */
function getHotkeys(): Partial<Record<HotkeyAction, string[]>> {
function getHotkeys(): Record<HotkeyAction, string[]> {
return jotaiStore.get(hotkeysAtom);
}
@@ -168,25 +165,16 @@ const layoutInsensitiveKeys = [
"Space",
];
/** Zoom is the browser's own on these keys, so the app has no such action there. */
const ZOOM_ACTIONS: HotkeyAction[] = ["app.zoom_in", "app.zoom_out", "app.zoom_reset"];
/**
* The actions this host actually has. An action left out of here has no keys in
* `hotkeysAtom`, so it never matches and never claims the keystroke.
*/
export const hotkeyActions: HotkeyAction[] = (
Object.keys(defaultHotkeys) as (keyof typeof defaultHotkeys)[]
)
.filter((a) => platform.capabilities.interfaceZoom || !ZOOM_ACTIONS.includes(a))
.sort((a, b) => {
const scopeA = a.split(".")[0] || "";
const scopeB = b.split(".")[0] || "";
if (scopeA !== scopeB) {
return scopeA.localeCompare(scopeB);
}
return hotkeyLabels[a].localeCompare(hotkeyLabels[b]);
});
).sort((a, b) => {
const scopeA = a.split(".")[0] || "";
const scopeB = b.split(".")[0] || "";
if (scopeA !== scopeB) {
return scopeA.localeCompare(scopeB);
}
return hotkeyLabels[a].localeCompare(hotkeyLabels[b]);
});
export type HotKeyOptions = {
enable?: boolean | (() => boolean);
@@ -345,9 +333,7 @@ export function formatHotkeyString(trigger: string): string[] {
} else if (p === "Alt") {
labelParts.push("⌥");
} else if (p === "Enter") {
// U+21A9 has an emoji presentation, which Chromium's font fallback picks
// (a blue glyph among monochrome ones). U+FE0E forces the text form.
labelParts.push("↩︎");
labelParts.push("↩");
} else if (p === "Tab") {
labelParts.push("⇥");
} else if (p === "Backspace") {
@@ -1,133 +0,0 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vite-plus/test";
const mocks = vi.hoisted(() => ({
platform: { httpProxyUrl: "https://web.yaak.test" as string | null },
showConfirm: vi.fn<() => Promise<boolean>>(),
flushAllModelWrites: vi.fn(),
rpc: vi.fn(),
}));
vi.mock("@yaakapp-internal/platform", () => ({ platform: mocks.platform }));
vi.mock("@yaakapp-internal/models", () => ({ flushAllModelWrites: mocks.flushAllModelWrites }));
vi.mock("../lib/confirm", () => ({ showConfirm: mocks.showConfirm }));
vi.mock("../lib/appInfo", () => ({ appInfo: { identifier: "app.yaak.web" } }));
vi.mock("../lib/rpc", () => ({ rpc: mocks.rpc }));
vi.mock("../lib/toast", () => ({ showToast: vi.fn() }));
vi.mock("./useActiveCookieJar", () => ({ getActiveCookieJar: () => ({ id: "cj_test" }) }));
vi.mock("./useActiveEnvironment", () => ({ getActiveEnvironment: () => ({ id: "ev_test" }) }));
async function sender() {
return (await import("./useSendAnyHttpRequest")).sendAnyHttpRequest;
}
describe("web proxy confirmation before sending", () => {
let stored: Map<string, string>;
beforeEach(() => {
vi.resetModules();
vi.resetAllMocks();
mocks.platform.httpProxyUrl = "https://web.yaak.test";
mocks.rpc.mockResolvedValue({ id: "rs_test" });
stored = new Map();
vi.stubGlobal("localStorage", {
getItem: (key: string) => stored.get(key) ?? null,
setItem: (key: string, value: string) => stored.set(key, value),
});
});
afterEach(() => vi.unstubAllGlobals());
test("blocks all pending sends until one shared confirmation is accepted", async () => {
let decide!: (accepted: boolean) => void;
mocks.showConfirm.mockReturnValue(
new Promise((resolve) => {
decide = resolve;
}),
);
const send = await sender();
const first = send.mutateAsync("rq_one");
const second = send.mutateAsync("rq_two");
expect(mocks.showConfirm).toHaveBeenCalledTimes(1);
expect(mocks.flushAllModelWrites).not.toHaveBeenCalled();
expect(mocks.rpc).not.toHaveBeenCalled();
decide(true);
await Promise.all([first, second]);
expect(mocks.rpc).toHaveBeenCalledTimes(2);
expect(mocks.rpc).toHaveBeenCalledWith("cmd_send_http_request", {
requestId: "rq_one",
environmentId: "ev_test",
cookieJarId: "cj_test",
});
expect(stored.size).toBe(1);
});
test("cancel sends nothing, remembers nothing, and asks again next time", async () => {
mocks.showConfirm.mockResolvedValue(false);
const send = await sender();
expect(await send.mutateAsync("rq_one")).toBeNull();
expect(await send.mutateAsync("rq_one")).toBeNull();
expect(mocks.showConfirm).toHaveBeenCalledTimes(2);
expect(mocks.flushAllModelWrites).not.toHaveBeenCalled();
expect(mocks.rpc).not.toHaveBeenCalled();
expect(stored.size).toBe(0);
});
test("cancel applies to every send waiting on the dialog", async () => {
mocks.showConfirm.mockResolvedValue(false);
const send = await sender();
expect(await Promise.all([send.mutateAsync("rq_one"), send.mutateAsync("rq_two")])).toEqual([
null,
null,
]);
expect(mocks.showConfirm).toHaveBeenCalledTimes(1);
expect(mocks.rpc).not.toHaveBeenCalled();
});
test("remembers acceptance across module reloads for the same proxy", async () => {
mocks.showConfirm.mockResolvedValue(true);
await (await sender()).mutateAsync("rq_one");
vi.resetModules();
await (await sender()).mutateAsync("rq_two");
expect(mocks.showConfirm).toHaveBeenCalledTimes(1);
expect(mocks.rpc).toHaveBeenCalledTimes(2);
});
test("requires a new decision when the proxy changes", async () => {
mocks.showConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(false);
const send = await sender();
await send.mutateAsync("rq_one");
mocks.platform.httpProxyUrl = "https://another-proxy.test";
expect(await send.mutateAsync("rq_two")).toBeNull();
expect(mocks.showConfirm).toHaveBeenCalledTimes(2);
expect(mocks.rpc).toHaveBeenCalledTimes(1);
});
test("still asks and remembers in this tab if browser storage is unavailable", async () => {
const unavailable = () => {
throw new Error("Storage unavailable");
};
vi.stubGlobal("localStorage", { getItem: unavailable, setItem: unavailable });
mocks.showConfirm.mockResolvedValue(true);
const send = await sender();
await send.mutateAsync("rq_one");
await send.mutateAsync("rq_two");
expect(mocks.showConfirm).toHaveBeenCalledTimes(1);
expect(mocks.rpc).toHaveBeenCalledTimes(2);
});
test("desktop sends directly without confirmation", async () => {
mocks.platform.httpProxyUrl = null;
await (await sender()).mutateAsync("rq_one");
expect(mocks.showConfirm).not.toHaveBeenCalled();
expect(mocks.rpc).toHaveBeenCalledTimes(1);
expect(stored.size).toBe(0);
});
test("an empty selection does not prompt or send", async () => {
expect(await (await sender()).mutateAsync(null)).toBeNull();
expect(mocks.showConfirm).not.toHaveBeenCalled();
expect(mocks.rpc).not.toHaveBeenCalled();
});
});
@@ -1,6 +1,5 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { flushAllModelWrites } from "@yaakapp-internal/models";
import { confirmWebProxy } from "../lib/confirmWebProxy";
import { rpc } from "../lib/rpc";
import { getActiveCookieJar } from "./useActiveCookieJar";
import { getActiveEnvironment } from "./useActiveEnvironment";
@@ -11,10 +10,6 @@ async function sendAnyHttpRequestById(id: string | null): Promise<HttpResponse |
return null;
}
if (!(await confirmWebProxy())) {
return null;
}
await flushAllModelWrites();
return rpc("cmd_send_http_request", {
-3
View File
@@ -3,9 +3,6 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" type="image/png" href="/icon-128.png" sizes="128x128" />
<link rel="apple-touch-icon" href="/icon-128.png" />
<title>Yaak App</title>
<!-- <script src="http://localhost:8097"></script>-->
+2 -1
View File
@@ -14,8 +14,9 @@ export function showAlert({ id, title, body, size = "sm" }: AlertArgs) {
showDialog({
id,
title,
hideX: true,
size,
disableClose: true,
disableBackdropClose: true, // Prevent accidental dismisses
render: ({ hide }) => Alert({ onHide: hide, body }),
});
}
+2 -1
View File
@@ -18,8 +18,9 @@ export async function showConfirm({
return new Promise((onResult: ConfirmProps["onResult"]) => {
showDialog({
...extraProps,
hideX: true,
size,
disableClose: true,
disableBackdropClose: true, // Prevent accidental dismisses
render: ({ hide }) => Confirm({ onHide: hide, color, onResult, confirmText, requireTyping }),
});
});
@@ -1,64 +0,0 @@
import { renderToStaticMarkup } from "react-dom/server";
import { afterEach, beforeEach, describe, expect, test, vi } from "vite-plus/test";
import type { DialogInstance } from "../components/Dialogs";
const mocks = vi.hoisted(() => ({
platform: { httpProxyUrl: "https://web.yaak.app" },
showDialog: vi.fn<(dialog: DialogInstance) => void>(),
}));
vi.mock("@yaakapp-internal/platform", () => ({ platform: mocks.platform }));
vi.mock("./appInfo", () => ({ appInfo: { identifier: "app.yaak.web" } }));
vi.mock("./dialog", () => ({ showDialog: mocks.showDialog }));
vi.mock("../hooks/useHotKey", () => ({
useHotKey: vi.fn(),
useFormattedHotkey: () => null,
}));
describe("proxy consent disclosure", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
vi.stubGlobal("localStorage", { getItem: () => null });
});
afterEach(() => vi.unstubAllGlobals());
test.each([
["Yaak-hosted", "https://web.yaak.app"],
["self-hosted", "https://yaak.example.com"],
["local", "http://localhost:8080"],
["split deployment", "https://send.example.com/relay"],
])("identifies the %s proxy and requires an explicit decision", async (_, proxyUrl) => {
mocks.platform.httpProxyUrl = proxyUrl;
const { confirmWebProxy } = await import("./confirmWebProxy");
void confirmWebProxy();
const dialog = mocks.showDialog.mock.calls[0]?.[0];
expect(dialog).toMatchObject({
id: "web-proxy-consent",
title: "Requests in Yaak Web use a proxy",
size: "sm",
disableClose: true,
});
if (dialog == null) throw new Error("Expected the proxy consent dialog");
const disclosure = renderToStaticMarkup(<>{dialog.description}</>);
expect(disclosure).toContain(`>${proxyUrl}</code>`);
expect(disclosure).toMatch(/request and response data/i);
expect(disclosure).toContain("credentials");
expect(disclosure).toContain("server shown below");
expect(disclosure).not.toContain("Yaak’s servers");
expect(disclosure).toContain(
'href="https://yaak.app/docs/getting-started/web-proxy?ref=app.yaak.web"',
);
expect(disclosure).toContain('target="_blank"');
expect(disclosure).toContain("how the proxy works");
const Actions = dialog.render;
const actions = renderToStaticMarkup(<Actions hide={vi.fn()} />);
expect(actions).toMatch(/<button[^>]*type="submit"/);
expect(actions).toContain(">Send via Proxy<");
expect(actions).toContain(">Cancel<");
});
});
-60
View File
@@ -1,60 +0,0 @@
import { platform } from "@yaakapp-internal/platform";
import { InlineCode } from "@yaakapp-internal/ui";
import { Link } from "../components/core/Link";
import { showConfirm } from "./confirm";
const acceptedProxies = new Set<string>();
const pendingConfirmations = new Map<string, Promise<boolean>>();
/** One decision for all pending sends; only acceptance is remembered. */
export function confirmWebProxy(): Promise<boolean> {
const proxyUrl = platform.httpProxyUrl;
if (proxyUrl == null) return Promise.resolve(true);
const key = `yaak.webProxyConsent.v1:${proxyUrl}`;
try {
if (localStorage.getItem(key) === "accepted") return Promise.resolve(true);
} catch {
// Storage can be unavailable in private or restricted browsing.
}
if (acceptedProxies.has(proxyUrl)) return Promise.resolve(true);
const pending = pendingConfirmations.get(proxyUrl);
if (pending != null) return pending;
const confirmation = showConfirm({
id: "web-proxy-consent",
title: "Requests in Yaak Web use a proxy",
confirmText: "Send via Proxy",
description: (
<div className="space-y-3">
<p>
Yaak Web sends requests through a hosted proxy. Request and response data, including
credentials, pass through the server shown below.
</p>
<p>
<InlineCode className="break-all">{proxyUrl}</InlineCode>
</p>
<p>
Learn more about{" "}
<Link href="https://yaak.app/docs/getting-started/web-proxy">how the proxy works</Link>.
</p>
</div>
),
})
.then((accepted) => {
if (accepted) {
acceptedProxies.add(proxyUrl);
try {
localStorage.setItem(key, "accepted");
} catch {
// Keep acceptance for this tab if it cannot be persisted.
}
}
return accepted;
})
.finally(() => pendingConfirmations.delete(proxyUrl));
pendingConfirmations.set(proxyUrl, confirmation);
return confirmation;
}
+4 -4
View File
@@ -1,4 +1,4 @@
import { MIMEType } from "whatwg-mimetype";
import MimeType from "whatwg-mimetype";
import type { EditorProps } from "../components/core/Editor/Editor";
export function languageFromContentType(
@@ -90,10 +90,10 @@ export function isProbablyTextContentType(contentType: string | null): boolean {
].some((textType) => normalized === textType || normalized.endsWith(textType));
}
export function getMimeTypeFromContentType(contentType: string): MIMEType {
export function getMimeTypeFromContentType(contentType: string): MimeType {
try {
return new MIMEType(contentType);
return new MimeType(contentType);
} catch {
return new MIMEType("text/plain");
return new MimeType("text/plain");
}
}
-3
View File
@@ -1,3 +0,0 @@
export function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
+4 -1
View File
@@ -1,5 +1,4 @@
import type { GraphQLSchema, IntrospectionQuery } from "graphql";
import { errorMessage } from "./errorMessage";
import { buildClientSchema, buildSchema, introspectionFromSchema } from "graphql";
// Accepts either a GraphQL introspection JSON ({ data: { __schema } } or
@@ -46,3 +45,7 @@ export function tryBuildIntrospectionFromFile(
};
}
}
function errorMessage(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
+17 -55
View File
@@ -1,41 +1,14 @@
import {
type BatchUpsertResult,
type ImportDestination,
type ImportPlan,
type ImportSource,
workspacesAtom,
} from "@yaakapp-internal/models";
import type { BatchUpsertResult } from "@yaakapp-internal/models";
import { FormattedError, VStack } from "@yaakapp-internal/ui";
import { Button } from "../components/core/Button";
import { ImportDataDialog } from "../components/ImportDataDialog";
import type { ImportSourcePath } from "../components/ImportSourceList";
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
import { createFastMutation } from "../hooks/useFastMutation";
import { showAlert } from "./alert";
import { showDialog } from "./dialog";
import { jotaiStore } from "./jotai";
import { pluralizeCount } from "./pluralize";
import { router } from "./router";
import { rpc } from "./rpc";
// Stable identities so the dialog's effects don't re-run (and cancel in-flight
// fetches) every time the dialog container re-renders.
const planSources = (sources: ImportSourcePath[], destination: ImportDestination) =>
rpc<ImportPlan>("cmd_import_data", {
filePaths: sources.filter((source) => source.kind !== "url").map((source) => source.path),
urls: sources.filter((source) => source.kind === "url").map((source) => source.path),
destination,
});
const detectSource = (source: ImportSourcePath) =>
rpc<string>(
"cmd_detect_import_source",
source.kind === "url" ? { url: source.path } : { filePath: source.path },
);
const listSources = (workspaceId: string) =>
rpc<ImportSource[]>("cmd_list_import_sources", { workspaceId });
const findSourcesForOrigin = (args: { filePath?: string; url?: string }) =>
rpc<ImportSource[]>("cmd_import_sources_for_origin", args);
export const importData = createFastMutation({
mutationKey: ["import_data"],
onError: (err: string) => {
@@ -48,40 +21,29 @@ export const importData = createFastMutation({
},
mutationFn: async () => {
return new Promise<void>((resolve, reject) => {
const currentWorkspace = jotaiStore.get(activeWorkspaceAtom);
const workspaces = jotaiStore.get(workspacesAtom);
showDialog({
id: "import",
title: "Import Data",
size: "lg",
className: "h-[36rem]",
disableClose: true,
size: "sm",
render: ({ hide }) => {
const cancel = () => {
hide();
resolve();
};
const fail = (err: unknown) => {
hide();
reject(err);
};
const commit = async (plan: ImportPlan) => {
const imported = await rpc<BatchUpsertResult>("cmd_commit_import", { plan });
hide();
await finishImport(imported);
resolve();
const importAndHide = async (runImport: () => Promise<BatchUpsertResult>) => {
try {
await finishImport(await runImport());
resolve();
} catch (err) {
reject(err);
} finally {
hide();
}
};
return (
<ImportDataDialog
currentWorkspace={currentWorkspace}
workspaces={workspaces}
planSources={planSources}
detectSource={detectSource}
listSources={listSources}
findSourcesForOrigin={findSourcesForOrigin}
commit={commit}
cancel={cancel}
onError={fail}
importFile={(filePath) =>
importAndHide(() => rpc<BatchUpsertResult>("cmd_import_data", { filePath }))
}
importUrl={(url) =>
importAndHide(() => rpc<BatchUpsertResult>("cmd_import_url", { url }))
}
/>
);
},
+14 -60
View File
@@ -12,7 +12,7 @@ import type {
UpdateResponse,
YaakNotification,
} from "@yaakapp-internal/tauri-client";
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import { HStack, Icon, VStack } from "@yaakapp-internal/ui";
import { openSettings } from "../commands/openSettings";
import { Button } from "../components/core/Button";
import { ButtonInfiniteLoading } from "../components/core/ButtonInfiniteLoading";
@@ -180,65 +180,9 @@ function showUpdateInstalledToast(version: string) {
async function showUpdateAvailableToast(updateInfo: UpdateInfo) {
const UPDATE_TOAST_ID = "update-info";
const { version, replyEventId, downloaded, install } = updateInfo;
const { version, replyEventId, downloaded } = 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&apos;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;
}
jotaiStore.set(updateAvailableAtom, { version, downloaded });
// Acknowledge the event, so we don't time out and try the fallback update logic
await platform.emit(replyEventId, { type: "ack" } satisfies UpdateResponse);
@@ -271,7 +215,17 @@ async function showUpdateAvailableToast(updateInfo: UpdateInfo) {
>
{downloaded ? "Install Now" : "Download and Install"}
</ButtonInfiniteLoading>
{whatsNewButton}
<Button
size="xs"
color="info"
variant="border"
rightSlot={<Icon icon="external_link" />}
onClick={async () => {
await platform.openUrl(`https://yaak.app/changelog/${version}`);
}}
>
What&apos;s New
</Button>
</HStack>
),
});
@@ -1,251 +0,0 @@
import { describe, expect, test } from "vite-plus/test";
import type { JsonSchema } from "./jsonSchemaExample";
import { buildExampleFromSchema } from "./jsonSchemaExample";
describe("buildExampleFromSchema", () => {
test("fills scalar fields with placeholders", () => {
const schema: JsonSchema = {
type: "object",
properties: {
name: { type: "string" },
age: { type: "number", format: "int32" },
active: { type: "boolean" },
data: { type: "string", format: "byte" },
},
};
expect(buildExampleFromSchema(schema)).toEqual({
name: "",
age: 0,
active: false,
data: "",
});
});
test("encodes 64-bit integers as strings", () => {
const schema: JsonSchema = {
type: "object",
properties: {
id: { type: "string", format: "int64" },
count: { type: "string", format: "uint64" },
offset: { type: "string", format: "sfixed64" },
},
};
expect(buildExampleFromSchema(schema)).toEqual({ id: "0", count: "0", offset: "0" });
});
test("fills date-time with a parseable timestamp", () => {
const schema: JsonSchema = {
type: "object",
properties: { createdAt: { type: "string", format: "date-time" } },
};
const example = buildExampleFromSchema(schema) as { createdAt: string };
expect(Number.isNaN(Date.parse(example.createdAt))).toBe(false);
});
test("fills a duration with a value that parses", () => {
const schema: JsonSchema = {
type: "object",
properties: { timeout: { type: "string", format: "duration" } },
};
// An empty string fails protobuf's Duration parsing, so the message wouldn't send
expect(buildExampleFromSchema(schema)).toEqual({ timeout: "0s" });
});
test("expands nested messages through $defs", () => {
const schema: JsonSchema = {
type: "object",
properties: { user: { $ref: "#/$defs/example.User" } },
$defs: {
"example.User": {
type: "object",
properties: {
name: { type: "string" },
address: { $ref: "#/$defs/example.Address" },
},
},
"example.Address": {
type: "object",
properties: { city: { type: "string" } },
},
},
};
expect(buildExampleFromSchema(schema)).toEqual({
user: { name: "", address: { city: "" } },
});
});
test("gives repeated fields a single placeholder item", () => {
const schema: JsonSchema = {
type: "object",
properties: {
tags: { type: "array", items: { type: "string" } },
users: { type: "array", items: { $ref: "#/$defs/example.User" } },
unknown: { type: "array" },
},
$defs: {
"example.User": { type: "object", properties: { name: { type: "string" } } },
},
};
expect(buildExampleFromSchema(schema)).toEqual({
tags: [""],
users: [{ name: "" }],
unknown: [],
});
});
test("uses the first value of an enum", () => {
const schema: JsonSchema = {
type: "object",
properties: {
status: { type: "string", enum: ["STATUS_UNSPECIFIED", "STATUS_ACTIVE"] },
empty: { type: "string", enum: [] },
},
};
expect(buildExampleFromSchema(schema)).toEqual({ status: "STATUS_UNSPECIFIED", empty: "" });
});
test("gives maps a single placeholder entry", () => {
const schema: JsonSchema = {
type: "object",
properties: {
labels: { type: "object", additionalProperties: { type: "string" } },
users: { type: "object", additionalProperties: { $ref: "#/$defs/example.User" } },
},
$defs: {
"example.User": { type: "object", properties: { name: { type: "string" } } },
},
};
expect(buildExampleFromSchema(schema)).toEqual({
labels: { key: "" },
users: { key: { name: "" } },
});
});
test("stops at the root self-reference", () => {
const schema: JsonSchema = {
type: "object",
properties: {
value: { type: "string" },
children: { type: "array", items: { $ref: "#" } },
},
};
expect(buildExampleFromSchema(schema)).toEqual({ value: "", children: [{}] });
});
test("stops at a cycle between messages", () => {
const schema: JsonSchema = {
type: "object",
properties: { node: { $ref: "#/$defs/example.Node" } },
$defs: {
"example.Node": {
type: "object",
properties: {
name: { type: "string" },
parent: { $ref: "#/$defs/example.Node" },
leaf: { $ref: "#/$defs/example.Leaf" },
},
},
"example.Leaf": {
type: "object",
properties: { node: { $ref: "#/$defs/example.Node" } },
},
},
};
expect(buildExampleFromSchema(schema)).toEqual({
node: { name: "", parent: {}, leaf: { node: {} } },
});
});
test("expands the same message twice when it is not on the same path", () => {
const schema: JsonSchema = {
type: "object",
properties: {
from: { $ref: "#/$defs/example.User" },
to: { $ref: "#/$defs/example.User" },
},
$defs: {
"example.User": { type: "object", properties: { name: { type: "string" } } },
},
};
expect(buildExampleFromSchema(schema)).toEqual({ from: { name: "" }, to: { name: "" } });
});
test("fills every branch of a flattened oneof", () => {
const schema: JsonSchema = {
type: "object",
properties: {
id: { type: "string" },
text: { type: "string" },
image: { $ref: "#/$defs/example.Image" },
},
$defs: {
"example.Image": { type: "object", properties: { url: { type: "string" } } },
},
};
expect(buildExampleFromSchema(schema)).toEqual({
id: "",
text: "",
image: { url: "" },
});
});
test("stops expanding once the node budget runs out", () => {
// Every level references the next one twice, so an unbounded walk would build 2^depth
// nodes without ever repeating a ref on the same path.
const depth = 16;
const $defs: Record<string, JsonSchema> = { [`d${depth}`]: { type: "string" } };
for (let i = 0; i < depth; i++) {
$defs[`d${i}`] = {
type: "object",
properties: {
a: { $ref: `#/$defs/d${i + 1}` },
b: { $ref: `#/$defs/d${i + 1}` },
},
};
}
const example = buildExampleFromSchema({
type: "object",
properties: { root: { $ref: "#/$defs/d0" } },
$defs,
});
// 2 ** 16 nodes unbounded; the budget holds it to a couple of thousand
expect(countNodes(example)).toBeLessThan(10_000);
});
test("handles messages without a known type", () => {
const schema: JsonSchema = {
type: "object",
properties: {
empty: {},
struct: { type: "object" },
missing: { $ref: "#/$defs/example.Nope" },
},
};
expect(buildExampleFromSchema(schema)).toEqual({ empty: null, struct: {}, missing: {} });
});
});
function countNodes(value: unknown): number {
if (Array.isArray(value)) {
return 1 + value.reduce((total: number, v) => total + countNodes(v), 0);
}
if (value !== null && typeof value === "object") {
return 1 + Object.values(value).reduce((total: number, v) => total + countNodes(v), 0);
}
return 1;
}
-121
View File
@@ -1,121 +0,0 @@
/**
* Subset of JSON Schema emitted by the gRPC reflection layer for a method's
* input message. See `message_to_json_schema` in the `yaak-grpc` crate.
*/
export type JsonSchema = {
type?: string;
format?: string;
properties?: Record<string, JsonSchema>;
items?: JsonSchema;
additionalProperties?: JsonSchema;
enum?: unknown[];
$defs?: Record<string, JsonSchema>;
$ref?: string;
};
const DEFS_PREFIX = "#/$defs/";
const ROOT_REF = "#";
// Protobuf 64-bit integers are encoded as strings in the JSON mapping
const STRING_NUMBER_FORMATS = ["int64", "uint64", "sint64", "fixed64", "sfixed64"];
// Refs on sibling branches each expand their own subtree, so a schema that references the
// same messages repeatedly can produce exponentially many nodes without ever cycling.
const MAX_NODES = 5000;
type Budget = { remaining: number };
/** Build a sample message with placeholder values for every field in the schema */
export function buildExampleFromSchema(schema: JsonSchema): unknown {
// The root is already being built, so a `#` ref anywhere below it is a cycle
return buildValue(schema, schema, new Set([ROOT_REF]), { remaining: MAX_NODES });
}
function buildValue(
schema: JsonSchema,
root: JsonSchema,
refPath: Set<string>,
budget: Budget,
): unknown {
if (schema == null || typeof schema !== "object" || budget.remaining <= 0) {
return null;
}
budget.remaining -= 1;
if (typeof schema.$ref === "string") {
if (refPath.has(schema.$ref)) {
return {};
}
const resolved = resolveRef(schema.$ref, root);
if (resolved == null) {
return {};
}
return buildValue(resolved, root, new Set(refPath).add(schema.$ref), budget);
}
if (Array.isArray(schema.enum)) {
return schema.enum[0] ?? "";
}
switch (schema.type) {
case "object":
return buildObject(schema, root, refPath, budget);
case "array":
return schema.items == null ? [] : [buildValue(schema.items, root, refPath, budget)];
case "string":
return buildString(schema.format);
case "number":
return 0;
case "boolean":
return false;
default:
return null;
}
}
function buildObject(
schema: JsonSchema,
root: JsonSchema,
refPath: Set<string>,
budget: Budget,
): unknown {
if (schema.properties != null && typeof schema.properties === "object") {
const example: Record<string, unknown> = {};
for (const [name, propertySchema] of Object.entries(schema.properties)) {
example[name] = buildValue(propertySchema, root, refPath, budget);
}
return example;
}
// Maps have no properties, only a value schema
if (schema.additionalProperties != null) {
return { key: buildValue(schema.additionalProperties, root, refPath, budget) };
}
return {};
}
function buildString(format: string | undefined): string {
if (format === "date-time") {
return new Date().toISOString();
}
// Duration JSON is a decimal string with an `s` suffix, and an empty one fails to parse
if (format === "duration") {
return "0s";
}
if (format != null && STRING_NUMBER_FORMATS.includes(format)) {
return "0";
}
return "";
}
function resolveRef(ref: string, root: JsonSchema): JsonSchema | null {
if (ref === ROOT_REF) {
return root;
}
if (!ref.startsWith(DEFS_PREFIX)) {
return null;
}
return root.$defs?.[ref.slice(DEFS_PREFIX.length)] ?? null;
}
+22 -12
View File
@@ -6,6 +6,7 @@ import type { SniffedValue } from "../components/core/Editor/sniffValue";
import { isEncodedRun } from "../components/core/Editor/sniffValue";
import { copyToClipboard } from "./copy";
import { fireAndForget } from "./fireAndForget";
import { rpc } from "./rpc";
import { showToast } from "./toast";
import { platform } from "@yaakapp-internal/platform";
@@ -211,26 +212,35 @@ export function copyImage(text: string, sniffed: SniffedValue, fallback: string)
});
}
/** Base64 of a byte array, in chunks so a few megabytes don't blow the argument limit. */
function toBase64(bytes: Uint8Array): string {
let binary = "";
for (let i = 0; i < bytes.length; i += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
}
return btoa(binary);
}
/**
* Writes a collapsed value to a file the user picks.
*
* A value that is already base64 is handed over as base64. These are the values
* the editor collapsed for being large, so decoding one here only for a host to
* A value that is already base64 goes straight to the backend as-is — decoding it here only to
* encode it again would walk megabytes twice for nothing.
*/
export async function saveValue(text: string, sniffed: SniffedValue | null, name: string) {
const ext = sniffed == null ? "txt" : (mime.getExtension(sniffed.mime) ?? "bin");
const content =
sniffed == null
? new TextEncoder().encode(text)
: sniffed.encoding === "base64"
? { base64: normalizeBase64(payloadOf(text, sniffed)) }
: decodeValue(text, sniffed);
const savedTo = await platform.files.save(`${name}.${ext}`, content);
if (savedTo == null) {
const filepath = await platform.dialog.save({ defaultPath: `${name}.${ext}`, title: "Save Value" });
if (filepath == null) {
return; // Cancelled
}
showToast({ message: `Saved to ${savedTo}` });
const data =
sniffed == null
? toBase64(new TextEncoder().encode(text))
: sniffed.encoding === "base64"
? normalizeBase64(payloadOf(text, sniffed))
: toBase64(decodeValue(text, sniffed));
await rpc("cmd_save_base64_to_binary", { filepath, data });
showToast({ message: `Saved to ${filepath}` });
}
-44
View File
@@ -1,44 +0,0 @@
import type { BatchUpsertResult } from "@yaakapp-internal/models";
import { createGlobalModel } from "@yaakapp-internal/models";
import { router } from "./router";
import { rpc } from "./rpc";
import { setKeyValue } from "./keyValueStore";
export type OnboardingChoice =
| "import"
| "import_yaak"
| "example"
| "fresh"
| "open_folder"
| "clone_git";
const NEW_WORKSPACE_NAME = "My Workspace";
/**
* Remembered so later surfaces can lean toward what the user came for (an importer, the
* example, or a blank slate). Nothing reads it yet.
*/
export function recordOnboardingChoice(choice: OnboardingChoice) {
setKeyValue({
namespace: "global",
key: "onboarding",
value: { choice, at: new Date().toISOString() },
}).catch(console.error);
}
export async function createFreshWorkspace() {
const workspaceId = await createGlobalModel({ model: "workspace", name: NEW_WORKSPACE_NAME });
await router.navigate({ to: "/workspaces/$workspaceId", params: { workspaceId } });
}
export async function createExampleWorkspace() {
const created = await rpc<BatchUpsertResult>("cmd_create_example_workspace", {});
const workspace = created.workspaces[0];
if (workspace == null) throw new Error("Example workspace was not created");
const firstRequest = created.httpRequests.find((r) => r.name === "List posts");
await router.navigate({
to: "/workspaces/$workspaceId",
params: { workspaceId: workspace.id },
search: firstRequest ? { request_id: firstRequest.id } : {},
});
}
+6 -1
View File
@@ -25,8 +25,13 @@ export async function showPromptForm({
id,
title,
description,
hideX: true,
size: size ?? "sm",
disableClose: true,
disableBackdropClose: true, // Prevent accidental dismisses
onClose: () => {
// Click backdrop, close, or escape
resolve(null);
},
render: ({ hide }) =>
Prompt({
onCancel: () => {
+16 -14
View File
@@ -5,7 +5,6 @@ type ModelType = AnyModel["model"];
type WorkspaceRequestSettings = Pick<
Workspace,
| "settingFollowRedirects"
| "settingHttpVersion"
| "settingRequestMessageSize"
| "settingRequestTimeout"
| "settingSendCookies"
@@ -19,7 +18,9 @@ type ModelTypeWithSetting<K extends RequestSettingKey> = {
[M in ModelType]: K extends keyof ModelForType<M> ? M : never;
}[ModelType];
export type RequestSettingDefinition<K extends RequestSettingKey = RequestSettingKey> = {
export type RequestSettingDefinition<
K extends RequestSettingKey = RequestSettingKey,
> = {
defaultValue: WorkspaceRequestSettings[K];
description: string;
modelKey: K;
@@ -45,7 +46,8 @@ export const SETTING_REQUEST_TIMEOUT = defineRequestSetting({
export const SETTING_REQUEST_MESSAGE_SIZE = defineRequestSetting({
defaultValue: 64 * 1024 * 1024,
description: "Maximum gRPC or WebSocket message size in MB. Set to 0 to disable.",
description:
"Maximum gRPC or WebSocket message size in MB. Set to 0 to disable.",
modelKey: "settingRequestMessageSize",
models: ["workspace", "folder", "websocket_request", "grpc_request"],
title: "Message Size Limit",
@@ -55,7 +57,13 @@ export const SETTING_VALIDATE_CERTIFICATES = defineRequestSetting({
defaultValue: true,
description: "When disabled, skip validation of server certificates.",
modelKey: "settingValidateCertificates",
models: ["workspace", "folder", "http_request", "websocket_request", "grpc_request"],
models: [
"workspace",
"folder",
"http_request",
"websocket_request",
"grpc_request",
],
title: "Validate TLS certificates",
});
@@ -67,17 +75,10 @@ export const SETTING_FOLLOW_REDIRECTS = defineRequestSetting({
title: "Follow redirects",
});
export const SETTING_HTTP_VERSION = defineRequestSetting({
defaultValue: "auto",
description: "Force HTTP/1.1 or HTTP/2 for servers that don't negotiate the version correctly.",
modelKey: "settingHttpVersion",
models: ["workspace", "folder", "http_request"],
title: "HTTP version",
});
export const SETTING_SEND_COOKIES = defineRequestSetting({
defaultValue: true,
description: "Attach matching cookies from the active cookie jar to outgoing requests.",
description:
"Attach matching cookies from the active cookie jar to outgoing requests.",
modelKey: "settingSendCookies",
models: ["workspace", "folder", "http_request", "websocket_request"],
title: "Automatically send cookies",
@@ -85,7 +86,8 @@ export const SETTING_SEND_COOKIES = defineRequestSetting({
export const SETTING_STORE_COOKIES = defineRequestSetting({
defaultValue: true,
description: "Save cookies from Set-Cookie response headers to the active cookie jar.",
description:
"Save cookies from Set-Cookie response headers to the active cookie jar.",
modelKey: "settingStoreCookies",
models: ["workspace", "folder", "http_request", "websocket_request"],
title: "Automatically store cookies",
+27 -23
View File
@@ -9,7 +9,7 @@
"lint": "tsc --noEmit"
},
"dependencies": {
"@codemirror/commands": "^6.11.1",
"@codemirror/commands": "^6.8.1",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-markdown": "^6.3.2",
@@ -19,22 +19,23 @@
"@codemirror/merge": "^6.11.2",
"@codemirror/search": "^6.5.11",
"@dnd-kit/core": "^6.3.1",
"@gilbarbara/deep-equal": "^0.4.1",
"@gilbarbara/deep-equal": "^0.3.1",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.3",
"@mjackson/multipart-parser": "^0.10.1",
"@prantlf/jsonlint": "^16.0.0",
"@replit/codemirror-emacs": "^6.1.0",
"@replit/codemirror-vim": "^6.3.0",
"@replit/codemirror-vscode-keymap": "^6.0.2",
"@shopify/lang-jsonc": "^1.0.1",
"@tanstack/react-query": "^5.103.2",
"@tanstack/react-router": "^1.170.38",
"@tanstack/react-virtual": "^3.14.13",
"@tanstack/react-query": "^5.90.5",
"@tanstack/react-router": "^1.133.13",
"@tanstack/react-virtual": "^3.14.9",
"@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-fs": "^2.5.1",
"@tauri-apps/plugin-log": "^2.9.2",
"@tauri-apps/plugin-log": "^2.8.0",
"@tauri-apps/plugin-opener": "^2.5.4",
"@tauri-apps/plugin-os": "^2.3.2",
"@tauri-apps/plugin-shell": "^2.3.5",
@@ -45,26 +46,25 @@
"date-fns": "^4.1.0",
"deep-equal": "^2.2.3",
"eventemitter3": "^5.0.1",
"focus-trap-react": "^12.0.3",
"focus-trap-react": "^11.0.4",
"fuzzbunny": "^1.0.1",
"graphql": "^17.0.2",
"hexy": "^0.4.0",
"graphql": "^16.13.1",
"hexy": "^0.3.5",
"history": "^5.3.0",
"jotai": "^2.18.0",
"jotai-family": "^1.0.1",
"js-md5": "^0.9.2",
"jsonc-parser": "^3.3.1",
"lucide-react": "^1.47.0",
"js-md5": "^0.8.3",
"lucide-react": "^0.525.0",
"mime": "^4.0.4",
"motion": "^13.4.0",
"nanoid": "^6.0.1",
"motion": "^12.4.7",
"nanoid": "^5.1.16",
"papaparse": "^5.4.1",
"parse-color": "^1.0.0",
"react": "^19.2.0",
"react-colorful": "^5.6.1",
"react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"react-pdf": "^11.0.0",
"react-pdf": "^10.0.1",
"react-syntax-highlighter": "^16.1.0",
"react-use": "^17.6.1",
"rehype-stringify": "^10.0.1",
@@ -73,30 +73,34 @@
"slugify": "^1.6.6",
"uuid": "^14.0.0",
"vkbeautify": "^0.99.3",
"whatwg-mimetype": "^5.0.0",
"whatwg-mimetype": "^4.0.0",
"yaml": "^2.8.3"
},
"devDependencies": {
"@lezer/generator": "^1.8.0",
"@rolldown/plugin-babel": "^0.2.3",
"@tanstack/router-plugin": "^1.168.40",
"@tanstack/router-plugin": "^1.127.5",
"@types/babel__core": "^7.20.5",
"@types/node": "^22.20.4",
"@types/node": "^24.0.13",
"@types/papaparse": "^5.3.16",
"@types/parse-color": "^1.0.3",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/uuid": "^10.0.0",
"@types/whatwg-mimetype": "^5.0.0",
"@types/whatwg-mimetype": "^3.0.2",
"@vitejs/plugin-react": "^6.0.1",
"@yaakapp-internal/theme": "^1.0.0",
"@yaakapp-internal/ui": "^1.0.0",
"babel-plugin-react-compiler": "^1.0.0",
"rollup": "^4.63.4",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.3.3",
"vite-plugin-static-copy": "^4.1.1",
"decompress": "^4.2.1",
"internal-ip": "^8.0.0",
"rollup": "^4.60.3",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1",
"vite-plugin-static-copy": "^3.3.0",
"vite-plugin-svgr": "^4.5.0",
"vite-plugin-top-level-await": "^1.5.0",
"vite-plugin-wasm": "^3.5.0",
"vite-plus": "^0.3.3"
"vite-plus": "^0.2.1"
}
}
-8
View File
@@ -1,8 +0,0 @@
import "react";
declare module "react" {
interface HTMLAttributes<T> {
// Not in @types/react yet; opts editable elements out of macOS Writing Tools
writingsuggestions?: "true" | "false";
}
}
+6 -6
View File
@@ -46,9 +46,9 @@ const WorkspacesWorkspaceIdRequestsRequestIdRoute =
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/workspaces/': typeof WorkspacesIndexRoute
'/workspaces': typeof WorkspacesIndexRoute
'/workspaces/$workspaceId/settings': typeof WorkspacesWorkspaceIdSettingsRoute
'/workspaces/$workspaceId/': typeof WorkspacesWorkspaceIdIndexRoute
'/workspaces/$workspaceId': typeof WorkspacesWorkspaceIdIndexRoute
'/workspaces/$workspaceId/requests/$requestId': typeof WorkspacesWorkspaceIdRequestsRequestIdRoute
}
export interface FileRoutesByTo {
@@ -70,9 +70,9 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/workspaces/'
| '/workspaces'
| '/workspaces/$workspaceId/settings'
| '/workspaces/$workspaceId/'
| '/workspaces/$workspaceId'
| '/workspaces/$workspaceId/requests/$requestId'
fileRoutesByTo: FileRoutesByTo
to:
@@ -110,14 +110,14 @@ declare module '@tanstack/react-router' {
'/workspaces/': {
id: '/workspaces/'
path: '/workspaces'
fullPath: '/workspaces/'
fullPath: '/workspaces'
preLoaderRoute: typeof WorkspacesIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/workspaces/$workspaceId/': {
id: '/workspaces/$workspaceId/'
path: '/workspaces/$workspaceId'
fullPath: '/workspaces/$workspaceId/'
fullPath: '/workspaces/$workspaceId'
preLoaderRoute: typeof WorkspacesWorkspaceIdIndexRouteImport
parentRoute: typeof rootRouteImport
}
+1 -10
View File
@@ -1,19 +1,10 @@
import { createFileRoute } from "@tanstack/react-router";
import { Onboarding } from "../components/Onboarding";
import { RedirectToLatestWorkspace } from "../components/RedirectToLatestWorkspace";
type IndexSearchSchema = {
/** Show the home screen even when workspaces exist. Set by the dev menu. */
home?: true;
};
export const Route = createFileRoute("/")({
component: RouteComponent,
validateSearch: (search: Record<string, unknown>): IndexSearchSchema =>
search.home === true ? { home: true } : {},
});
function RouteComponent() {
const { home } = Route.useSearch();
return home ? <Onboarding /> : <RedirectToLatestWorkspace />;
return <RedirectToLatestWorkspace />;
}
@@ -14,6 +14,5 @@ export const Route = createFileRoute("/workspaces/$workspaceId/settings")({
});
function RouteComponent() {
const { tab } = Route.useSearch();
return <Settings tab={tab} />;
return <Settings />;
}
Loaded 100 of 501 files, more files were not shown because too many files have changed in this diff. Show more