mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
Compare commits
77
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5520fb3a8 | ||
|
|
c71256d800 | ||
|
|
6e7f23a778 | ||
|
|
b03c41b767 | ||
|
|
a0d71eb486 | ||
|
|
38c562f775 | ||
|
|
1249d15f2c | ||
|
|
1707c387ea | ||
|
|
a497d9f018 | ||
|
|
c13e26656b | ||
|
|
dfc974db95 | ||
|
|
0b86d3ca3f | ||
|
|
9e51e95f9e | ||
|
|
aa10c44a45 | ||
|
|
46464cfacf | ||
|
|
263a215f7a | ||
|
|
646d969649 | ||
|
|
4913711968 | ||
|
|
35d4c9d79d | ||
|
|
bbe8d1eef5 | ||
|
|
1e3dfff01e | ||
|
|
fccff5b7db | ||
|
|
6ed25b9f42 | ||
|
|
4a4d3b4301 | ||
|
|
25a464546a | ||
|
|
2c51fc92d6 | ||
|
|
f827aae46d | ||
|
|
caeebebc75 | ||
|
|
621f9c3b8f | ||
|
|
77b18d3477 | ||
|
|
a8c845b091 | ||
|
|
bcbf6d32a7 | ||
|
|
e30b289ed7 | ||
|
|
85904566e7 | ||
|
|
0b6803c6c0 | ||
|
|
edeb433c79 | ||
|
|
12e6e8a977 | ||
|
|
ab616fd915 | ||
|
|
4859aba142 | ||
|
|
cef569ee15 | ||
|
|
ae518dfb4c | ||
|
|
c9990d8492 | ||
|
|
309818200a | ||
|
|
06142d007a | ||
|
|
8a6e4810fd | ||
|
|
5a793e1d42 | ||
|
|
3d6c31440c | ||
|
|
31d55440c1 | ||
|
|
a822cc93d4 | ||
|
|
48889b6550 | ||
|
|
881cff472d | ||
|
|
d465c5679a | ||
|
|
0a68dea133 | ||
|
|
17a44f34a3 | ||
|
|
8c8be31aa2 | ||
|
|
ff5db718ce | ||
|
|
fd95b4cba3 | ||
|
|
7c660f4528 | ||
|
|
0337ef35c9 | ||
|
|
602bd1bb45 | ||
|
|
57ab349901 | ||
|
|
7a6d275fdb | ||
|
|
7d0926c961 | ||
|
|
522b898620 | ||
|
|
cb7ed9264d | ||
|
|
90cb578e26 | ||
|
|
ff7eebf3cd | ||
|
|
eff0c06cb0 | ||
|
|
c6e09a5af3 | ||
|
|
b05071ef4b | ||
|
|
a44f31eeb2 | ||
|
|
1103d05915 | ||
|
|
2f5639a26f | ||
|
|
7f8b7bf567 | ||
|
|
096e83a7bd | ||
|
|
89ce84cefe | ||
|
|
d5d2a64c32 |
No files matched your search
@@ -0,0 +1,55 @@
|
||||
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
|
||||
@@ -0,0 +1,79 @@
|
||||
// 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}`);
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
runtime: "wry"
|
||||
targets: "aarch64-pc-windows-msvc"
|
||||
runs-on: ${{ matrix.platform }}
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 360
|
||||
steps:
|
||||
- name: Checkout yaakapp/app
|
||||
uses: actions/checkout@v4
|
||||
@@ -172,7 +172,8 @@ 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
|
||||
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
- name: Build and upload Tauri bundles
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
|
||||
|
||||
@@ -194,6 +195,8 @@ 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__"
|
||||
@@ -203,6 +206,17 @@ 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:
|
||||
@@ -217,7 +231,13 @@ jobs:
|
||||
mkdir -p "$extract_dir"
|
||||
dpkg-deb -x "$deb" "$extract_dir"
|
||||
tar -C "$extract_dir" -czf "$tarball" .
|
||||
gh release upload "${{ github.ref_name }}" "$tarball" --clobber
|
||||
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
|
||||
|
||||
# Build a per-machine NSIS installer for enterprise deployment (PDQ, SCCM, Intune)
|
||||
- name: Build and upload machine-wide installer (Windows only)
|
||||
@@ -235,6 +255,7 @@ 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"
|
||||
@@ -242,5 +263,30 @@ jobs:
|
||||
$destSig = "$dest.sig"
|
||||
Copy-Item $setup.FullName $dest
|
||||
Copy-Item $setupSig $destSig
|
||||
gh release upload "${{ github.ref_name }}" "$dest" --clobber
|
||||
gh release upload "${{ github.ref_name }}" "$destSig" --clobber
|
||||
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
|
||||
+2
-1
@@ -3,6 +3,7 @@
|
||||
"ignorePatterns": [
|
||||
"**/bindings/**",
|
||||
"crates/yaak-templates/pkg/**",
|
||||
"apps/yaak-client/routeTree.gen.ts"
|
||||
"apps/yaak-client/routeTree.gen.ts",
|
||||
"plugins/importer-bruno/tests/fixtures/upstream/**"
|
||||
]
|
||||
}
|
||||
Generated
+1100
-1031
File diff suppressed because it is too large.
Load diff
+13
-1
@@ -2,6 +2,8 @@
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/yaak",
|
||||
"crates/yaak-assertions",
|
||||
"crates/yaak-jsonpath",
|
||||
"crates/yaak-commands",
|
||||
# Common/foundation crates
|
||||
"crates/common/yaak-database",
|
||||
@@ -29,6 +31,7 @@ members = [
|
||||
"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
|
||||
@@ -47,7 +50,14 @@ chrono = "0.4.42"
|
||||
hex = "0.4.3"
|
||||
keyring = "3.6.3"
|
||||
log = "0.4.29"
|
||||
reqwest = "0.12.20"
|
||||
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",
|
||||
] }
|
||||
rustls = { version = "0.23.34", default-features = false }
|
||||
rustls-platform-verifier = "0.6.2"
|
||||
schemars = { version = "0.8.22", features = ["chrono"] }
|
||||
@@ -74,6 +84,8 @@ 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" }
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# 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"]
|
||||
+4
-1
@@ -38,7 +38,10 @@ 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
|
||||
ENV YAAK_WEB_BIND=0.0.0.0:8080
|
||||
# 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:
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<br>
|
||||
|
||||
<p align="center">
|
||||
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https://github.com/MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a> <a href="https://github.com/dharsanb"><img src="https://github.com/dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a> <a href="https://github.com/railwayapp"><img src="https://github.com/railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a> <a href="https://github.com/caseyamcl"><img src="https://github.com/caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a> <a href="https://github.com/bytebase"><img src="https://github.com/bytebase.png" width="80px" alt="User avatar: bytebase" /></a> <a href="https://github.com/"><img src="https://raw.githubusercontent.com/JamesIves/github-sponsors-readme-action/dev/.github/assets/placeholder.png" width="80px" alt="User avatar: " /></a> <!-- sponsors-premium -->
|
||||
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https://github.com/MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a> <a href="https://github.com/dharsanb"><img src="https://github.com/dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a> <a href="https://github.com/railwayapp"><img src="https://github.com/railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a> <a href="https://github.com/caseyamcl"><img src="https://github.com/caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a> <a href="https://github.com/bytebase"><img src="https://github.com/bytebase.png" width="80px" alt="User avatar: bytebase" /></a> <a href="https://github.com/rauchg"><img src="https://github.com/rauchg.png" width="80px" alt="User avatar: rauchg" /></a> <a href="https://github.com/"><img src="https://raw.githubusercontent.com/JamesIves/github-sponsors-readme-action/dev/.github/assets/placeholder.png" width="80px" alt="User avatar: " /></a> <!-- sponsors-premium -->
|
||||
</p>
|
||||
<p align="center">
|
||||
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https://github.com/seanwash.png" width="50px" alt="User avatar: seanwash" /></a> <a href="https://github.com/jerath"><img src="https://github.com/jerath.png" width="50px" alt="User avatar: jerath" /></a> <a href="https://github.com/itsa-sh"><img src="https://github.com/itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a> <a href="https://github.com/dmmulroy"><img src="https://github.com/dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a> <a href="https://github.com/timcole"><img src="https://github.com/timcole.png" width="50px" alt="User avatar: timcole" /></a> <a href="https://github.com/VLZH"><img src="https://github.com/VLZH.png" width="50px" alt="User avatar: VLZH" /></a> <a href="https://github.com/terasaka2k"><img src="https://github.com/terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a> <a href="https://github.com/andriyor"><img src="https://github.com/andriyor.png" width="50px" alt="User avatar: andriyor" /></a> <a href="https://github.com/majudhu"><img src="https://github.com/majudhu.png" width="50px" alt="User avatar: majudhu" /></a> <a href="https://github.com/axelrindle"><img src="https://github.com/axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a> <a href="https://github.com/jirizverina"><img src="https://github.com/jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a> <a href="https://github.com/chip-well"><img src="https://github.com/chip-well.png" width="50px" alt="User avatar: chip-well" /></a> <a href="https://github.com/GRAYAH"><img src="https://github.com/GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a> <a href="https://github.com/flashblaze"><img src="https://github.com/flashblaze.png" width="50px" alt="User avatar: flashblaze" /></a> <a href="https://github.com/Frostist"><img src="https://github.com/Frostist.png" width="50px" alt="User avatar: Frostist" /></a> <a href="https://github.com/PurplProto"><img src="https://github.com/PurplProto.png" width="50px" alt="User avatar: PurplProto" /></a> <!-- sponsors-base -->
|
||||
@@ -25,6 +25,10 @@
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
@@ -68,3 +72,9 @@ 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.
|
||||
@@ -1,12 +1,12 @@
|
||||
import { gitClone } from "@yaakapp-internal/git";
|
||||
import { Banner, VStack } from "@yaakapp-internal/ui";
|
||||
import { useState } from "react";
|
||||
import { useId, 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,6 +29,7 @@ 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);
|
||||
@@ -83,7 +84,7 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack as="form" space={3} alignItems="start" className="pb-3" onSubmit={handleClone}>
|
||||
<VStack as="form" id={formId} space={3} alignItems="start" onSubmit={handleClone}>
|
||||
{error && (
|
||||
<Banner color="danger" className="w-full">
|
||||
{error}
|
||||
@@ -132,15 +133,17 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
color="primary"
|
||||
className="w-full mt-3"
|
||||
disabled={!url || !directory || isCloning}
|
||||
isLoading={isCloning}
|
||||
>
|
||||
{isCloning ? "Cloning..." : "Clone Repository"}
|
||||
</Button>
|
||||
<DialogFooter
|
||||
actions={[
|
||||
{
|
||||
label: isCloning ? "Cloning..." : "Clone Repository",
|
||||
color: "primary",
|
||||
form: formId,
|
||||
disabled: !url || !directory || isCloning,
|
||||
isLoading: isCloning,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createWorkspaceModel } from "@yaakapp-internal/models";
|
||||
import { useState } from "react";
|
||||
import { useId, 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,9 +18,11 @@ 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
|
||||
className="pb-3 flex flex-col gap-3"
|
||||
id={formId}
|
||||
className="flex flex-col gap-3"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
const id = await createWorkspaceModel({
|
||||
@@ -59,10 +61,17 @@ export function CreateEnvironmentDialog({ workspaceId, hide, onCreate }: Props)
|
||||
</Label>
|
||||
<ColorPickerWithThemeColors onChange={setColor} color={color} />
|
||||
</div>
|
||||
<Button type="submit" color="secondary" className="mt-3">
|
||||
{color != null && <ColorIndicator color={color} />}
|
||||
Create Environment
|
||||
</Button>
|
||||
<DialogFooter
|
||||
inline
|
||||
actions={[
|
||||
{
|
||||
label: "Create Environment",
|
||||
color: "secondary",
|
||||
form: formId,
|
||||
leftSlot: color != null ? <ColorIndicator color={color} /> : undefined,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</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 { useState } from "react";
|
||||
import { useId, 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,12 +26,13 @@ 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 });
|
||||
@@ -89,9 +90,7 @@ export function CreateWorkspaceDialog({ hide }: Props) {
|
||||
title="Enable Encryption"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" color="primary" className="w-full mt-3">
|
||||
Create Workspace
|
||||
</Button>
|
||||
<DialogFooter actions={[{ label: "Create Workspace", color: "primary", form: formId }]} />
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Workspace } from "@yaakapp-internal/models";
|
||||
import { workspacesAtom } from "@yaakapp-internal/models";
|
||||
import { HStack, VStack } from "@yaakapp-internal/ui";
|
||||
import { 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,29 +65,30 @@ 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 exportPath = await platform.dialog.save({
|
||||
title: "Export Data",
|
||||
defaultPath: `yaak.${slug}.json`,
|
||||
});
|
||||
if (exportPath == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await rpc("cmd_export_data", {
|
||||
const document = await rpc<string>("cmd_export_data", {
|
||||
workspaceIds: ids,
|
||||
exportPath,
|
||||
includePrivateEnvironments: includePrivateEnvironments,
|
||||
});
|
||||
|
||||
const savedTo = await platform.files.save(
|
||||
`yaak.${slug}.json`,
|
||||
new TextEncoder().encode(document),
|
||||
[{ name: "JSON", extensions: ["json"] }],
|
||||
);
|
||||
if (savedTo == null) {
|
||||
return; // Cancelled
|
||||
}
|
||||
|
||||
onHide();
|
||||
onSuccess(exportPath);
|
||||
onSuccess(savedTo);
|
||||
}, [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 (
|
||||
<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">
|
||||
<>
|
||||
<VStack space={3} className="pb-4">
|
||||
<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">
|
||||
@@ -140,29 +141,22 @@ function ExportDataDialogContent({
|
||||
/>
|
||||
</DetailsBanner>
|
||||
</VStack>
|
||||
<footer className="px-5 grid grid-cols-[1fr_auto] items-center bg-surface py-3 border-t border-border-subtle">
|
||||
<div>
|
||||
<DialogFooter
|
||||
leftSlot={
|
||||
<Link href="https://yaak.app/button/new" noUnderline className="text-text-subtlest">
|
||||
Create Run Button
|
||||
</Link>
|
||||
</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>
|
||||
}
|
||||
actions={[
|
||||
{ label: "Cancel", onClick: onHide },
|
||||
{
|
||||
label: `Export ${pluralizeCount("Workspace", numSelected, { omitSingle: true, noneWord: "Nothing" })}`,
|
||||
color: "primary",
|
||||
disabled: noneSelected,
|
||||
onClick: () => handleExport(),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { NavigateWorkspace } from "@yaakapp-internal/tauri-client";
|
||||
import { activeRequestAtom } from "../hooks/useActiveRequest";
|
||||
import { useSubscribeActiveWorkspaceId } from "../hooks/useActiveWorkspace";
|
||||
import { useActiveWorkspaceChangedToast } from "../hooks/useActiveWorkspaceChangedToast";
|
||||
@@ -5,10 +6,13 @@ 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();
|
||||
@@ -24,6 +28,20 @@ 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 () => {
|
||||
|
||||
@@ -239,6 +239,7 @@ export function GrpcEditor({
|
||||
showDialog({
|
||||
title: "Configure Schema",
|
||||
size: "md",
|
||||
className: "h-[30rem]",
|
||||
id: "grpc-configure-schema",
|
||||
render: ({ hide }) => <GrpcProtoSelectionDialog onDone={hide} />,
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { GrpcRequest } from "@yaakapp-internal/models";
|
||||
import { Banner, HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import { Banner, 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 { Button } from "./core/Button";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { DialogFooter } from "./core/Dialog";
|
||||
import { PathList } from "./core/PathList";
|
||||
import { Link } from "./core/Link";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
|
||||
@@ -20,7 +20,10 @@ export function GrpcProtoSelectionDialog(props: Props) {
|
||||
return <GrpcProtoSelectionDialogWithRequest request={request} {...props} />;
|
||||
}
|
||||
|
||||
function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: GrpcRequest }) {
|
||||
function GrpcProtoSelectionDialogWithRequest({
|
||||
request,
|
||||
onDone,
|
||||
}: Props & { request: GrpcRequest }) {
|
||||
const protoFilesKv = useGrpcProtoFiles(request.id);
|
||||
const protoFiles = protoFilesKv.value ?? [];
|
||||
const grpc = useGrpc(request, null, protoFiles);
|
||||
@@ -38,54 +41,8 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
|
||||
}
|
||||
|
||||
return (
|
||||
<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}>
|
||||
<>
|
||||
<VStack space={5} className="pb-4">
|
||||
{reflectError && (
|
||||
<Banner color="warning">
|
||||
<h1 className="font-bold">
|
||||
@@ -128,46 +85,47 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
<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();
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{reflectionUnimplemented && protoFiles.length === 0 && (
|
||||
<Banner>
|
||||
<InlineCode>{request.url}</InlineCode> doesn't implement{" "}
|
||||
@@ -178,6 +136,17 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
|
||||
</Banner>
|
||||
)}
|
||||
</VStack>
|
||||
</VStack>
|
||||
<DialogFooter
|
||||
actions={[
|
||||
{
|
||||
label: "Refresh Schema",
|
||||
isLoading: grpc.reflect.isFetching,
|
||||
disabled: grpc.reflect.isFetching,
|
||||
onClick: () => grpc.reflect.refetch(),
|
||||
},
|
||||
{ label: "Done", color: "primary", onClick: onDone },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -133,6 +133,8 @@ 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" },
|
||||
@@ -146,10 +148,10 @@ export function GrpcRequestPane({
|
||||
{
|
||||
value: TAB_DESCRIPTION,
|
||||
label: "Info",
|
||||
rightSlot: activeRequest.description && <CountBadge count={true} />,
|
||||
rightSlot: hasDescription && <CountBadge count={true} />,
|
||||
},
|
||||
],
|
||||
[activeRequest.description, authTab, metadataTab, numSettingsOverrides],
|
||||
[authTab, hasDescription, metadataTab, numSettingsOverrides],
|
||||
);
|
||||
|
||||
const handleMetadataChange = useCallback(
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
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,11 +42,12 @@ 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 { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs";
|
||||
import { 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";
|
||||
@@ -72,6 +73,7 @@ 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
|
||||
@@ -165,10 +167,13 @@ 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,
|
||||
@@ -260,6 +265,17 @@ 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,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -267,6 +283,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
authTab,
|
||||
contentType,
|
||||
handleContentTypeChange,
|
||||
hasDescription,
|
||||
headersTab,
|
||||
numParams,
|
||||
numSettingsOverrides,
|
||||
@@ -306,11 +323,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
e.preventDefault(); // Prevent input onChange
|
||||
|
||||
await patchModel(activeRequest, patch);
|
||||
await setActiveTab({
|
||||
storageKey: TABS_STORAGE_KEY,
|
||||
activeTabKey: activeRequestId,
|
||||
value: TAB_PARAMS,
|
||||
});
|
||||
tabsRef.current?.setActiveTab(TAB_PARAMS);
|
||||
|
||||
// Wait for request to update, then refresh the UI
|
||||
// TODO: Somehow make this deterministic
|
||||
@@ -364,8 +377,12 @@ 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 } from "react";
|
||||
import { lazy, Suspense, useMemo, useRef } from "react";
|
||||
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
|
||||
import { useCopyHttpResponse } from "../hooks/useCopyHttpResponse";
|
||||
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
|
||||
@@ -23,11 +23,12 @@ import { HttpResponseDurationTag } from "./core/HttpResponseDurationTag";
|
||||
import { HttpStatusTag } from "./core/HttpStatusTag";
|
||||
import { PillButton } from "./core/PillButton";
|
||||
import { SizeTag } from "./core/SizeTag";
|
||||
import type { TabItem } from "./core/Tabs/Tabs";
|
||||
import type { TabItem, TabsRef } 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";
|
||||
@@ -56,6 +57,7 @@ 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";
|
||||
@@ -67,6 +69,7 @@ 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);
|
||||
@@ -83,9 +86,53 @@ 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",
|
||||
@@ -162,6 +209,10 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||
activeResponse?.requestHeaders.length,
|
||||
activeResponse?.state,
|
||||
activeResponse?.status,
|
||||
hasAssertionResults,
|
||||
assertionFailures,
|
||||
assertionPasses,
|
||||
assertionsHaveErrors,
|
||||
cookieCounts.sent,
|
||||
cookieCounts.received,
|
||||
copyResponse.mutate,
|
||||
@@ -210,6 +261,17 @@ 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>•</span>
|
||||
<HttpResponseDurationTag response={activeResponse} />
|
||||
<span>•</span>
|
||||
@@ -282,13 +344,18 @@ 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>
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,271 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
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,6 +7,7 @@ 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);
|
||||
@@ -14,10 +15,6 @@ export function RedirectToLatestWorkspace() {
|
||||
|
||||
useEffect(() => {
|
||||
if (workspaces.length === 0 || recentWorkspaces == null) {
|
||||
console.log("No workspaces found to redirect to. Skipping.", {
|
||||
workspaces,
|
||||
recentWorkspaces,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -34,11 +31,21 @@ 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;
|
||||
}
|
||||
@@ -941,10 +941,14 @@ const sidebarTreeAtom = atom<
|
||||
|
||||
if (node.children != null) {
|
||||
childItems.sort((a, b) => {
|
||||
if (a.sortPriority === b.sortPriority) {
|
||||
return a.updatedAt > b.updatedAt ? 1 : -1;
|
||||
if (a.sortPriority !== b.sortPriority) {
|
||||
return a.sortPriority - b.sortPriority;
|
||||
}
|
||||
return a.sortPriority - b.sortPriority;
|
||||
// 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;
|
||||
});
|
||||
|
||||
for (const item of childItems) {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { Workspace } from "@yaakapp-internal/models";
|
||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
||||
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import { 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,45 +16,33 @@ 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 && (
|
||||
<HStack justifyContent="end">
|
||||
<Checkbox checked={remember} title="Remember my choice" onChange={setRemember} />
|
||||
</HStack>
|
||||
<Checkbox checked={remember} title="Remember my choice" onChange={setRemember} />
|
||||
)}
|
||||
<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, useMemo, useState } from "react";
|
||||
import { useEffect, useId, 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 { Button } from "./core/Button";
|
||||
import { DialogFooter } from "./core/Dialog";
|
||||
import { collectArgumentValues } from "./core/Editor/twig/util";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
@@ -91,6 +91,7 @@ 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) => ({
|
||||
@@ -160,13 +161,14 @@ function InitializedTemplateFunctionDialog({
|
||||
|
||||
return (
|
||||
<form
|
||||
className="grid grid-rows-[minmax(0,1fr)_auto_auto] h-full max-h-[90vh]"
|
||||
id={formId}
|
||||
className="grid grid-rows-[minmax(0,1fr)_auto] h-full"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleDone();
|
||||
}}
|
||||
>
|
||||
<div className="overflow-y-auto h-full px-6">
|
||||
<div className="overflow-y-auto h-full">
|
||||
{name === "secure" ? (
|
||||
<PlainInput
|
||||
required
|
||||
@@ -188,75 +190,71 @@ function InitializedTemplateFunctionDialog({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
{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 || <> </>
|
||||
)}
|
||||
</InlineCode>
|
||||
<div className="absolute right-0.5 top-0 bottom-0 flex items-center">
|
||||
<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",
|
||||
)}
|
||||
icon="refresh"
|
||||
className="text-text-subtle"
|
||||
title="Refresh preview"
|
||||
spin={rendered.isPending}
|
||||
onClick={() => {
|
||||
setRenderKey(new Date().toISOString());
|
||||
}}
|
||||
/>
|
||||
</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 || <> </>
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<DialogFooter
|
||||
actions={[
|
||||
...(templateFunction.data.name === "secure"
|
||||
? [{ label: "Reveal Encryption Key", onClick: setupOrConfigureEncryption }]
|
||||
: []),
|
||||
{ label: "Save", color: "primary", form: formId },
|
||||
]}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -272,7 +270,6 @@ 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,6 +110,8 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
||||
[activeRequest.url, activeRequest.urlParameters, handleRenamePathPlaceholder],
|
||||
);
|
||||
|
||||
const hasDescription = activeRequest.description.trim().length > 0;
|
||||
|
||||
const tabs = useMemo<TabItem[]>(() => {
|
||||
return [
|
||||
{
|
||||
@@ -131,9 +133,10 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
||||
{
|
||||
value: TAB_DESCRIPTION,
|
||||
label: "Info",
|
||||
rightSlot: hasDescription && <CountBadge count={true} />,
|
||||
},
|
||||
];
|
||||
}, [authTab, headersTab, numSettingsOverrides, urlParameterPairs.length]);
|
||||
}, [authTab, hasDescription, headersTab, numSettingsOverrides, urlParameterPairs.length]);
|
||||
|
||||
const { activeResponse } = usePinnedHttpResponse(activeRequestId);
|
||||
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
|
||||
|
||||
@@ -40,6 +40,7 @@ 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";
|
||||
@@ -66,9 +67,8 @@ export function Workspace() {
|
||||
return { background };
|
||||
}, [activeEnvironment?.color]);
|
||||
|
||||
// We're loading still
|
||||
if (workspaces.length === 0) {
|
||||
return null;
|
||||
return <RedirectToLatestWorkspace />;
|
||||
}
|
||||
|
||||
const header = (
|
||||
|
||||
@@ -21,6 +21,8 @@ interface Props<T> {
|
||||
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;
|
||||
@@ -29,7 +31,9 @@ interface Props<T> {
|
||||
|
||||
export function CheckboxTree<T>(props: Props<T>) {
|
||||
const { node, depth = 0 } = props;
|
||||
const [collapsed, setCollapsed] = useState<boolean>(false);
|
||||
const [collapsed, setCollapsed] = useState<boolean>(
|
||||
() => props.isCollapsedByDefault?.(node) ?? false,
|
||||
);
|
||||
if (!hasRelevantNode(node, props.isRelevant)) return null;
|
||||
|
||||
const checked = props.checked(node);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { Color } from "@yaakapp-internal/plugins";
|
||||
import { HStack } from "@yaakapp-internal/ui";
|
||||
import type { FormEvent } from "react";
|
||||
import { useState } from "react";
|
||||
import { useId, useState } from "react";
|
||||
import { CopyIconButton } from "../CopyIconButton";
|
||||
import { Button } from "./Button";
|
||||
import { DialogFooter } from "./Dialog";
|
||||
import { PlainInput } from "./PlainInput";
|
||||
|
||||
export interface ConfirmProps {
|
||||
@@ -22,6 +21,7 @@ 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 className="flex flex-col" onSubmit={handleSuccess}>
|
||||
<form id={formId} className="flex flex-col" onSubmit={handleSuccess}>
|
||||
{requireTyping && (
|
||||
<PlainInput
|
||||
autoFocus
|
||||
@@ -61,14 +61,19 @@ export function Confirm({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<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>
|
||||
<DialogFooter
|
||||
inline
|
||||
actions={[
|
||||
{ label: "Cancel", onClick: handleHide },
|
||||
{
|
||||
label: confirmText ?? "Confirm",
|
||||
color,
|
||||
form: formId,
|
||||
disabled: !didConfirm,
|
||||
autoFocus: !requireTyping,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { DialogSize } from "@yaakapp-internal/plugins";
|
||||
import { Heading, Overlay } from "@yaakapp-internal/ui";
|
||||
import { Heading, HStack, Overlay } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import * as m from "motion/react-m";
|
||||
import type { ReactNode } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { createContext, useContext, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Button, type ButtonProps } from "./Button";
|
||||
import { IconButton } from "./IconButton";
|
||||
|
||||
export interface DialogProps {
|
||||
@@ -38,6 +40,7 @@ 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],
|
||||
@@ -71,7 +74,7 @@ export function Dialog({
|
||||
animate={{ top: 0, scale: 1 }}
|
||||
className={classNames(
|
||||
className,
|
||||
"grid grid-rows-[auto_auto_minmax(0,1fr)]",
|
||||
"grid grid-rows-[auto_auto_minmax(0,1fr)_auto]",
|
||||
"grid-cols-1", // must be here for inline code blocks to correctly break words
|
||||
"relative bg-surface pointer-events-auto",
|
||||
"rounded-lg",
|
||||
@@ -108,9 +111,11 @@ export function Dialog({
|
||||
!noScroll && "overflow-y-auto overflow-x-hidden",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<DialogFooterSlot.Provider value={footerEl}>{children}</DialogFooterSlot.Provider>
|
||||
</div>
|
||||
|
||||
<div ref={setFooterEl} className="contents" />
|
||||
|
||||
{/*Put close at the end so that it's the last thing to be tabbed to*/}
|
||||
{!disableClose && !hideX && (
|
||||
<div className="ml-auto absolute right-1 top-1">
|
||||
@@ -129,3 +134,67 @@ 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);
|
||||
}
|
||||
@@ -32,6 +32,8 @@ interface JsonLintOptions {
|
||||
export function jsonParseLinter(options?: JsonLintOptions) {
|
||||
return (view: EditorView): Diagnostic[] => {
|
||||
const doc = view.state.doc.toString();
|
||||
if (doc.trim() === "") 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));
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
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);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
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("");
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
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;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/* 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,5 +1,6 @@
|
||||
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 = "${[ ";
|
||||
@@ -36,6 +37,22 @@ 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_.]*/);
|
||||
@@ -44,7 +61,8 @@ export function twigCompletion({ options }: TwigCompletionConfig) {
|
||||
if (toMatch === null) return null;
|
||||
|
||||
const matchLen = toMatch.to - toMatch.from;
|
||||
if (!context.explicit && toMatch.from > 0 && matchLen < MIN_MATCH_NAME) {
|
||||
const hasOpenTag = findOpenTag(context.state, toMatch.from) !== null;
|
||||
if (!context.explicit && !hasOpenTag && toMatch.from > 0 && matchLen < MIN_MATCH_NAME) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -83,6 +101,16 @@ 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 },
|
||||
@@ -103,7 +131,8 @@ export function twigCompletion({ options }: TwigCompletionConfig) {
|
||||
|
||||
return {
|
||||
matchLen,
|
||||
validFor: () => true, // Not really sure why this is all it needs
|
||||
// Delimiters change the replacement range; dots change namespace options.
|
||||
validFor: /^\w*$/,
|
||||
from: toMatch.from,
|
||||
options: sortedCompletions,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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";
|
||||
@@ -33,7 +34,10 @@ export function Link({ href, children, noUnderline, className, ...other }: Props
|
||||
href={finalHref}
|
||||
target="_blank"
|
||||
rel={isYaakLink ? undefined : "noopener noreferrer"}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void platform.openUrl(finalHref);
|
||||
}}
|
||||
className={className}
|
||||
{...other}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
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,6 +205,7 @@ 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"))}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
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 { Button } from "./Button";
|
||||
import { DialogFooter } from "./Dialog";
|
||||
|
||||
export interface PromptProps {
|
||||
inputs: FormInput[];
|
||||
@@ -48,19 +47,15 @@ export function Prompt({
|
||||
const id = `prompt.form.${useRef(generateId()).current}`;
|
||||
|
||||
return (
|
||||
<form
|
||||
className="grid grid-rows-[auto_auto] grid-cols-[minmax(0,1fr)] gap-4 mb-4"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<form id={id} className="grid grid-cols-[minmax(0,1fr)] mb-2" onSubmit={handleSubmit}>
|
||||
<DynamicForm inputs={inputs} onChange={setValue} data={value} stateKey={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>
|
||||
<DialogFooter
|
||||
inline
|
||||
actions={[
|
||||
{ label: cancelText || "Cancel", onClick: onCancel },
|
||||
{ label: confirmText || "Done", color: "primary", form: id },
|
||||
]}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ 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> {
|
||||
@@ -36,11 +37,15 @@ 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={help}
|
||||
help={hideLabel ? undefined : help}
|
||||
visuallyHidden={hideLabel}
|
||||
className={classNames(labelClassName)}
|
||||
>
|
||||
@@ -78,9 +83,10 @@ export function SegmentedControl<T extends string>({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{options.map((o) => {
|
||||
{options.map((o, i) => {
|
||||
const isSelected = selectedValue === o.value;
|
||||
const isActive = value === o.value;
|
||||
const rightSlot = i === options.length - 1 ? inlineHelp : null;
|
||||
if (o.icon == null) {
|
||||
return (
|
||||
<Button
|
||||
@@ -95,6 +101,7 @@ export function SegmentedControl<T extends string>({
|
||||
isActive && "text-text!",
|
||||
"focus:ring-1 focus:ring-border-focus",
|
||||
)}
|
||||
rightSlot={rightSlot}
|
||||
onClick={() => onChange(o.value)}
|
||||
>
|
||||
{o.label}
|
||||
@@ -117,6 +124,7 @@ export function SegmentedControl<T extends string>({
|
||||
)}
|
||||
title={o.label}
|
||||
icon={o.icon}
|
||||
rightSlot={rightSlot}
|
||||
onClick={() => onChange(o.value)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import classNames from "classnames";
|
||||
import type { ReactNode, Ref } from "react";
|
||||
import type { CSSProperties, ReactNode, Ref } from "react";
|
||||
import {
|
||||
forwardRef,
|
||||
memo,
|
||||
@@ -27,11 +27,19 @@ 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 =
|
||||
export type TabItem = {
|
||||
/** Stable label for the visibility menu, including tabs with dropdown labels. */
|
||||
menuLabel?: string;
|
||||
hiddenByDefault?: boolean;
|
||||
} & (
|
||||
| {
|
||||
value: string;
|
||||
label: string;
|
||||
@@ -44,7 +52,8 @@ export type TabItem =
|
||||
options: Omit<RadioDropdownProps, "children">;
|
||||
leftSlot?: ReactNode;
|
||||
rightSlot?: ReactNode;
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
interface TabsStorage {
|
||||
order: string[];
|
||||
@@ -72,6 +81,8 @@ 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(
|
||||
@@ -87,6 +98,7 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
|
||||
layout = "vertical",
|
||||
storageKey,
|
||||
activeTabKey,
|
||||
visibilityStorageKey,
|
||||
}: Props,
|
||||
forwardedRef: Ref<TabsRef>,
|
||||
) {
|
||||
@@ -108,10 +120,33 @@ 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 value = storedActiveTab ?? internalValue ?? defaultValue ?? originalTabs[0]?.value;
|
||||
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;
|
||||
|
||||
// Helper to normalize storage (handle migration from old format)
|
||||
const normalizeStorage = useCallback(
|
||||
@@ -123,6 +158,9 @@ 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) => {
|
||||
@@ -135,7 +173,15 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
|
||||
}
|
||||
onChangeValueProp?.(newValue);
|
||||
},
|
||||
[storageKey, activeTabKey, setStorage, onChangeValueProp, normalizeStorage],
|
||||
[
|
||||
storageKey,
|
||||
activeTabKey,
|
||||
setStorage,
|
||||
onChangeValueProp,
|
||||
normalizeStorage,
|
||||
visibilityStorageKey,
|
||||
setVisibility,
|
||||
],
|
||||
);
|
||||
|
||||
// Expose imperative methods via ref
|
||||
@@ -199,6 +245,18 @@ 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(() => {
|
||||
@@ -290,69 +348,168 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
|
||||
const tabButtons = useMemo(() => {
|
||||
const items: ReactNode[] = [];
|
||||
tabs.forEach((t, i) => {
|
||||
if ("hidden" in t && t.hidden) {
|
||||
if (!fitsInStrip(t)) {
|
||||
return;
|
||||
}
|
||||
|
||||
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>,
|
||||
);
|
||||
}
|
||||
|
||||
const next = tabs[i + 1];
|
||||
const fitted = fittedTabs?.find((tab) => tab.value === t.value);
|
||||
items.push(
|
||||
<TabButton
|
||||
key={t.value}
|
||||
tab={t}
|
||||
isActive={isActive}
|
||||
isActive={t.value === value}
|
||||
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]);
|
||||
}, [
|
||||
tabs,
|
||||
value,
|
||||
addBorders,
|
||||
layout,
|
||||
reorderable,
|
||||
isDragging,
|
||||
onChangeValue,
|
||||
hoveredIndex,
|
||||
fitsInStrip,
|
||||
fittedTabs,
|
||||
wrapTabs,
|
||||
]);
|
||||
|
||||
const tabList = (
|
||||
const tabStrip = (
|
||||
<div
|
||||
ref={viewportRef}
|
||||
role="tablist"
|
||||
aria-label={label}
|
||||
className={classNames(
|
||||
tabListClassName,
|
||||
!visibilityStorageKey && 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" && "overflow-x-auto overflow-y-visible ",
|
||||
layout === "vertical" &&
|
||||
(responsive ? "relative overflow-hidden py-1" : "overflow-x-auto overflow-y-visible"),
|
||||
// Give space for button focus states within overflow boundary.
|
||||
!addBorders && layout === "vertical" && "py-1 pl-3 -ml-5 pr-1",
|
||||
!responsive && !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}
|
||||
{hoveredIndex === tabs.length && (
|
||||
<div className={classNames("relative", layout === "vertical" ? "w-0" : "h-0")}>
|
||||
<DropMarker orientation={layout === "vertical" ? "vertical" : "horizontal"} />
|
||||
</div>
|
||||
</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",
|
||||
)}
|
||||
>
|
||||
<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 (
|
||||
@@ -361,9 +518,13 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
|
||||
className={classNames(
|
||||
className,
|
||||
"tabs-container",
|
||||
"h-full grid",
|
||||
"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",
|
||||
layout === "horizontal" && "grid-rows-1 grid-cols-[auto_minmax(0,1fr)]",
|
||||
layout === "vertical" && "grid-rows-[auto_minmax(0,1fr)] grid-cols-1",
|
||||
!responsive && layout === "vertical" && "grid-rows-[auto_minmax(0,1fr)] grid-cols-1",
|
||||
)}
|
||||
>
|
||||
{reorderable ? (
|
||||
@@ -409,6 +570,11 @@ interface TabButtonProps {
|
||||
isDragging: boolean;
|
||||
onChangeValue?: (value: string) => void;
|
||||
overlay?: boolean;
|
||||
width?: number;
|
||||
spacingCompression?: number;
|
||||
wrap?: boolean;
|
||||
dropBefore?: boolean;
|
||||
dropAfter?: boolean;
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
@@ -420,35 +586,26 @@ function TabButton({
|
||||
isDragging,
|
||||
onChangeValue,
|
||||
overlay = false,
|
||||
width,
|
||||
spacingCompression = 0,
|
||||
wrap = false,
|
||||
dropBefore = false,
|
||||
dropAfter = false,
|
||||
}: TabButtonProps) {
|
||||
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 constrained = width !== undefined;
|
||||
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) => {
|
||||
@@ -457,7 +614,8 @@ function TabButton({
|
||||
},
|
||||
className: classNames(
|
||||
"flex items-center rounded-sm whitespace-nowrap",
|
||||
"px-2! ml-px",
|
||||
"ml-px",
|
||||
!constrained && "px-2!",
|
||||
"outline-hidden",
|
||||
"ring-none",
|
||||
"focus-visible-or-class:outline-2",
|
||||
@@ -471,6 +629,9 @@ 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",
|
||||
),
|
||||
};
|
||||
|
||||
@@ -487,6 +648,9 @@ 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">
|
||||
@@ -511,22 +675,91 @@ function TabButton({
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button leftSlot={tab.leftSlot} rightSlot={tab.rightSlot} {...btnProps}>
|
||||
<Button
|
||||
title={(constrained || wrap) && "label" in tab ? tab.label : undefined}
|
||||
leftSlot={tab.leftSlot}
|
||||
rightSlot={tab.rightSlot}
|
||||
{...btnProps}
|
||||
>
|
||||
{"label" in tab && tab.label ? tab.label : tab.value}
|
||||
</Button>
|
||||
);
|
||||
})();
|
||||
|
||||
// Apply drag handlers to wrapper, not button
|
||||
const wrapperProps = reorderable && !overlay ? { ...attributes, ...listeners } : {};
|
||||
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],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={handleSetWrapperRef}
|
||||
className={classNames("relative", layout === "vertical" && "mr-2")}
|
||||
{...wrapperProps}
|
||||
>
|
||||
{buttonContent}
|
||||
<div ref={setRef} className={className} style={style} {...attributes} {...listeners}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
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),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
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 };
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
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 { GitStatusEntry } from "@yaakapp-internal/git";
|
||||
import type { GitStatus, GitStatusEntry } from "@yaakapp-internal/git";
|
||||
import { useGit } from "@yaakapp-internal/git";
|
||||
import type {
|
||||
Environment,
|
||||
@@ -9,8 +9,7 @@ import type {
|
||||
Workspace,
|
||||
} from "@yaakapp-internal/models";
|
||||
import { Banner, HStack, Icon, InlineCode, SplitLayout } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { type ComponentProps, useCallback, useMemo, useState } from "react";
|
||||
import { modelToYaml } from "../../lib/diffYaml";
|
||||
import { trackFeatureUsage } from "../../lib/featureFeedback";
|
||||
import { resolvedModelName } from "../../lib/resolvedModelName";
|
||||
@@ -19,6 +18,7 @@ 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";
|
||||
@@ -333,16 +333,7 @@ function CommitTreeRow({ node }: { node: CommitTreeNode }) {
|
||||
)}
|
||||
<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>
|
||||
<Chip color={statusColor(node.status.status)}>{node.status.status}</Chip>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
@@ -376,16 +367,9 @@ 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>
|
||||
<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",
|
||||
)}
|
||||
>
|
||||
<Chip className="ml-auto" color={statusColor(entry.status)}>
|
||||
{entry.status}
|
||||
</InlineCode>
|
||||
</Chip>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
@@ -479,3 +463,20 @@ function DiffPanel({
|
||||
</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";
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { DivergedStrategy } from "@yaakapp-internal/git";
|
||||
import { HStack, InlineCode } from "@yaakapp-internal/ui";
|
||||
import { InlineCode } from "@yaakapp-internal/ui";
|
||||
import { useState } from "react";
|
||||
import { showDialog } from "../../lib/dialog";
|
||||
import { Button } from "../core/Button";
|
||||
import { DialogFooter } from "../core/Dialog";
|
||||
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 mb-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-text-subtle">
|
||||
Your local branch has diverged from{" "}
|
||||
<InlineCode>
|
||||
@@ -59,18 +59,18 @@ function DivergedDialog({ remote, branch, onResult, onHide }: DivergedDialogProp
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<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>
|
||||
<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,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,18 +3,15 @@ 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 } from "react-pdf";
|
||||
import { Document, Page, pdfjs } from "react-pdf";
|
||||
import { useContainerSize } from "@yaakapp-internal/ui";
|
||||
import { fireAndForget } from "../../lib/fireAndForget";
|
||||
|
||||
fireAndForget(
|
||||
import("react-pdf").then(({ pdfjs }) => {
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
||||
"pdfjs-dist/build/pdf.worker.min.mjs",
|
||||
import.meta.url,
|
||||
).toString();
|
||||
}),
|
||||
);
|
||||
// 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();
|
||||
|
||||
interface Props {
|
||||
/** A URL for the body the host already stored. */
|
||||
@@ -59,6 +56,8 @@ 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}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
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 } from "react";
|
||||
import { Banner, HStack, Icon, InlineCode } from "@yaakapp-internal/ui";
|
||||
import { Children, useCallback, useMemo, useState } from "react";
|
||||
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,12 +38,43 @@ 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;
|
||||
@@ -155,16 +186,7 @@ export function TextViewer({
|
||||
}
|
||||
|
||||
return (
|
||||
<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 />
|
||||
)}
|
||||
<div className="relative h-full w-full">
|
||||
<Editor
|
||||
readOnly
|
||||
className={className}
|
||||
@@ -174,45 +196,23 @@ 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) => {
|
||||
|
||||
@@ -23,7 +23,6 @@ export function useExportData() {
|
||||
id: "export-data",
|
||||
title: "Export Data",
|
||||
size: "md",
|
||||
noPadding: true,
|
||||
render: ({ hide }) => (
|
||||
<ExportDataDialog
|
||||
onHide={hide}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
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,5 +1,6 @@
|
||||
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";
|
||||
@@ -10,6 +11,10 @@ async function sendAnyHttpRequestById(id: string | null): Promise<HttpResponse |
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!(await confirmWebProxy())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await flushAllModelWrites();
|
||||
|
||||
return rpc("cmd_send_http_request", {
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
<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>-->
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
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<");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
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
|
||||
@@ -45,7 +46,3 @@ export function tryBuildIntrospectionFromFile(
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import { FormattedError, VStack } from "@yaakapp-internal/ui";
|
||||
import { Button } from "../components/core/Button";
|
||||
import { ImportDataDialog } from "../components/ImportDataDialog";
|
||||
import { activeFolderAtom } from "../hooks/useActiveFolder";
|
||||
import type { ImportSourcePath } from "../components/ImportSourceList";
|
||||
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
|
||||
import { createFastMutation } from "../hooks/useFastMutation";
|
||||
import { showAlert } from "./alert";
|
||||
@@ -20,10 +20,17 @@ 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 planFile = (filePath: string, destination: ImportDestination) =>
|
||||
rpc<ImportPlan>("cmd_import_data", { filePath, destination });
|
||||
const planUrl = (url: string, destination: ImportDestination) =>
|
||||
rpc<ImportPlan>("cmd_import_url", { url, destination });
|
||||
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 }) =>
|
||||
@@ -43,11 +50,11 @@ export const importData = createFastMutation({
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const currentWorkspace = jotaiStore.get(activeWorkspaceAtom);
|
||||
const workspaces = jotaiStore.get(workspacesAtom);
|
||||
const selectedFolder = jotaiStore.get(activeFolderAtom);
|
||||
showDialog({
|
||||
id: "import",
|
||||
title: "Import Data",
|
||||
size: "lg",
|
||||
className: "h-[36rem]",
|
||||
disableClose: true,
|
||||
render: ({ hide }) => {
|
||||
const cancel = () => {
|
||||
@@ -68,9 +75,8 @@ export const importData = createFastMutation({
|
||||
<ImportDataDialog
|
||||
currentWorkspace={currentWorkspace}
|
||||
workspaces={workspaces}
|
||||
selectedFolder={selectedFolder}
|
||||
planFile={planFile}
|
||||
planUrl={planUrl}
|
||||
planSources={planSources}
|
||||
detectSource={detectSource}
|
||||
listSources={listSources}
|
||||
findSourcesForOrigin={findSourcesForOrigin}
|
||||
commit={commit}
|
||||
|
||||
@@ -6,7 +6,6 @@ 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";
|
||||
|
||||
@@ -212,35 +211,26 @@ 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 goes straight to the backend as-is — decoding it here only to
|
||||
* 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
|
||||
* 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 filepath = await platform.dialog.save({ defaultPath: `${name}.${ext}`, title: "Save Value" });
|
||||
if (filepath == null) {
|
||||
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) {
|
||||
return; // Cancelled
|
||||
}
|
||||
|
||||
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}` });
|
||||
showToast({ message: `Saved to ${savedTo}` });
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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 } : {},
|
||||
});
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.8.1",
|
||||
"@codemirror/commands": "^6.11.1",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/lang-markdown": "^6.3.2",
|
||||
@@ -19,7 +19,7 @@
|
||||
"@codemirror/merge": "^6.11.2",
|
||||
"@codemirror/search": "^6.5.11",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@gilbarbara/deep-equal": "^0.3.1",
|
||||
"@gilbarbara/deep-equal": "^0.4.1",
|
||||
"@lezer/highlight": "^1.1.3",
|
||||
"@lezer/lr": "^1.3.3",
|
||||
"@mjackson/multipart-parser": "^0.10.1",
|
||||
@@ -27,14 +27,14 @@
|
||||
"@replit/codemirror-vim": "^6.3.0",
|
||||
"@replit/codemirror-vscode-keymap": "^6.0.2",
|
||||
"@shopify/lang-jsonc": "^1.0.1",
|
||||
"@tanstack/react-query": "^5.90.5",
|
||||
"@tanstack/react-router": "^1.133.13",
|
||||
"@tanstack/react-virtual": "^3.14.9",
|
||||
"@tanstack/react-query": "^5.103.2",
|
||||
"@tanstack/react-router": "^1.170.38",
|
||||
"@tanstack/react-virtual": "^3.14.13",
|
||||
"@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.8.0",
|
||||
"@tauri-apps/plugin-log": "^2.9.2",
|
||||
"@tauri-apps/plugin-opener": "^2.5.4",
|
||||
"@tauri-apps/plugin-os": "^2.3.2",
|
||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||
@@ -45,26 +45,26 @@
|
||||
"date-fns": "^4.1.0",
|
||||
"deep-equal": "^2.2.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"focus-trap-react": "^11.0.4",
|
||||
"focus-trap-react": "^12.0.3",
|
||||
"fuzzbunny": "^1.0.1",
|
||||
"graphql": "^16.13.1",
|
||||
"hexy": "^0.3.5",
|
||||
"graphql": "^17.0.2",
|
||||
"hexy": "^0.4.0",
|
||||
"history": "^5.3.0",
|
||||
"jotai": "^2.18.0",
|
||||
"jotai-family": "^1.0.1",
|
||||
"js-md5": "^0.8.3",
|
||||
"js-md5": "^0.9.2",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"lucide-react": "^0.525.0",
|
||||
"lucide-react": "^1.47.0",
|
||||
"mime": "^4.0.4",
|
||||
"motion": "^12.4.7",
|
||||
"nanoid": "^5.1.16",
|
||||
"motion": "^13.4.0",
|
||||
"nanoid": "^6.0.1",
|
||||
"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": "^10.0.1",
|
||||
"react-pdf": "^11.0.0",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"react-use": "^17.6.1",
|
||||
"rehype-stringify": "^10.0.1",
|
||||
@@ -73,32 +73,30 @@
|
||||
"slugify": "^1.6.6",
|
||||
"uuid": "^14.0.0",
|
||||
"vkbeautify": "^0.99.3",
|
||||
"whatwg-mimetype": "^4.0.0",
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"yaml": "^2.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lezer/generator": "^1.8.0",
|
||||
"@rolldown/plugin-babel": "^0.2.3",
|
||||
"@tanstack/router-plugin": "^1.127.5",
|
||||
"@tanstack/router-plugin": "^1.168.40",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"@types/node": "^24.0.13",
|
||||
"@types/node": "^22.20.4",
|
||||
"@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": "^3.0.2",
|
||||
"@types/whatwg-mimetype": "^5.0.0",
|
||||
"@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",
|
||||
"internal-ip": "^8.0.0",
|
||||
"rollup": "^4.60.3",
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.9",
|
||||
"vite-plugin-static-copy": "^3.3.0",
|
||||
"vite-plugin-svgr": "^4.5.0",
|
||||
"rollup": "^4.63.4",
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.3.3",
|
||||
"vite-plugin-static-copy": "^4.1.1",
|
||||
"vite-plugin-wasm": "^3.5.0",
|
||||
"vite-plus": "^0.2.9"
|
||||
"vite-plus": "^0.3.3"
|
||||
}
|
||||
}
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
import "react";
|
||||
|
||||
declare module "react" {
|
||||
interface HTMLAttributes<T> {
|
||||
// Not in @types/react yet; opts editable elements out of macOS Writing Tools
|
||||
writingsuggestions?: "true" | "false";
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,19 @@
|
||||
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() {
|
||||
return <RedirectToLatestWorkspace />;
|
||||
const { home } = Route.useSearch();
|
||||
return home ? <Onboarding /> : <RedirectToLatestWorkspace />;
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
// @ts-ignore
|
||||
import { tanstackRouter } from "@tanstack/router-plugin/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import fs from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { defineConfig, normalizePath } from "vite-plus";
|
||||
import { viteStaticCopy } from "vite-plugin-static-copy";
|
||||
import svgr from "vite-plugin-svgr";
|
||||
import wasm from "vite-plugin-wasm";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
@@ -16,6 +16,12 @@ const standardFontsDir = normalizePath(
|
||||
path.join(path.dirname(require.resolve("pdfjs-dist/package.json")), "standard_fonts"),
|
||||
);
|
||||
|
||||
// The app's own icons, served as the page icon rather than copied into the client:
|
||||
// the bundled app and the tab should not be able to disagree about what Yaak looks like.
|
||||
const iconsDir = normalizePath(
|
||||
path.join(import.meta.dirname, "../../crates-tauri/yaak-app-client/icons/release"),
|
||||
);
|
||||
|
||||
/**
|
||||
* Which host the platform package installs. `web` builds Yaak to run in a plain
|
||||
* browser tab, with its own IndexedDB store instead of the Rust engine; anything
|
||||
@@ -23,6 +29,84 @@ const standardFontsDir = normalizePath(
|
||||
*/
|
||||
const yaakTarget = process.env.YAAK_TARGET === "web" ? "web" : "desktop";
|
||||
|
||||
const webUrl = "https://web.yaak.app";
|
||||
const webDir = normalizePath(path.join(import.meta.dirname, "web"));
|
||||
|
||||
/** Link previews for the hosted web app. Meaningless inside the desktop window. */
|
||||
function webHead() {
|
||||
return {
|
||||
name: "web-head",
|
||||
transformIndexHtml() {
|
||||
const title = "Yaak in your browser";
|
||||
const description = "No download, no setup. Open a tab and send your first request.";
|
||||
return [
|
||||
{ tag: "meta", attrs: { name: "description", content: description } },
|
||||
{ tag: "meta", attrs: { property: "og:type", content: "website" } },
|
||||
{ tag: "meta", attrs: { property: "og:url", content: webUrl } },
|
||||
{ tag: "meta", attrs: { property: "og:title", content: title } },
|
||||
{ tag: "meta", attrs: { property: "og:description", content: description } },
|
||||
{ tag: "meta", attrs: { property: "og:image", content: `${webUrl}/og.png` } },
|
||||
{ tag: "meta", attrs: { property: "og:image:width", content: "1200" } },
|
||||
{ tag: "meta", attrs: { property: "og:image:height", content: "630" } },
|
||||
{ tag: "meta", attrs: { name: "twitter:card", content: "summary_large_image" } },
|
||||
{ tag: "meta", attrs: { name: "twitter:site", content: "@yaakapp" } },
|
||||
{ tag: "meta", attrs: { name: "twitter:title", content: title } },
|
||||
{ tag: "meta", attrs: { name: "twitter:description", content: description } },
|
||||
{ tag: "meta", attrs: { name: "twitter:image", content: `${webUrl}/og.png` } },
|
||||
];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Where `yaak-web` is listening, taken from the same variables that put it there.
|
||||
*
|
||||
* `HOST` is an instruction about what the server accepts rather than an address to
|
||||
* dial, so a wildcard becomes loopback: the dev server and the send server share a
|
||||
* machine.
|
||||
*/
|
||||
function sendServerUrl(): string {
|
||||
const host = process.env.HOST?.trim();
|
||||
const port = process.env.PORT?.trim() || "9227";
|
||||
const wildcard = !host || host === "0.0.0.0" || host === "::" || host === "[::]";
|
||||
return `http://${wildcard ? "127.0.0.1" : host}:${port}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails the build when a copied asset is not where the app fetches it from.
|
||||
*
|
||||
* `PdfViewer` and the page head address these by URL, but nothing ties those
|
||||
* URLs to the copy targets below, and a target that mirrors its source path
|
||||
* instead of landing at the root still builds cleanly — the miss only shows up
|
||||
* as a 404 once the app runs.
|
||||
*/
|
||||
function verifyServedAssets(expected: string[]) {
|
||||
let outDir = "";
|
||||
return {
|
||||
name: "verify-served-assets",
|
||||
apply: "build" as const,
|
||||
configResolved(config: { root: string; build: { outDir: string } }) {
|
||||
outDir = path.resolve(config.root, config.build.outDir);
|
||||
},
|
||||
// After viteStaticCopy, which writes on `writeBundle`.
|
||||
closeBundle() {
|
||||
const missing = expected.filter((asset) => {
|
||||
const full = path.join(outDir, asset);
|
||||
if (!fs.existsSync(full)) return true;
|
||||
const stat = fs.statSync(full);
|
||||
return stat.isDirectory() && fs.readdirSync(full).length === 0;
|
||||
});
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`Copied assets missing from ${outDir}: ${missing.join(", ")}. ` +
|
||||
`Every viteStaticCopy target needs to strip its base path, or it lands ` +
|
||||
`under a copy of the directories it came from instead.`,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(async () => {
|
||||
return {
|
||||
@@ -56,14 +140,44 @@ export default defineConfig(async () => {
|
||||
generatedRouteTree: "./routeTree.gen.ts",
|
||||
autoCodeSplitting: true,
|
||||
}),
|
||||
svgr(),
|
||||
react(),
|
||||
viteStaticCopy({
|
||||
// v4 matches only files and always mirrors the source tree into the
|
||||
// output, so every target here needs stripBase to land where it is
|
||||
// actually served from — without it these end up under a copy of the
|
||||
// path they came from, and nothing fails until the request 404s.
|
||||
targets: [
|
||||
{ src: cMapsDir, dest: "" },
|
||||
{ src: standardFontsDir, dest: "" },
|
||||
{ src: `${cMapsDir}/*`, dest: "cmaps", rename: { stripBase: true } },
|
||||
{
|
||||
src: `${standardFontsDir}/*`,
|
||||
dest: "standard_fonts",
|
||||
rename: { stripBase: true },
|
||||
},
|
||||
// `/favicon.ico` is requested by browsers whether or not anything links to it,
|
||||
// so it is served under that name to keep a 404 out of every console.
|
||||
{
|
||||
src: `${iconsDir}/icon.ico`,
|
||||
dest: "",
|
||||
rename: { name: "favicon.ico", stripBase: true },
|
||||
},
|
||||
{
|
||||
src: `${iconsDir}/128x128.png`,
|
||||
dest: "",
|
||||
rename: { name: "icon-128.png", stripBase: true },
|
||||
},
|
||||
...(yaakTarget === "web"
|
||||
? [{ src: `${webDir}/og.png`, dest: "", rename: { stripBase: true } }]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
...(yaakTarget === "web" ? [webHead()] : []),
|
||||
verifyServedAssets([
|
||||
"cmaps",
|
||||
"standard_fonts",
|
||||
"favicon.ico",
|
||||
"icon-128.png",
|
||||
...(yaakTarget === "web" ? ["og.png"] : []),
|
||||
]),
|
||||
],
|
||||
build: {
|
||||
target: "esnext",
|
||||
@@ -84,8 +198,28 @@ export default defineConfig(async () => {
|
||||
},
|
||||
clearScreen: false,
|
||||
server: {
|
||||
// `HOST` names the interface, as it does most places: unset leaves Vite on
|
||||
// loopback, `0.0.0.0` exposes it for reaching the dev server from another
|
||||
// device.
|
||||
host: process.env.HOST,
|
||||
// Vite refuses a `Host` it does not recognise, which stops a page on a name the
|
||||
// attacker controls from rebinding that name here and driving `/v1` as its own
|
||||
// origin. Addresses are allowed already; only names need listing, so reaching
|
||||
// this as `dev-box.example` means naming it. Deliberately not widened to "any
|
||||
// host when exposed": the proxy below leads to an unauthenticated sender.
|
||||
allowedHosts: process.env.ALLOWED_HOSTS?.split(",")
|
||||
.map((h) => h.trim())
|
||||
.filter(Boolean),
|
||||
port: parseInt(process.env.YAAK_CLIENT_DEV_PORT ?? process.env.YAAK_DEV_PORT ?? "1420", 10),
|
||||
strictPort: true,
|
||||
// A web dev server is one origin, the way the built app is: `/v1` is passed
|
||||
// through to `yaak-web` rather than the tab being told to call it directly.
|
||||
// That is one address to open instead of two, no CORS in the loop, and a
|
||||
// dev build that sends exactly the way a production build does.
|
||||
proxy:
|
||||
yaakTarget === "web"
|
||||
? { "/v1": { target: sendServerUrl(), changeOrigin: true } }
|
||||
: undefined,
|
||||
},
|
||||
envPrefix: ["VITE_", "TAURI_"],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
Source for og.png (1200x630). Re-render after editing:
|
||||
cd apps/yaak-client/web && "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new --disable-gpu --hide-scrollbars --window-size=1200,630 --force-device-scale-factor=1 --virtual-time-budget=10000 --screenshot=og.png "file://$PWD/og.html"
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@500;600;800&family=JetBrains+Mono:wght@400;500&display=block"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
width: 1200px;
|
||||
height: 630px;
|
||||
overflow: hidden;
|
||||
}
|
||||
body {
|
||||
font-family: Inter, sans-serif;
|
||||
color: hsl(245, 23%, 85%);
|
||||
background:
|
||||
radial-gradient(900px 600px at 0% 100%, hsl(160, 40%, 16%) 0%, transparent 60%),
|
||||
radial-gradient(900px 600px at 100% 0%, hsl(275, 40%, 20%) 0%, transparent 60%),
|
||||
linear-gradient(135deg, hsl(244, 23%, 12%) 0%, hsl(244, 23%, 15%) 100%);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.dots {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: radial-gradient(hsla(245, 23%, 85%, 0.07) 1.2px, transparent 1.2px);
|
||||
background-size: 28px 28px;
|
||||
mask-image: radial-gradient(ellipse at 50% 40%, black 30%, transparent 75%);
|
||||
}
|
||||
.page {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
padding: 56px 64px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
font-weight: 600;
|
||||
font-size: 26px;
|
||||
}
|
||||
.brand img {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
h1 {
|
||||
margin-top: 52px;
|
||||
font-size: 84px;
|
||||
line-height: 1.05;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
color: hsl(245, 23%, 92%);
|
||||
}
|
||||
h1 em {
|
||||
font-style: normal;
|
||||
color: hsl(48, 80%, 63%);
|
||||
}
|
||||
p {
|
||||
margin-top: 28px;
|
||||
font-size: 34px;
|
||||
line-height: 1.4;
|
||||
font-weight: 500;
|
||||
color: hsl(245, 18%, 68%);
|
||||
max-width: 1040px;
|
||||
}
|
||||
.bar {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 22px;
|
||||
height: 72px;
|
||||
padding: 0 28px;
|
||||
border-radius: 12px;
|
||||
background: hsla(244, 23%, 22%, 0.6);
|
||||
border: 1px solid hsl(244, 23%, 28%);
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 28px;
|
||||
box-shadow: 0 10px 40px hsla(244, 40%, 5%, 0.4);
|
||||
}
|
||||
.method {
|
||||
color: hsl(245, 18%, 62%);
|
||||
font-weight: 500;
|
||||
}
|
||||
.url .scheme {
|
||||
color: hsl(245, 18%, 55%);
|
||||
}
|
||||
.url .host {
|
||||
color: hsl(245, 23%, 92%);
|
||||
}
|
||||
.status {
|
||||
margin-left: auto;
|
||||
color: hsl(150, 99%, 44%);
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="dots"></div>
|
||||
<div class="page">
|
||||
<div class="brand">
|
||||
<img src="../../../crates-tauri/yaak-app-client/icons/release/128x128@2x.png" alt="" />
|
||||
<span>Yaak</span>
|
||||
</div>
|
||||
<h1>Your favorite API client<br /><em>in your browser</em></h1>
|
||||
<p>No download, no setup. Open a tab and send your first request.</p>
|
||||
<div class="bar">
|
||||
<span class="method">GET</span>
|
||||
<span class="url"><span class="scheme">https://</span><span class="host">web.yaak.app</span></span>
|
||||
<span class="status">200 OK</span>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 295 KiB |
@@ -9,7 +9,7 @@
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.90.5",
|
||||
"@tanstack/react-query": "^5.103.2",
|
||||
"@tauri-apps/api": "^2.11.0",
|
||||
"@tauri-apps/plugin-os": "^2.3.2",
|
||||
"@yaakapp-internal/model-store": "^1.0.0",
|
||||
@@ -19,7 +19,7 @@
|
||||
"classnames": "^2.5.1",
|
||||
"jotai": "^2.18.0",
|
||||
"jotai-family": "^1.0.1",
|
||||
"motion": "^12.4.7",
|
||||
"motion": "^13.4.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
@@ -31,7 +31,7 @@
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.9",
|
||||
"vite-plus": "^0.2.9"
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.3.3",
|
||||
"vite-plus": "^0.3.3"
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ name = "yaak"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
yaak-assertions = { workspace = true }
|
||||
arboard = "3"
|
||||
base64 = "0.22"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
@@ -22,8 +23,11 @@ include_dir = "0.7"
|
||||
keyring = { workspace = true, features = ["apple-native", "windows-native", "sync-secret-service"] }
|
||||
log = { workspace = true }
|
||||
rand = "0.8"
|
||||
reqwest = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["form"] }
|
||||
rolldown = "0.1.0"
|
||||
# Pinned exactly: rolldown_resolver takes `oxc_resolver = "^11"`, so cargo
|
||||
# drifts it forward and breaks on 11.11 (TsconfigOptions became
|
||||
# TsconfigDiscovery). This dep exists only to hold that resolution.
|
||||
oxc_resolver = "=11.10.0"
|
||||
schemars = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
@@ -39,7 +43,7 @@ tokio = { workspace = true, features = [
|
||||
] }
|
||||
walkdir = "2"
|
||||
webbrowser = "1"
|
||||
zip = "4"
|
||||
zip = "7"
|
||||
yaak = { workspace = true }
|
||||
yaak-api = { workspace = true }
|
||||
yaak-core = { workspace = true }
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
---
|
||||
name: use-yaak
|
||||
description: >
|
||||
Build and run HTTP API requests with the Yaak CLI (`yaak`): create workspaces,
|
||||
folders, environments and variables, author HTTP requests, configure
|
||||
authentication (OAuth 2.0, bearer tokens, API keys, basic, JWT, AWS SigV4),
|
||||
send them individually or a whole folder/workspace at once, chain one
|
||||
request's response into the next, and import existing APIs from OpenAPI,
|
||||
Postman, Insomnia, or cURL. Use this skill whenever the user mentions Yaak, a
|
||||
Yaak workspace, or the `yaak` command, and also when they ask to try, hit,
|
||||
call, exercise, or smoke test an HTTP or REST endpoint, to save or organize
|
||||
API requests for reuse, to set up API requests for manual testing, to add auth
|
||||
to a saved request, to turn an OpenAPI or Postman collection into runnable
|
||||
requests, or to run a saved request suite against staging versus production.
|
||||
Prefer this over one-off `curl` commands whenever the requests should be
|
||||
saved, reused, shared, or run as a set.
|
||||
Use when the user mentions Yaak, a Yaak workspace, or the `yaak` command, or
|
||||
asks to call, hit, or smoke test HTTP/REST endpoints, save or organize API
|
||||
requests for reuse or manual testing, configure auth on saved requests, import
|
||||
an OpenAPI spec, a Postman, Insomnia, or Bruno collection, or a cURL command, or run
|
||||
saved requests across environments. Prefer over one-off `curl` when requests
|
||||
should be saved, reused, shared, or run as a set.
|
||||
allowed-tools: Bash(yaak:*), Bash(which:*), Bash(command:*), Bash(npm:*), Bash(npx:*)
|
||||
---
|
||||
|
||||
@@ -34,7 +27,7 @@ only for publishing plugins to the Yaak registry.
|
||||
Two consequences worth holding onto. Requests you create are permanent user data
|
||||
in an app they use, not scratch files, so name them the way the user would and
|
||||
clean up anything created just to test. And because the app is right there, the
|
||||
CLI is usually the wrong place to *read* a response in detail; it is the right
|
||||
CLI is usually the wrong place to _read_ a response in detail; it is the right
|
||||
place to build, organize, and run requests.
|
||||
|
||||
## The CLI describes itself
|
||||
@@ -105,9 +98,9 @@ re-run it after acting.
|
||||
|
||||
## Core workflows
|
||||
|
||||
**Start from a spec when one exists.** `yaak import <file>` auto-detects OpenAPI,
|
||||
Swagger, Postman, Insomnia, cURL, and Yaak exports, and beats authoring requests
|
||||
by hand every time.
|
||||
**Start from a spec when one exists.** `yaak import <path>` auto-detects OpenAPI,
|
||||
Swagger, Postman, Insomnia, Bruno, cURL, and Yaak exports, and beats authoring requests
|
||||
by hand every time. Bruno collections can also be imported from a ZIP or directory.
|
||||
|
||||
**Make the host swappable.** Put the base URL in a base-environment variable,
|
||||
reference it as `${[ base_url ]}`, then add a sub-environment per deployment
|
||||
|
||||
@@ -45,7 +45,7 @@ pub enum Commands {
|
||||
/// Authentication commands
|
||||
Auth(AuthArgs),
|
||||
|
||||
/// Import API data from Yaak, OpenAPI, Postman, Insomnia, Swagger, or cURL
|
||||
/// Import API data from Yaak, OpenAPI, Postman, Insomnia, Bruno, Swagger, or cURL
|
||||
Import(ImportArgs),
|
||||
|
||||
/// Export Yaak workspace data
|
||||
@@ -71,6 +71,9 @@ pub enum Commands {
|
||||
/// Send a request, folder, or workspace by ID
|
||||
Send(SendArgs),
|
||||
|
||||
/// Run HTTP assertions (exit 0: passed, 1: failed/error, 2: invalid configuration)
|
||||
Test(TestArgs),
|
||||
|
||||
/// Cookie jar commands
|
||||
CookieJar(CookieJarArgs),
|
||||
|
||||
@@ -186,6 +189,24 @@ pub enum AgentCommands {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct TestArgs {
|
||||
/// Request, folder, or workspace ID; sends HTTP requests sequentially in sidebar order
|
||||
pub id: String,
|
||||
|
||||
/// Stop after the first failed check or request error
|
||||
#[arg(long)]
|
||||
pub fail_fast: bool,
|
||||
|
||||
/// Write a JSON report without URLs, headers, bodies, or assertion values
|
||||
#[arg(long, value_name = "FILE")]
|
||||
pub json: Option<PathBuf>,
|
||||
|
||||
/// Write a JUnit XML report without request or assertion values
|
||||
#[arg(long, value_name = "FILE")]
|
||||
pub junit: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct SendArgs {
|
||||
/// Request, folder, or workspace ID
|
||||
@@ -202,7 +223,7 @@ pub struct SendArgs {
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct ImportArgs {
|
||||
/// Path to the file to import
|
||||
/// Path to the file, ZIP archive, or directory to import
|
||||
pub file: PathBuf,
|
||||
|
||||
/// Existing workspace ID to import into when supported by the importer
|
||||
|
||||
@@ -49,8 +49,11 @@ fn schema(pretty: bool) -> CommandResult {
|
||||
fn list(ctx: &CliContext, workspace_id: Option<&str>) -> CommandResult {
|
||||
let workspace_id = resolve_workspace_id(ctx, workspace_id, "environment list")?;
|
||||
let environments = ctx
|
||||
.db()
|
||||
.list_environments_ensure_base(&workspace_id)
|
||||
.query_manager()
|
||||
.with_tx(|tx| {
|
||||
tx.ensure_base_environment(&workspace_id)?;
|
||||
tx.list_environments(&workspace_id)
|
||||
})
|
||||
.map_err(|e| format!("Failed to list environments: {e}"))?;
|
||||
|
||||
if environments.is_empty() {
|
||||
@@ -111,8 +114,8 @@ fn create(
|
||||
}
|
||||
|
||||
let created = ctx
|
||||
.db()
|
||||
.upsert_environment(&environment, &UpdateSource::Sync)
|
||||
.query_manager()
|
||||
.with_tx(|tx| tx.upsert_environment(&environment, &UpdateSource::Sync))
|
||||
.map_err(|e| format!("Failed to create environment: {e}"))?;
|
||||
|
||||
println!("Created environment: {}", created.id);
|
||||
@@ -133,8 +136,8 @@ fn create(
|
||||
};
|
||||
|
||||
let created = ctx
|
||||
.db()
|
||||
.upsert_environment(&environment, &UpdateSource::Sync)
|
||||
.query_manager()
|
||||
.with_tx(|tx| tx.upsert_environment(&environment, &UpdateSource::Sync))
|
||||
.map_err(|e| format!("Failed to create environment: {e}"))?;
|
||||
|
||||
println!("Created environment: {}", created.id);
|
||||
@@ -152,8 +155,8 @@ fn update(ctx: &CliContext, json: Option<String>, json_input: Option<String>) ->
|
||||
let updated = apply_merge_patch(&existing, &patch, &id, "environment update")?;
|
||||
|
||||
let saved = ctx
|
||||
.db()
|
||||
.upsert_environment(&updated, &UpdateSource::Sync)
|
||||
.query_manager()
|
||||
.with_tx(|tx| tx.upsert_environment(&updated, &UpdateSource::Sync))
|
||||
.map_err(|e| format!("Failed to update environment: {e}"))?;
|
||||
|
||||
println!("Updated environment: {}", saved.id);
|
||||
@@ -167,8 +170,8 @@ fn delete(ctx: &CliContext, environment_id: &str, yes: bool) -> CommandResult {
|
||||
}
|
||||
|
||||
let deleted = ctx
|
||||
.db()
|
||||
.delete_environment_by_id(environment_id, &UpdateSource::Sync)
|
||||
.query_manager()
|
||||
.with_tx(|tx| tx.delete_environment_by_id(environment_id, &UpdateSource::Sync))
|
||||
.map_err(|e| format!("Failed to delete environment: {e}"))?;
|
||||
|
||||
println!("Deleted environment: {}", deleted.id);
|
||||
|
||||
@@ -102,8 +102,8 @@ fn create(
|
||||
)?;
|
||||
|
||||
let created = ctx
|
||||
.db()
|
||||
.upsert_folder(&folder, &UpdateSource::Sync)
|
||||
.query_manager()
|
||||
.with_tx(|tx| tx.upsert_folder(&folder, &UpdateSource::Sync))
|
||||
.map_err(|e| format!("Failed to create folder: {e}"))?;
|
||||
|
||||
println!("Created folder: {}", created.id);
|
||||
@@ -118,8 +118,8 @@ fn create(
|
||||
let folder = Folder { workspace_id, name, ..Default::default() };
|
||||
|
||||
let created = ctx
|
||||
.db()
|
||||
.upsert_folder(&folder, &UpdateSource::Sync)
|
||||
.query_manager()
|
||||
.with_tx(|tx| tx.upsert_folder(&folder, &UpdateSource::Sync))
|
||||
.map_err(|e| format!("Failed to create folder: {e}"))?;
|
||||
|
||||
println!("Created folder: {}", created.id);
|
||||
@@ -135,8 +135,8 @@ fn update(ctx: &CliContext, json: Option<String>, json_input: Option<String>) ->
|
||||
let updated = apply_merge_patch(&existing, &patch, &id, "folder update")?;
|
||||
|
||||
let saved = ctx
|
||||
.db()
|
||||
.upsert_folder(&updated, &UpdateSource::Sync)
|
||||
.query_manager()
|
||||
.with_tx(|tx| tx.upsert_folder(&updated, &UpdateSource::Sync))
|
||||
.map_err(|e| format!("Failed to update folder: {e}"))?;
|
||||
|
||||
println!("Updated folder: {}", saved.id);
|
||||
@@ -150,8 +150,8 @@ fn delete(ctx: &CliContext, folder_id: &str, yes: bool) -> CommandResult {
|
||||
}
|
||||
|
||||
let deleted = ctx
|
||||
.db()
|
||||
.delete_folder_by_id(folder_id, &UpdateSource::Sync)
|
||||
.query_manager()
|
||||
.with_tx(|tx| tx.delete_folder_by_id(folder_id, &UpdateSource::Sync))
|
||||
.map_err(|e| format!("Failed to delete folder: {e}"))?;
|
||||
|
||||
println!("Deleted folder: {}", deleted.id);
|
||||
|
||||
@@ -2,13 +2,12 @@ use crate::cli::{ExportArgs, ImportArgs};
|
||||
use crate::context::CliContext;
|
||||
use crate::utils::workspace::resolve_workspace_id;
|
||||
use std::fs;
|
||||
use std::io::ErrorKind;
|
||||
use yaak::export::{self, ExportDataParams};
|
||||
use yaak::import;
|
||||
use yaak_models::export::{self, ExportDataParams};
|
||||
use yaak_models::util::{
|
||||
BatchUpsertResult, ImportDestination, ImportOrigin, ImportPlanAction, ImportPlanItem,
|
||||
};
|
||||
use yaak_plugins::events::{ImportResources, PluginContext};
|
||||
use yaak_plugins::events::{ImportRequest, ImportResources, PluginContext};
|
||||
|
||||
type CommandResult<T = ()> = std::result::Result<T, String>;
|
||||
|
||||
@@ -51,11 +50,12 @@ async fn import(
|
||||
.map_err(|e| format!("Failed to get workspace '{workspace_id}': {e}"))?;
|
||||
}
|
||||
|
||||
let file_contents = read_import_file(&args.file)?;
|
||||
let input = ImportRequest::from_path(&args.file)
|
||||
.map_err(|err| format!("Unable to read import source {}: {err}", args.file.display()))?;
|
||||
let plugin_context = PluginContext::new(None, args.workspace_id.clone());
|
||||
let plugin_manager = ctx.plugin_manager();
|
||||
let import_result = plugin_manager
|
||||
.import_data(&plugin_context, &file_contents)
|
||||
.import_input(&plugin_context, &input)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to import data: {e}"))?;
|
||||
let importer = import_result.importer;
|
||||
@@ -68,7 +68,9 @@ async fn import(
|
||||
);
|
||||
}
|
||||
let destination = match workspace_id {
|
||||
Some(workspace_id) => ImportDestination::ExistingWorkspace { workspace_id, folder_id: None },
|
||||
Some(workspace_id) => {
|
||||
ImportDestination::ExistingWorkspace { workspace_id, folder_id: None }
|
||||
}
|
||||
None => ImportDestination::NewWorkspace,
|
||||
};
|
||||
let plan = import::plan_import_resources(
|
||||
@@ -113,6 +115,10 @@ fn format_skipped(items: &[ImportPlanItem]) -> Option<String> {
|
||||
if keep_local > 0 {
|
||||
parts.push(format!("{keep_local} with local edits"));
|
||||
}
|
||||
let ignored = count(ImportPlanAction::Ignored);
|
||||
if ignored > 0 {
|
||||
parts.push(format!("{ignored} ignored"));
|
||||
}
|
||||
let unchanged = count(ImportPlanAction::Unchanged);
|
||||
if unchanged > 0 {
|
||||
parts.push(format!("{unchanged} unchanged"));
|
||||
@@ -121,17 +127,61 @@ fn format_skipped(items: &[ImportPlanItem]) -> Option<String> {
|
||||
if parts.is_empty() { None } else { Some(parts.join(", ")) }
|
||||
}
|
||||
|
||||
/// Write a file so that afterwards it is either the old one or the whole new one.
|
||||
///
|
||||
/// Writing in place would truncate the previous export before the new one existed, so an
|
||||
/// interrupted run loses both. Instead the bytes go to a neighbouring file, are flushed, and
|
||||
/// take the target's name in one step. The directory is flushed too: `sync_all` on a file
|
||||
/// promises its contents, not that the name it just gained will survive a power cut.
|
||||
fn write_durably(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
use std::io::Write;
|
||||
|
||||
let dir = match path.parent() {
|
||||
Some(p) if !p.as_os_str().is_empty() => p,
|
||||
_ => std::path::Path::new("."),
|
||||
};
|
||||
// Alongside the target, because a rename is only atomic within one filesystem.
|
||||
let mut name = path.file_name().unwrap_or_default().to_os_string();
|
||||
name.push(format!(".tmp{}", std::process::id()));
|
||||
let tmp = dir.join(name);
|
||||
|
||||
let write = |tmp: &std::path::Path| -> std::io::Result<()> {
|
||||
let mut file = std::fs::File::create(tmp)?;
|
||||
file.write_all(bytes)?;
|
||||
file.sync_all()
|
||||
};
|
||||
if let Err(e) = write(&tmp) {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
if let Err(e) = std::fs::rename(&tmp, path) {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// Only Unix lets a directory be opened for this. Elsewhere the rename is as much as
|
||||
// the platform offers, and the file itself is already flushed.
|
||||
#[cfg(unix)]
|
||||
std::fs::File::open(dir)?.sync_all()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn export(ctx: &CliContext, args: ExportArgs) -> CommandResult<usize> {
|
||||
let workspace_ids = resolve_export_workspace_ids(ctx, args.workspace_ids, args.all)?;
|
||||
let workspace_id_refs: Vec<&str> = workspace_ids.iter().map(String::as_str).collect();
|
||||
export::export_data(ExportDataParams {
|
||||
let document = export::export_data(ExportDataParams {
|
||||
query_manager: ctx.query_manager(),
|
||||
yaak_version: env!("CARGO_PKG_VERSION"),
|
||||
export_path: &args.file,
|
||||
workspace_ids: workspace_id_refs,
|
||||
include_private_environments: args.include_private_environments,
|
||||
})
|
||||
.map_err(|e| format!("Failed to export data: {e}"))?;
|
||||
// Flushed before reporting success: an export is a backup, and a backup that the
|
||||
// command called done while it was still only in the page cache is the one case
|
||||
// where saying nothing went wrong is worst.
|
||||
write_durably(&args.file, document.as_bytes())
|
||||
.map_err(|e| format!("Failed to write {}: {e}", args.file.display()))?;
|
||||
|
||||
Ok(workspace_ids.len())
|
||||
}
|
||||
@@ -162,19 +212,6 @@ fn resolve_export_workspace_ids(
|
||||
Ok(workspace_ids)
|
||||
}
|
||||
|
||||
fn read_import_file(path: &std::path::Path) -> CommandResult<String> {
|
||||
fs::read_to_string(path).map_err(|err| {
|
||||
if err.kind() == ErrorKind::InvalidData {
|
||||
format!(
|
||||
"Import file must be UTF-8 text; binary files are not supported: {}",
|
||||
path.display()
|
||||
)
|
||||
} else {
|
||||
format!("Unable to read import file {}: {err}", path.display())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn resources_need_current_workspace(resources: &ImportResources) -> bool {
|
||||
resources.workspaces.iter().any(|w| w.id == "CURRENT_WORKSPACE")
|
||||
|| resources.environments.iter().any(|e| {
|
||||
|
||||
@@ -10,3 +10,5 @@ pub mod response;
|
||||
pub mod send;
|
||||
pub mod template_function;
|
||||
pub mod workspace;
|
||||
|
||||
pub mod test;
|
||||
@@ -348,17 +348,19 @@ async fn install_from_directory(context: &CliContext, source: &str) -> CommandRe
|
||||
ui::info(&format!("Installing plugin from directory {}", plugin_dir.display()));
|
||||
|
||||
let plugin = context
|
||||
.db()
|
||||
.upsert_plugin(
|
||||
&Plugin {
|
||||
directory: plugin_dir_str,
|
||||
url: None,
|
||||
enabled: true,
|
||||
source: PluginSource::Filesystem,
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Background,
|
||||
)
|
||||
.query_manager()
|
||||
.with_tx(|tx| {
|
||||
tx.upsert_plugin(
|
||||
&Plugin {
|
||||
directory: plugin_dir_str,
|
||||
url: None,
|
||||
enabled: true,
|
||||
source: PluginSource::Filesystem,
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Background,
|
||||
)
|
||||
})
|
||||
.map_err(|err| format!("Failed to save plugin in database: {err}"))?;
|
||||
|
||||
let plugin_context = PluginContext::new(Some("cli".to_string()), None);
|
||||
@@ -883,11 +885,8 @@ mod tests {
|
||||
fs::create_dir_all(root.join("build")).expect("create build");
|
||||
fs::create_dir_all(root.join("vendor")).expect("create vendor");
|
||||
fs::write(root.join("vendor/core_bg.wasm"), "asset").expect("write asset");
|
||||
fs::write(
|
||||
root.join("package.json"),
|
||||
r#"{"yaak":{"buildAssets":["vendor/core_bg.wasm"]}}"#,
|
||||
)
|
||||
.expect("write package.json");
|
||||
fs::write(root.join("package.json"), r#"{"yaak":{"buildAssets":["vendor/core_bg.wasm"]}}"#)
|
||||
.expect("write package.json");
|
||||
|
||||
copy_build_assets(root).expect("copy assets");
|
||||
|
||||
|
||||
@@ -424,8 +424,8 @@ fn create(
|
||||
)?;
|
||||
|
||||
let created = ctx
|
||||
.db()
|
||||
.upsert_http_request(&request, &UpdateSource::Sync)
|
||||
.query_manager()
|
||||
.with_tx(|tx| tx.upsert_http_request(&request, &UpdateSource::Sync))
|
||||
.map_err(|e| format!("Failed to create request: {e}"))?;
|
||||
|
||||
println!("Created request: {}", created.id);
|
||||
@@ -443,8 +443,8 @@ fn create(
|
||||
}
|
||||
|
||||
let created = ctx
|
||||
.db()
|
||||
.upsert_http_request(&request, &UpdateSource::Sync)
|
||||
.query_manager()
|
||||
.with_tx(|tx| tx.upsert_http_request(&request, &UpdateSource::Sync))
|
||||
.map_err(|e| format!("Failed to create request: {e}"))?;
|
||||
|
||||
println!("Created request: {}", created.id);
|
||||
@@ -463,8 +463,8 @@ fn update(ctx: &CliContext, json: Option<String>, json_input: Option<String>) ->
|
||||
let updated = apply_merge_patch(&existing, &patch, &id, "request update")?;
|
||||
|
||||
let saved = ctx
|
||||
.db()
|
||||
.upsert_http_request(&updated, &UpdateSource::Sync)
|
||||
.query_manager()
|
||||
.with_tx(|tx| tx.upsert_http_request(&updated, &UpdateSource::Sync))
|
||||
.map_err(|e| format!("Failed to update request: {e}"))?;
|
||||
|
||||
println!("Updated request: {}", saved.id);
|
||||
@@ -487,8 +487,8 @@ fn delete(ctx: &CliContext, request_id: &str, yes: bool) -> CommandResult {
|
||||
}
|
||||
|
||||
let deleted = ctx
|
||||
.db()
|
||||
.delete_http_request_by_id(request_id, &UpdateSource::Sync)
|
||||
.query_manager()
|
||||
.with_tx(|tx| tx.delete_http_request_by_id(request_id, &UpdateSource::Sync))
|
||||
.map_err(|e| format!("Failed to delete request: {e}"))?;
|
||||
println!("Deleted request: {}", deleted.id);
|
||||
Ok(())
|
||||
|
||||
@@ -118,8 +118,8 @@ fn delete(ctx: &CliContext, id: &str, yes: bool) -> CommandResult {
|
||||
return Ok(());
|
||||
}
|
||||
let count = ctx
|
||||
.db()
|
||||
.delete_all_http_responses_for_request(id, &UpdateSource::Sync)
|
||||
.query_manager()
|
||||
.with_tx(|tx| tx.delete_all_http_responses_for_request(id, &UpdateSource::Sync))
|
||||
.map_err(|e| format!("Failed to delete responses: {e}"))?;
|
||||
println!("Deleted {count} responses for request {id}");
|
||||
return Ok(());
|
||||
@@ -131,8 +131,10 @@ fn delete(ctx: &CliContext, id: &str, yes: bool) -> CommandResult {
|
||||
return Ok(());
|
||||
}
|
||||
let count = ctx
|
||||
.db()
|
||||
.delete_all_http_responses_for_workspace(&workspace_id, &UpdateSource::Sync)
|
||||
.query_manager()
|
||||
.with_tx(|tx| {
|
||||
tx.delete_all_http_responses_for_workspace(&workspace_id, &UpdateSource::Sync)
|
||||
})
|
||||
.map_err(|e| format!("Failed to delete responses: {e}"))?;
|
||||
println!("Deleted {count} responses for workspace {workspace_id}");
|
||||
Ok(())
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
use crate::cli::TestArgs;
|
||||
use crate::commands::request::resolve_cookie_jar_id;
|
||||
use crate::context::CliContext;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashSet;
|
||||
use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
|
||||
use yaak_assertions::{AssertionReport, HttpAssertions};
|
||||
use yaak_models::models::{Folder, HttpRequest};
|
||||
use yaak_models::queries::any_request::AnyRequest;
|
||||
use yaak_models::util::UpdateSource;
|
||||
use yaak_plugins::events::PluginContext;
|
||||
|
||||
// Reports intentionally contain no names, URLs, selectors, expected/actual values, or transport
|
||||
// error strings (which can embed URLs/tokens). The stored response has details for local inspection.
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CheckResult {
|
||||
assertion_id: String,
|
||||
outcome: String,
|
||||
reason: String,
|
||||
}
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RequestResult {
|
||||
request_id: String,
|
||||
outcome: String,
|
||||
reason: String,
|
||||
checks: Vec<CheckResult>,
|
||||
}
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RunReport {
|
||||
version: u32,
|
||||
exit_code: i32,
|
||||
requests: Vec<RequestResult>,
|
||||
}
|
||||
|
||||
pub async fn run(
|
||||
ctx: &CliContext,
|
||||
args: TestArgs,
|
||||
environment: Option<&str>,
|
||||
cookie_jar: Option<&str>,
|
||||
) -> i32 {
|
||||
match run_inner(ctx, &args, environment, cookie_jar).await {
|
||||
Ok(report) => {
|
||||
if let Err(e) = write_reports(&args, &report) {
|
||||
eprintln!("Could not write test report: {e}");
|
||||
return 2;
|
||||
}
|
||||
for request in &report.requests {
|
||||
println!(
|
||||
"{} {}: {}",
|
||||
request.outcome.to_uppercase(),
|
||||
request.request_id,
|
||||
request.reason
|
||||
);
|
||||
for check in &request.checks {
|
||||
println!(
|
||||
" {} {}: {}",
|
||||
check.outcome.to_uppercase(),
|
||||
check.assertion_id,
|
||||
check.reason
|
||||
);
|
||||
}
|
||||
}
|
||||
let count = |outcome| report.requests.iter().filter(|r| r.outcome == outcome).count();
|
||||
println!(
|
||||
"Test summary: {} passed, {} failed, {} errors, {} unchecked, {} skipped",
|
||||
count("passed"),
|
||||
count("failed"),
|
||||
count("error"),
|
||||
count("unchecked"),
|
||||
count("skipped")
|
||||
);
|
||||
report.exit_code
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("Invalid test configuration: {error}");
|
||||
2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_inner(
|
||||
ctx: &CliContext,
|
||||
args: &TestArgs,
|
||||
environment: Option<&str>,
|
||||
cookie_jar: Option<&str>,
|
||||
) -> Result<RunReport, String> {
|
||||
let (workspace_id, requests) = select_requests(ctx, &args.id)?;
|
||||
let jar = resolve_cookie_jar_id(ctx, &workspace_id, cookie_jar)?;
|
||||
if let Some(id) = environment {
|
||||
ctx.db().get_environment_for_workspace(&workspace_id, id).map_err(|_| {
|
||||
"Selected environment is missing or does not belong to this workspace".to_owned()
|
||||
})?;
|
||||
}
|
||||
// Validate the entire selection before sending anything, including unchecked setup requests.
|
||||
let mut enabled = 0;
|
||||
for request in &requests {
|
||||
yaak_assertions::validate(&request.assertions)
|
||||
.map_err(|e| format!("{}: {e}", request.id))?;
|
||||
enabled += request.assertions.checks.iter().filter(|c| c.enabled).count();
|
||||
}
|
||||
if enabled == 0 {
|
||||
return Err("Selected requests have no enabled assertions".into());
|
||||
}
|
||||
if args.json.is_some() && args.json == args.junit {
|
||||
return Err("JSON and JUnit reports require different files".into());
|
||||
}
|
||||
let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false);
|
||||
let cancel_task = tokio::spawn(async move {
|
||||
if tokio::signal::ctrl_c().await.is_ok() {
|
||||
let _ = cancel_tx.send(true);
|
||||
}
|
||||
});
|
||||
let plugin_context = PluginContext::new(Some("cli".into()), Some(workspace_id));
|
||||
let response_dir = ctx.data_dir().join("responses");
|
||||
let mut report = RunReport { version: 1, exit_code: 0, requests: Vec::new() };
|
||||
let mut stopped = false;
|
||||
for request in requests {
|
||||
if stopped || *cancel_rx.borrow() {
|
||||
report.requests.push(skipped(
|
||||
&request.id,
|
||||
&request.assertions,
|
||||
if *cancel_rx.borrow() { "Canceled" } else { "Stopped after failure" },
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let id = request.id.clone();
|
||||
let definition = request.assertions.clone();
|
||||
let result = send_http_request_with_plugins(SendHttpRequestWithPluginsParams {
|
||||
query_manager: ctx.query_manager(),
|
||||
blob_manager: ctx.blob_manager(),
|
||||
request,
|
||||
environment_id: environment,
|
||||
update_source: UpdateSource::Sync,
|
||||
cookie_jar_id: jar.clone(),
|
||||
response_dir: &response_dir,
|
||||
emit_events_to: None,
|
||||
emit_response_body_chunks_to: None,
|
||||
existing_response: None,
|
||||
plugin_manager: ctx.plugin_manager(),
|
||||
encryption_manager: ctx.encryption_manager.clone(),
|
||||
plugin_context: &plugin_context,
|
||||
cancelled_rx: Some(cancel_rx.clone()),
|
||||
connection_manager: ctx.connection_manager(),
|
||||
})
|
||||
.await;
|
||||
let request_result = match result {
|
||||
Ok(result) if result.response.error.is_none() && !*cancel_rx.borrow() => {
|
||||
summarize(&id, &definition, result.response.assertion_results.as_ref())
|
||||
}
|
||||
_ => RequestResult {
|
||||
request_id: id,
|
||||
outcome: "error".into(),
|
||||
reason: if *cancel_rx.borrow() {
|
||||
"Request canceled"
|
||||
} else {
|
||||
"Request did not complete successfully"
|
||||
}
|
||||
.into(),
|
||||
checks: definition
|
||||
.checks
|
||||
.iter()
|
||||
.map(|c| CheckResult {
|
||||
assertion_id: c.id.clone(),
|
||||
outcome: if c.enabled { "error" } else { "skipped" }.into(),
|
||||
reason: if c.enabled {
|
||||
"Request did not complete successfully"
|
||||
} else {
|
||||
"Disabled"
|
||||
}
|
||||
.into(),
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
};
|
||||
if matches!(request_result.outcome.as_str(), "failed" | "error") {
|
||||
report.exit_code = 1;
|
||||
stopped = args.fail_fast;
|
||||
}
|
||||
report.requests.push(request_result);
|
||||
}
|
||||
if *cancel_rx.borrow() {
|
||||
report.exit_code = 130;
|
||||
}
|
||||
cancel_task.abort();
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn summarize(
|
||||
id: &str,
|
||||
definition: &HttpAssertions,
|
||||
report: Option<&AssertionReport>,
|
||||
) -> RequestResult {
|
||||
let checks: Vec<CheckResult> = report
|
||||
.map(|r| {
|
||||
r.results
|
||||
.iter()
|
||||
.map(|c| CheckResult {
|
||||
assertion_id: c.assertion_id.clone(),
|
||||
outcome: c.outcome.clone(),
|
||||
reason: c.reason.clone(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let enabled = definition.checks.iter().filter(|c| c.enabled).count();
|
||||
let (outcome, reason) = if report.is_some_and(|r| r.error.is_some())
|
||||
|| checks.iter().any(|c| matches!(c.outcome.as_str(), "error" | "invalid"))
|
||||
{
|
||||
("error", "Assertions could not be evaluated")
|
||||
} else if checks.iter().any(|c| c.outcome == "failed") {
|
||||
("failed", "Assertions failed")
|
||||
} else if enabled == 0 {
|
||||
("unchecked", "No enabled assertions")
|
||||
} else if checks.iter().filter(|c| c.outcome == "passed").count() != enabled {
|
||||
("error", "Assertion results are incomplete")
|
||||
} else {
|
||||
("passed", "All enabled assertions passed")
|
||||
};
|
||||
RequestResult { request_id: id.into(), outcome: outcome.into(), reason: reason.into(), checks }
|
||||
}
|
||||
|
||||
fn skipped(id: &str, definition: &HttpAssertions, reason: &str) -> RequestResult {
|
||||
RequestResult {
|
||||
request_id: id.into(),
|
||||
outcome: "skipped".into(),
|
||||
reason: reason.into(),
|
||||
checks: definition
|
||||
.checks
|
||||
.iter()
|
||||
.map(|c| CheckResult {
|
||||
assertion_id: c.id.clone(),
|
||||
outcome: "skipped".into(),
|
||||
reason: reason.into(),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn select_requests(ctx: &CliContext, id: &str) -> Result<(String, Vec<HttpRequest>), String> {
|
||||
let db = ctx.db();
|
||||
if let Ok(request) = db.get_any_request(id) {
|
||||
return match request {
|
||||
AnyRequest::HttpRequest(r) => Ok((r.workspace_id.clone(), vec![r])),
|
||||
_ => Err("Assertions currently support HTTP requests only".into()),
|
||||
};
|
||||
}
|
||||
let (workspace_id, parent) = if let Ok(folder) = db.get_folder(id) {
|
||||
(folder.workspace_id, Some(folder.id))
|
||||
} else if let Ok(workspace) = db.get_workspace(id) {
|
||||
(workspace.id, None)
|
||||
} else {
|
||||
return Err("Could not resolve request, folder, or workspace".into());
|
||||
};
|
||||
let folders = db.list_folders(&workspace_id).map_err(|e| e.to_string())?;
|
||||
let requests = db.list_http_requests(&workspace_id).map_err(|e| e.to_string())?;
|
||||
// Refuse mixed protocols rather than silently producing a green partial suite.
|
||||
let mut folder_ids = HashSet::new();
|
||||
let ordered = ordered_requests(&folders, &requests, parent.as_deref(), &mut folder_ids)?;
|
||||
let selected = |folder: Option<&String>| {
|
||||
parent.is_none() || folder.is_some_and(|f| folder_ids.contains(f))
|
||||
};
|
||||
if db
|
||||
.list_grpc_requests(&workspace_id)
|
||||
.map_err(|e| e.to_string())?
|
||||
.iter()
|
||||
.any(|r| selected(r.folder_id.as_ref()))
|
||||
|| db
|
||||
.list_websocket_requests(&workspace_id)
|
||||
.map_err(|e| e.to_string())?
|
||||
.iter()
|
||||
.any(|r| selected(r.folder_id.as_ref()))
|
||||
{
|
||||
return Err(
|
||||
"Selection includes gRPC or WebSocket requests; choose an HTTP request or folder"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if parent.is_none() && ordered.len() != requests.len() {
|
||||
return Err("Request folder hierarchy is invalid".into());
|
||||
}
|
||||
Ok((workspace_id, ordered))
|
||||
}
|
||||
|
||||
fn ordered_requests(
|
||||
folders: &[Folder],
|
||||
requests: &[HttpRequest],
|
||||
parent: Option<&str>,
|
||||
visited: &mut HashSet<String>,
|
||||
) -> Result<Vec<HttpRequest>, String> {
|
||||
if let Some(id) = parent {
|
||||
if !visited.insert(id.to_owned()) {
|
||||
return Err("Request folder hierarchy contains a cycle".into());
|
||||
}
|
||||
}
|
||||
let mut children: Vec<_> = folders
|
||||
.iter()
|
||||
.filter(|f| f.folder_id.as_deref() == parent)
|
||||
.map(|f| (f.sort_priority, f.created_at, f.id.as_str(), None::<&HttpRequest>))
|
||||
.collect();
|
||||
children.extend(
|
||||
requests
|
||||
.iter()
|
||||
.filter(|r| r.folder_id.as_deref() == parent)
|
||||
.map(|r| (r.sort_priority, r.created_at, r.id.as_str(), Some(r))),
|
||||
);
|
||||
children.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)).then(a.2.cmp(b.2)));
|
||||
let mut ordered = Vec::new();
|
||||
for (_, _, id, request) in children {
|
||||
if let Some(request) = request {
|
||||
ordered.push(request.clone());
|
||||
} else {
|
||||
ordered.extend(ordered_requests(folders, requests, Some(id), visited)?);
|
||||
}
|
||||
}
|
||||
Ok(ordered)
|
||||
}
|
||||
|
||||
fn write_reports(args: &TestArgs, report: &RunReport) -> Result<(), String> {
|
||||
if let Some(path) = &args.json {
|
||||
std::fs::write(path, serde_json::to_vec_pretty(report).map_err(|e| e.to_string())?)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
if let Some(path) = &args.junit {
|
||||
std::fs::write(path, junit(report)).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn xml(s: &str) -> String {
|
||||
s.chars()
|
||||
.filter(|&c| {
|
||||
matches!(c, '\t' | '\n' | '\r') || c >= ' ' && c != '\u{fffe}' && c != '\u{ffff}'
|
||||
})
|
||||
.collect::<String>()
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
fn junit(report: &RunReport) -> String {
|
||||
let mut cases = Vec::new();
|
||||
for request in &report.requests {
|
||||
for check in &request.checks {
|
||||
cases.push((
|
||||
request.request_id.as_str(),
|
||||
check.assertion_id.as_str(),
|
||||
check.outcome.as_str(),
|
||||
check.reason.as_str(),
|
||||
));
|
||||
}
|
||||
if request.checks.is_empty()
|
||||
|| request.outcome == "error"
|
||||
&& !request.checks.iter().any(|c| c.outcome == "error" || c.outcome == "invalid")
|
||||
{
|
||||
cases.push((
|
||||
request.request_id.as_str(),
|
||||
"request",
|
||||
if request.outcome == "unchecked" { "skipped" } else { &request.outcome },
|
||||
request.reason.as_str(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let count = |outcome| cases.iter().filter(|c| c.2 == outcome).count();
|
||||
let mut out = format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<testsuite name=\"Yaak\" tests=\"{}\" failures=\"{}\" errors=\"{}\" skipped=\"{}\">\n",
|
||||
cases.len(),
|
||||
count("failed"),
|
||||
count("error") + count("invalid"),
|
||||
count("skipped")
|
||||
);
|
||||
for (request, check, outcome, reason) in cases {
|
||||
out.push_str(&format!(
|
||||
" <testcase classname=\"{}\" name=\"{}\">",
|
||||
xml(request),
|
||||
xml(check)
|
||||
));
|
||||
let tag = match outcome {
|
||||
"failed" => Some("failure"),
|
||||
"error" | "invalid" => Some("error"),
|
||||
"skipped" => Some("skipped"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(tag) = tag {
|
||||
out.push_str(&format!("<{tag} message=\"{}\"/>", xml(reason)));
|
||||
}
|
||||
out.push_str("</testcase>\n");
|
||||
}
|
||||
out.push_str("</testsuite>\n");
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn sidebar_order_interleaves_folders_and_requests() {
|
||||
let folders =
|
||||
vec![Folder { id: "folder".into(), sort_priority: 2.0, ..Default::default() }];
|
||||
let requests = vec![
|
||||
HttpRequest { id: "last".into(), sort_priority: 3.0, ..Default::default() },
|
||||
HttpRequest {
|
||||
id: "nested".into(),
|
||||
folder_id: Some("folder".into()),
|
||||
..Default::default()
|
||||
},
|
||||
HttpRequest { id: "first".into(), sort_priority: 1.0, ..Default::default() },
|
||||
];
|
||||
let ordered = ordered_requests(&folders, &requests, None, &mut HashSet::new()).unwrap();
|
||||
assert_eq!(
|
||||
ordered.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(),
|
||||
vec!["first", "nested", "last"]
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn junit_escapes_attributes_and_counts_all_outcomes() {
|
||||
let report = RunReport {
|
||||
version: 1,
|
||||
exit_code: 1,
|
||||
requests: vec![
|
||||
RequestResult {
|
||||
request_id: "rq_<&\"".into(),
|
||||
outcome: "failed".into(),
|
||||
reason: "Assertions failed".into(),
|
||||
checks: vec![CheckResult {
|
||||
assertion_id: "check".into(),
|
||||
outcome: "failed".into(),
|
||||
reason: "Value did not match".into(),
|
||||
}],
|
||||
},
|
||||
skipped("rq_skip", &HttpAssertions::default(), "Stopped after failure"),
|
||||
],
|
||||
};
|
||||
let xml = junit(&report);
|
||||
assert!(xml.contains("tests=\"2\" failures=\"1\" errors=\"0\" skipped=\"1\""));
|
||||
assert!(xml.contains("classname=\"rq_<&"\""));
|
||||
assert!(xml.contains("<skipped "));
|
||||
}
|
||||
}
|
||||
@@ -84,8 +84,8 @@ fn create(
|
||||
.map_err(|e| format!("Failed to parse workspace create JSON: {e}"))?;
|
||||
|
||||
let created = ctx
|
||||
.db()
|
||||
.upsert_workspace(&workspace, &UpdateSource::Sync)
|
||||
.query_manager()
|
||||
.with_tx(|tx| tx.upsert_workspace(&workspace, &UpdateSource::Sync))
|
||||
.map_err(|e| format!("Failed to create workspace: {e}"))?;
|
||||
println!("Created workspace: {}", created.id);
|
||||
return Ok(());
|
||||
@@ -97,8 +97,8 @@ fn create(
|
||||
|
||||
let workspace = Workspace { name, ..Default::default() };
|
||||
let created = ctx
|
||||
.db()
|
||||
.upsert_workspace(&workspace, &UpdateSource::Sync)
|
||||
.query_manager()
|
||||
.with_tx(|tx| tx.upsert_workspace(&workspace, &UpdateSource::Sync))
|
||||
.map_err(|e| format!("Failed to create workspace: {e}"))?;
|
||||
println!("Created workspace: {}", created.id);
|
||||
Ok(())
|
||||
@@ -115,8 +115,8 @@ fn update(ctx: &CliContext, json: Option<String>, json_input: Option<String>) ->
|
||||
let updated = apply_merge_patch(&existing, &patch, &id, "workspace update")?;
|
||||
|
||||
let saved = ctx
|
||||
.db()
|
||||
.upsert_workspace(&updated, &UpdateSource::Sync)
|
||||
.query_manager()
|
||||
.with_tx(|tx| tx.upsert_workspace(&updated, &UpdateSource::Sync))
|
||||
.map_err(|e| format!("Failed to update workspace: {e}"))?;
|
||||
|
||||
println!("Updated workspace: {}", saved.id);
|
||||
@@ -130,8 +130,10 @@ fn delete(ctx: &CliContext, workspace_id: &str, yes: bool) -> CommandResult {
|
||||
}
|
||||
|
||||
let deleted = ctx
|
||||
.db()
|
||||
.delete_workspace_by_id(workspace_id, &UpdateSource::Sync, ctx.blob_manager())
|
||||
.query_manager()
|
||||
.with_tx(|tx| {
|
||||
tx.delete_workspace_by_id(workspace_id, &UpdateSource::Sync, ctx.blob_manager())
|
||||
})
|
||||
.map_err(|e| format!("Failed to delete workspace: {e}"))?;
|
||||
println!("Deleted workspace: {}", deleted.id);
|
||||
Ok(())
|
||||
|
||||
@@ -51,11 +51,9 @@ impl CliContext {
|
||||
};
|
||||
|
||||
// Guest: the desktop may have this DB open, so only what's safe beside a live session
|
||||
let _ = yaak_lifecycle::on_launch(
|
||||
&yaak_lifecycle::Host::guest(),
|
||||
&query_manager.connect(),
|
||||
&blob_manager,
|
||||
);
|
||||
let _ = query_manager.with_tx(|tx| {
|
||||
yaak_lifecycle::on_launch(&yaak_lifecycle::Host::guest(), tx, &blob_manager)
|
||||
});
|
||||
|
||||
let encryption_manager = Arc::new(EncryptionManager::new(query_manager.clone(), app_id));
|
||||
|
||||
|
||||
@@ -129,6 +129,32 @@ async fn main() {
|
||||
}
|
||||
}
|
||||
}
|
||||
Commands::Test(args) => {
|
||||
let mut context = CliContext::new(data_dir.clone(), app_id);
|
||||
match resolve_send_execution_context(
|
||||
&context,
|
||||
&args.id,
|
||||
environment.as_deref(),
|
||||
cookie_jar.as_deref(),
|
||||
) {
|
||||
Ok(execution_context) => {
|
||||
context.init_plugins(execution_context).await;
|
||||
let exit_code = commands::test::run(
|
||||
&context,
|
||||
args,
|
||||
environment.as_deref(),
|
||||
cookie_jar.as_deref(),
|
||||
)
|
||||
.await;
|
||||
context.shutdown().await;
|
||||
exit_code
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("Error: {error}");
|
||||
2
|
||||
}
|
||||
}
|
||||
}
|
||||
Commands::CookieJar(args) => {
|
||||
let context = CliContext::new(data_dir.clone(), app_id);
|
||||
let exit_code = commands::cookie_jar::run(&context, args);
|
||||
|
||||
@@ -13,7 +13,6 @@ use tokio::task::JoinHandle;
|
||||
use yaak::plugin_events::{
|
||||
GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event,
|
||||
};
|
||||
use yaak_models::render::{render_grpc_request, render_http_request};
|
||||
use yaak::response_body::FileResponseBodyStore;
|
||||
use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
@@ -24,6 +23,7 @@ use yaak_models::models::Environment;
|
||||
use yaak_models::queries::any_request::AnyRequest;
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::render::make_vars_hashmap;
|
||||
use yaak_models::render::{render_grpc_request, render_http_request};
|
||||
use yaak_models::util::UpdateSource;
|
||||
use yaak_plugins::events::{
|
||||
EmptyPayload, ErrorResponse, FormInput, GetCookieValueResponse, InternalEvent,
|
||||
@@ -182,6 +182,31 @@ async fn build_plugin_reply(
|
||||
http_request.workspace_id = workspace_id;
|
||||
}
|
||||
|
||||
let environment_id = if let Some(environment_id) =
|
||||
send_http_request_request.environment_id.as_deref()
|
||||
{
|
||||
if shared_workspace_id.is_some_and(|id| id != http_request.workspace_id) {
|
||||
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
error: "HTTP request does not belong to the selected workspace"
|
||||
.to_string(),
|
||||
}));
|
||||
}
|
||||
match host_context
|
||||
.query_manager
|
||||
.connect()
|
||||
.get_environment_for_workspace(&http_request.workspace_id, environment_id)
|
||||
{
|
||||
Ok(environment) => Some(environment.id),
|
||||
Err(err) => {
|
||||
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
error: err.to_string(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
execution_context.environment_id.clone()
|
||||
};
|
||||
|
||||
let cookie_jar_id =
|
||||
if let Some(cookie_jar_id) = execution_context.cookie_jar_id.clone() {
|
||||
Some(cookie_jar_id)
|
||||
@@ -211,7 +236,7 @@ async fn build_plugin_reply(
|
||||
query_manager: &host_context.query_manager,
|
||||
blob_manager: &host_context.blob_manager,
|
||||
request: http_request,
|
||||
environment_id: execution_context.environment_id.as_deref(),
|
||||
environment_id: environment_id.as_deref(),
|
||||
update_source: UpdateSource::Plugin,
|
||||
cookie_jar_id,
|
||||
response_dir: &host_context.response_dir,
|
||||
@@ -1003,3 +1028,184 @@ fn prompt_label_for_base(base: &yaak_plugins::events::FormInputBase) -> String {
|
||||
}
|
||||
base.name.clone()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod environment_tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::time::{Duration, timeout};
|
||||
use yaak_models::models::{EnvironmentVariable, HttpRequest, HttpRequestHeader, Workspace};
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_send_uses_override_and_fallback_and_rejects_invalid_ids_before_sending() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let (received_tx, mut received_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let server = tokio::spawn(async move {
|
||||
loop {
|
||||
let (mut socket, _) = listener.accept().await.unwrap();
|
||||
let mut request = Vec::new();
|
||||
loop {
|
||||
let mut chunk = [0u8; 1024];
|
||||
let n = socket.read(&mut chunk).await.unwrap();
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
request.extend_from_slice(&chunk[..n]);
|
||||
if request.windows(4).any(|w| w == b"\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let request = String::from_utf8(request).unwrap();
|
||||
eprintln!("Local HTTP test received on {address}:\n{request}");
|
||||
received_tx.send(request).unwrap();
|
||||
socket
|
||||
.write_all(
|
||||
b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
});
|
||||
let (query_manager, blob_manager, _rx) = yaak_models::init_standalone(
|
||||
&dir.path().join("db.sqlite"),
|
||||
&dir.path().join("blobs.sqlite"),
|
||||
)
|
||||
.unwrap();
|
||||
let (base, a, b, request) = query_manager
|
||||
.with_tx(|db| {
|
||||
let source = &UpdateSource::Background;
|
||||
// Fresh databases no longer come with a workspace, so make one
|
||||
let workspace = db.upsert_workspace(
|
||||
&Workspace { name: "Test".into(), ..Default::default() },
|
||||
source,
|
||||
)?;
|
||||
let vars = |value: &str| {
|
||||
vec![EnvironmentVariable {
|
||||
enabled: true,
|
||||
name: "marker".into(),
|
||||
value: value.into(),
|
||||
..Default::default()
|
||||
}]
|
||||
};
|
||||
let base = db.ensure_base_environment(&workspace.id)?;
|
||||
let base = db.upsert_environment(
|
||||
&Environment { variables: vars("global"), ..base },
|
||||
source,
|
||||
)?;
|
||||
let sub = |name: &str| {
|
||||
db.upsert_environment(
|
||||
&Environment {
|
||||
name: name.into(),
|
||||
workspace_id: workspace.id.clone(),
|
||||
parent_model: "environment".into(),
|
||||
parent_id: Some(base.id.clone()),
|
||||
variables: vars(name),
|
||||
..Default::default()
|
||||
},
|
||||
source,
|
||||
)
|
||||
};
|
||||
let a = sub("a")?;
|
||||
let b = sub("b")?;
|
||||
let request = db.upsert_http_request(
|
||||
&HttpRequest {
|
||||
workspace_id: workspace.id,
|
||||
url: format!("http://{address}/echo"),
|
||||
method: "GET".into(),
|
||||
headers: vec![HttpRequestHeader {
|
||||
enabled: true,
|
||||
name: "X-Environment".into(),
|
||||
value: "${[ marker ]}".into(),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
source,
|
||||
)?;
|
||||
Ok::<_, yaak_models::error::Error>((base, a, b, request))
|
||||
})
|
||||
.unwrap();
|
||||
let plugin_dir = dir.path().join("plugins");
|
||||
std::fs::create_dir_all(&plugin_dir).unwrap();
|
||||
let plugin_manager = Arc::new(
|
||||
PluginManager::new(
|
||||
plugin_dir.clone(),
|
||||
plugin_dir,
|
||||
PathBuf::from("node"),
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../crates-tauri/yaak-app-client/vendored/plugin-runtime/index.cjs"),
|
||||
&query_manager,
|
||||
&PluginContext::new_empty(),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
let host = CliHostContext {
|
||||
encryption_manager: Arc::new(EncryptionManager::new(
|
||||
query_manager.clone(),
|
||||
"yaak-test",
|
||||
)),
|
||||
query_manager,
|
||||
blob_manager,
|
||||
plugin_manager: plugin_manager.clone(),
|
||||
connection_manager: Arc::new(HttpConnectionManager::new()),
|
||||
response_dir: dir.path().join("responses"),
|
||||
execution_context: CliExecutionContext {
|
||||
workspace_id: Some(base.workspace_id.clone()),
|
||||
environment_id: Some(a.id.clone()),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
std::fs::create_dir_all(&host.response_dir).unwrap();
|
||||
let event = |environment_id: Option<&str>| -> InternalEvent {
|
||||
let mut payload =
|
||||
json!({ "type": "send_http_request_request", "httpRequest": request });
|
||||
if let Some(id) = environment_id {
|
||||
payload["environmentId"] = json!(id);
|
||||
}
|
||||
serde_json::from_value(json!({
|
||||
"id": "test", "pluginRefId": "test", "pluginName": "test", "replyId": null,
|
||||
"context": PluginContext::new_empty(), "payload": payload
|
||||
}))
|
||||
.unwrap()
|
||||
};
|
||||
for (id, marker) in [
|
||||
(Some(b.id.as_str()), "b"),
|
||||
(None, "a"),
|
||||
(Some(base.id.as_str()), "global"),
|
||||
] {
|
||||
let reply =
|
||||
timeout(Duration::from_secs(10), build_plugin_reply(&host, &event(id), "test"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(reply, Some(InternalEventPayload::SendHttpRequestResponse(ref r)) if r.http_response.status == 200),
|
||||
"{reply:?}"
|
||||
);
|
||||
let received = received_rx.recv().await.unwrap().to_lowercase();
|
||||
assert!(received.contains(&format!("x-environment: {marker}\r\n")), "{received}");
|
||||
assert_eq!(host.execution_context.environment_id.as_deref(), Some(a.id.as_str()));
|
||||
}
|
||||
for id in ["", "ev_missing"] {
|
||||
let reply = build_plugin_reply(&host, &event(Some(id)), "test").await;
|
||||
assert!(matches!(reply, Some(InternalEventPayload::ErrorResponse(_))), "{reply:?}");
|
||||
}
|
||||
assert!(received_rx.try_recv().is_err());
|
||||
assert_eq!(
|
||||
host.query_manager
|
||||
.connect()
|
||||
.list_http_responses_for_request(&request.id, None)
|
||||
.unwrap()
|
||||
.len(),
|
||||
3
|
||||
);
|
||||
plugin_manager.terminate().await;
|
||||
server.abort();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
mod common;
|
||||
|
||||
use common::http_server::TestHttpServer;
|
||||
use common::{cli_cmd, query_manager, seed_workspace};
|
||||
use predicates::str::contains;
|
||||
use tempfile::TempDir;
|
||||
use yaak_models::models::{HttpAssertion, HttpAssertions, HttpRequest};
|
||||
use yaak_models::util::UpdateSource;
|
||||
|
||||
fn request(id: &str, url: &str, expected: &str) -> HttpRequest {
|
||||
HttpRequest {
|
||||
id: id.into(),
|
||||
workspace_id: "wk_test".into(),
|
||||
url: url.into(),
|
||||
name: "SECRET_REQUEST_NAME".into(),
|
||||
assertions: HttpAssertions {
|
||||
version: 1,
|
||||
checks: vec![HttpAssertion {
|
||||
id: "assertion".into(),
|
||||
selector: "$.token".into(),
|
||||
expected: expected.into(),
|
||||
..Default::default()
|
||||
}],
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
fn save(dir: &std::path::Path, request: &HttpRequest) {
|
||||
query_manager(dir).with_tx(|tx| tx.upsert_http_request(request, &UpdateSource::Sync)).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sends_assertions_persists_snapshots_and_writes_safe_reports() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
seed_workspace(dir.path(), "wk_test");
|
||||
let server = TestHttpServer::spawn_ok(r#"{"token":"SECRET_TOKEN"}"#);
|
||||
let mut req = request("rq_test", &server.url, "SECRET_TOKEN");
|
||||
save(dir.path(), &req);
|
||||
cli_cmd(dir.path())
|
||||
.args(["test", "rq_test", "--environment", "missing"])
|
||||
.assert()
|
||||
.code(2)
|
||||
.stderr(contains("Selected environment"));
|
||||
assert!(
|
||||
query_manager(dir.path())
|
||||
.connect()
|
||||
.list_http_responses("wk_test", None)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
let json = dir.path().join("results.json");
|
||||
let junit = dir.path().join("results.xml");
|
||||
let output = cli_cmd(dir.path())
|
||||
.args(["test", "rq_test", "--json"])
|
||||
.arg(&json)
|
||||
.arg("--junit")
|
||||
.arg(&junit)
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("1 passed, 0 failed"))
|
||||
.get_output()
|
||||
.clone();
|
||||
for text in [
|
||||
String::from_utf8_lossy(&output.stdout).into_owned(),
|
||||
std::fs::read_to_string(&json).unwrap(),
|
||||
std::fs::read_to_string(&junit).unwrap(),
|
||||
] {
|
||||
assert!(!text.contains("SECRET"));
|
||||
assert!(!text.contains(&server.url));
|
||||
assert!(!text.contains("$.token"));
|
||||
}
|
||||
let report: serde_json::Value = serde_json::from_slice(&std::fs::read(json).unwrap()).unwrap();
|
||||
assert_eq!(report["requests"][0]["checks"][0]["outcome"], "passed");
|
||||
let manager = query_manager(dir.path());
|
||||
let history = manager.connect().list_http_responses("wk_test", None).unwrap();
|
||||
assert_eq!(history.len(), 1);
|
||||
let original_id = history[0].id.clone();
|
||||
assert_eq!(history[0].assertion_results.as_ref().unwrap().definition, req.assertions);
|
||||
req.assertions.checks[0].expected = "DIFFERENT_SECRET".into();
|
||||
save(dir.path(), &req);
|
||||
assert_eq!(
|
||||
manager
|
||||
.connect()
|
||||
.get_http_response(&original_id)
|
||||
.unwrap()
|
||||
.assertion_results
|
||||
.unwrap()
|
||||
.definition
|
||||
.checks[0]
|
||||
.expected,
|
||||
"SECRET_TOKEN"
|
||||
);
|
||||
cli_cmd(dir.path()).args(["test", "rq_test"]).assert().code(1).stdout(contains("1 failed"));
|
||||
// Ordinary send still prints the response body and succeeds even when a check fails.
|
||||
cli_cmd(dir.path())
|
||||
.args(["send", "rq_test"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("SECRET_TOKEN"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_suite_and_empty_suite_do_not_send_and_fail_fast_records_skips() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
seed_workspace(dir.path(), "wk_test");
|
||||
let server = TestHttpServer::spawn_ok(r#"{"token":"actual"}"#);
|
||||
let mut first = request("rq_a", &server.url, "different");
|
||||
first.sort_priority = 0.0;
|
||||
let mut second = request("rq_b", &server.url, "actual");
|
||||
second.sort_priority = 1.0;
|
||||
second.assertions.checks[0].selector = "$[".into();
|
||||
save(dir.path(), &first);
|
||||
save(dir.path(), &second);
|
||||
cli_cmd(dir.path())
|
||||
.args(["test", "wk_test"])
|
||||
.assert()
|
||||
.code(2)
|
||||
.stderr(contains("valid JSONPath"));
|
||||
assert!(
|
||||
query_manager(dir.path())
|
||||
.connect()
|
||||
.list_http_responses("wk_test", None)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
second.assertions.checks[0].selector = "token".into();
|
||||
save(dir.path(), &second);
|
||||
let json = dir.path().join("results.json");
|
||||
cli_cmd(dir.path())
|
||||
.args(["test", "wk_test", "--fail-fast", "--json"])
|
||||
.arg(&json)
|
||||
.assert()
|
||||
.code(1)
|
||||
.stdout(contains("1 failed, 0 errors, 0 unchecked, 1 skipped"));
|
||||
let report: serde_json::Value = serde_json::from_slice(&std::fs::read(json).unwrap()).unwrap();
|
||||
assert_eq!(report["requests"][1]["requestId"], "rq_b");
|
||||
assert_eq!(report["requests"][1]["outcome"], "skipped");
|
||||
assert_eq!(
|
||||
query_manager(dir.path()).connect().list_http_responses("wk_test", None).unwrap().len(),
|
||||
1
|
||||
);
|
||||
first.assertions.checks.clear();
|
||||
save(dir.path(), &first);
|
||||
cli_cmd(dir.path())
|
||||
.args(["test", "rq_a"])
|
||||
.assert()
|
||||
.code(2)
|
||||
.stderr(contains("no enabled assertions"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_errors_are_distinct_from_failed_comparisons() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
seed_workspace(dir.path(), "wk_test");
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let url = format!("http://{}", listener.local_addr().unwrap());
|
||||
drop(listener);
|
||||
save(dir.path(), &request("rq_error", &url, "SECRET"));
|
||||
cli_cmd(dir.path())
|
||||
.args(["test", "rq_error"])
|
||||
.assert()
|
||||
.code(1)
|
||||
.stdout(contains("0 failed, 1 errors"));
|
||||
}
|
||||
@@ -27,6 +27,10 @@ impl TestHttpServer {
|
||||
while !shutdown_signal.load(Ordering::Relaxed) {
|
||||
match listener.accept() {
|
||||
Ok((mut stream, _)) => {
|
||||
// macOS and the BSDs hand back accepted sockets carrying the
|
||||
// listener's non-blocking flag, which leaves the read timeout
|
||||
// below with nothing to govern.
|
||||
let _ = stream.set_nonblocking(false);
|
||||
let _ = stream.set_read_timeout(Some(Duration::from_secs(1)));
|
||||
let mut request = Vec::new();
|
||||
let mut request_buf = [0u8; 4096];
|
||||
|
||||
@@ -40,8 +40,7 @@ pub fn seed_workspace(data_dir: &Path, workspace_id: &str) {
|
||||
};
|
||||
|
||||
query_manager(data_dir)
|
||||
.connect()
|
||||
.upsert_workspace(&workspace, &UpdateSource::Sync)
|
||||
.with_tx(|tx| tx.upsert_workspace(&workspace, &UpdateSource::Sync))
|
||||
.expect("Failed to seed workspace");
|
||||
}
|
||||
|
||||
@@ -56,8 +55,7 @@ pub fn seed_request(data_dir: &Path, workspace_id: &str, request_id: &str) {
|
||||
};
|
||||
|
||||
query_manager(data_dir)
|
||||
.connect()
|
||||
.upsert_http_request(&request, &UpdateSource::Sync)
|
||||
.with_tx(|tx| tx.upsert_http_request(&request, &UpdateSource::Sync))
|
||||
.expect("Failed to seed request");
|
||||
}
|
||||
|
||||
@@ -70,8 +68,7 @@ pub fn seed_folder(data_dir: &Path, workspace_id: &str, folder_id: &str) {
|
||||
};
|
||||
|
||||
query_manager(data_dir)
|
||||
.connect()
|
||||
.upsert_folder(&folder, &UpdateSource::Sync)
|
||||
.with_tx(|tx| tx.upsert_folder(&folder, &UpdateSource::Sync))
|
||||
.expect("Failed to seed folder");
|
||||
}
|
||||
|
||||
@@ -85,8 +82,7 @@ pub fn seed_grpc_request(data_dir: &Path, workspace_id: &str, request_id: &str)
|
||||
};
|
||||
|
||||
query_manager(data_dir)
|
||||
.connect()
|
||||
.upsert_grpc_request(&request, &UpdateSource::Sync)
|
||||
.with_tx(|tx| tx.upsert_grpc_request(&request, &UpdateSource::Sync))
|
||||
.expect("Failed to seed gRPC request");
|
||||
}
|
||||
|
||||
@@ -100,7 +96,6 @@ pub fn seed_websocket_request(data_dir: &Path, workspace_id: &str, request_id: &
|
||||
};
|
||||
|
||||
query_manager(data_dir)
|
||||
.connect()
|
||||
.upsert_websocket_request(&request, &UpdateSource::Sync)
|
||||
.with_tx(|tx| tx.upsert_websocket_request(&request, &UpdateSource::Sync))
|
||||
.expect("Failed to seed WebSocket request");
|
||||
}
|
||||
@@ -4,6 +4,7 @@ use common::{cli_cmd, parse_created_id, query_manager, seed_request};
|
||||
use predicates::str::contains;
|
||||
use serde_json::Value;
|
||||
use tempfile::TempDir;
|
||||
use yaak_models::util::UpdateSource;
|
||||
|
||||
#[test]
|
||||
fn export_writes_yaak_workspace_file() {
|
||||
@@ -160,8 +161,7 @@ fn import_postman_environment_uses_workspace_id() {
|
||||
|
||||
let query_manager = query_manager(data_dir);
|
||||
let db = query_manager.connect();
|
||||
let environments =
|
||||
db.list_environments_ensure_base(&workspace_id).expect("list imported environments");
|
||||
let environments = db.list_environments(&workspace_id).expect("list imported environments");
|
||||
|
||||
let imported_environment =
|
||||
environments.iter().find(|e| e.name == "Local").expect("postman environment imported");
|
||||
@@ -209,7 +209,10 @@ fn re_import_merges_into_linked_workspace() {
|
||||
],
|
||||
);
|
||||
cli_cmd(data_dir)
|
||||
.args(["import", import_path.to_str().expect("import path is utf-8")])
|
||||
.args([
|
||||
"import",
|
||||
import_path.to_str().expect("import path is utf-8"),
|
||||
])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Imported 1 workspace, 2 HTTP requests"));
|
||||
@@ -257,3 +260,65 @@ fn re_import_merges_into_linked_workspace() {
|
||||
assert!(requests.iter().any(|r| r.name == "Request B"), "removal must not auto-apply");
|
||||
assert!(requests.iter().any(|r| r.name == "Request C"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn re_import_leaves_deleted_resources_alone() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let data_dir = temp_dir.path();
|
||||
let import_path = temp_dir.path().join("linked.json");
|
||||
|
||||
write_linked_fixture(
|
||||
&import_path,
|
||||
&[
|
||||
("req_a", "Request A", "https://example.com/a"),
|
||||
("req_b", "Request B", "https://example.com/b"),
|
||||
],
|
||||
);
|
||||
cli_cmd(data_dir)
|
||||
.args([
|
||||
"import",
|
||||
import_path.to_str().expect("import path is utf-8"),
|
||||
])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let workspace_id = {
|
||||
let query_manager = query_manager(data_dir);
|
||||
let db = query_manager.connect();
|
||||
let workspace_id = db
|
||||
.list_workspaces()
|
||||
.expect("list workspaces")
|
||||
.into_iter()
|
||||
.find(|w| w.name == "Linked Workspace")
|
||||
.expect("workspace imported")
|
||||
.id;
|
||||
let request_b = db
|
||||
.list_http_requests(&workspace_id)
|
||||
.expect("list requests")
|
||||
.into_iter()
|
||||
.find(|r| r.name == "Request B")
|
||||
.expect("request B imported");
|
||||
drop(db);
|
||||
query_manager
|
||||
.with_tx(|tx| tx.delete_http_request_by_id(&request_b.id, &UpdateSource::Sync))
|
||||
.expect("delete request B");
|
||||
workspace_id
|
||||
};
|
||||
|
||||
cli_cmd(data_dir)
|
||||
.args([
|
||||
"import",
|
||||
import_path.to_str().expect("import path is utf-8"),
|
||||
"--workspace-id",
|
||||
&workspace_id,
|
||||
])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Skipped 1 ignored"));
|
||||
|
||||
let query_manager = query_manager(data_dir);
|
||||
let requests =
|
||||
query_manager.connect().list_http_requests(&workspace_id).expect("list requests");
|
||||
assert_eq!(requests.len(), 1, "a deleted request must not come back: {requests:?}");
|
||||
assert_eq!(requests[0].name, "Request A");
|
||||
}
|
||||
@@ -25,8 +25,7 @@ fn top_level_send_folder_sends_http_requests_and_prints_summary() {
|
||||
..Default::default()
|
||||
};
|
||||
query_manager(data_dir)
|
||||
.connect()
|
||||
.upsert_http_request(&request, &UpdateSource::Sync)
|
||||
.with_tx(|tx| tx.upsert_http_request(&request, &UpdateSource::Sync))
|
||||
.expect("Failed to seed folder request");
|
||||
|
||||
cli_cmd(data_dir)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "yaak-playground"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
description = "A small, disposable API for trying Yaak against (yaak.run)"
|
||||
|
||||
[[bin]]
|
||||
name = "yaak-playground"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
axum = "0.7"
|
||||
bytes = "1.11.1"
|
||||
clap = { version = "4.5", features = ["derive", "env"] }
|
||||
env_logger = "0.11"
|
||||
log = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "signal", "net"] }
|
||||
tower-http = { version = "0.6", features = ["cors"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
@@ -0,0 +1,21 @@
|
||||
# yaak-playground
|
||||
|
||||
The API behind [yaak.run](https://yaak.run), for trying Yaak without bringing your own.
|
||||
|
||||
- Users and todos are fixed sample data.
|
||||
- Posts can be created, changed, and deleted. Each client IP gets its own copy, which goes back
|
||||
to the sample data `YAAK_PLAYGROUND_RESET_AFTER_SECS` after its first write.
|
||||
- `POST /auth/token` returns a bearer token for `GET /auth/me`. `POST /auth/session` sets a
|
||||
session cookie for `GET /auth/session`. Any username with the password `yaak` logs in.
|
||||
- `GET /` lists every endpoint and `GET /openapi.json` describes them.
|
||||
|
||||
Nothing is written to disk, so a restart resets everything.
|
||||
|
||||
```sh
|
||||
cargo run -p yaak-playground # http://127.0.0.1:9228
|
||||
```
|
||||
|
||||
Run `yaak-playground --help` for the limits. Behind a load balancer that sets
|
||||
`X-Forwarded-For`, set `YAAK_PLAYGROUND_TRUST_FORWARDED_FOR=true`.
|
||||
|
||||
The image is `Dockerfile.playground` at the repo root.
|
||||
Loaded 100 of 392 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user