mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-14 20:53:43 +01:00
Compare commits
105
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
d2d4b80a09 | ||
|
|
d461c982ec | ||
|
|
8eebee460e | ||
|
|
81a2a5d955 | ||
|
|
e63a87718c | ||
|
|
222ea9cb83 | ||
|
|
360c098b09 | ||
|
|
fce1039bac | ||
|
|
f18f93d613 | ||
|
|
661a384bed | ||
|
|
50cccf1d25 | ||
|
|
0aae516f3a | ||
|
|
b7b8ae5f94 | ||
|
|
6777003b70 | ||
|
|
426c6d5eb6 | ||
|
|
068afe325e | ||
|
|
cef1129d4b | ||
|
|
9a7bcf73bb | ||
|
|
aa76d501f0 | ||
|
|
ce8cb5e7bc | ||
|
|
66c13d7e13 | ||
|
|
388dd8815f | ||
|
|
cb295b7102 | ||
|
|
4e835cf7e8 | ||
|
|
2350689d7a | ||
|
|
4eebd606ef | ||
|
|
c4f96f3f11 | ||
|
|
71c217d3f0 | ||
|
|
d89831a84d | ||
|
|
3332ae263f | ||
|
|
df1fd864b2 | ||
|
|
a2d54ca774 | ||
|
|
36fec8b005 | ||
|
|
115615d994 | ||
|
|
538f782068 | ||
|
|
3f202ff664 | ||
|
|
131b7e5ab1 | ||
|
|
69083918f9 | ||
|
|
4b2dcf9a1a | ||
|
|
569f552d79 | ||
|
|
33f32cccf6 | ||
|
|
7ca772347f | ||
|
|
b89c448345 | ||
|
|
2021df112a |
No files matched your search
@@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
**/node_modules
|
||||
dist
|
||||
**/dist
|
||||
target
|
||||
**/target
|
||||
.claude
|
||||
vendored
|
||||
**/vendored
|
||||
*.log
|
||||
.git
|
||||
@@ -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}`);
|
||||
@@ -31,3 +31,7 @@ jobs:
|
||||
run: vp test
|
||||
- name: Run Rust Tests
|
||||
run: cargo test --all --features yaak-app-client/wry
|
||||
- name: OpenAPI import round-trip
|
||||
run: |
|
||||
cargo build -p yaak-cli
|
||||
node plugins/importer-openapi/tests/roundtrip.mjs
|
||||
@@ -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
|
||||
@@ -103,7 +103,7 @@ jobs:
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y cmake ninja-build libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libnss3 patchelf xdg-utils
|
||||
# crates/yaak-web compiles SQLite to wasm via sqlite-wasm-rs, whose C shim
|
||||
# crates/yaak-wasm compiles SQLite to wasm via sqlite-wasm-rs, whose C shim
|
||||
# uses C23 [[noreturn]] and expects a freestanding wasm32 target. Ubuntu
|
||||
# 22.04 ships only clang <=15: 14 rejects the attribute, and 15 falls
|
||||
# through to host glibc headers ("bits/libc-header-start.h" not found).
|
||||
@@ -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
|
||||
@@ -0,0 +1,123 @@
|
||||
name: Release web image
|
||||
|
||||
# Builds ghcr.io/mountain-loop/yaak-web: the browser client and the server that serves it.
|
||||
# One image per architecture on its own native runner (emulating a Rust release build is hours),
|
||||
# joined into one multi-arch tag at the end.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: [v*]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: Version to publish, without the v (e.g. 2026.2.0). Empty publishes main and sha tags only.
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
IMAGE: ghcr.io/mountain-loop/yaak-web
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: github.repository == 'mountain-loop/yaak'
|
||||
name: Build ${{ matrix.platform }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-22.04
|
||||
arch: amd64
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-22.04-arm
|
||||
arch: arm64
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.web
|
||||
platforms: ${{ matrix.platform }}
|
||||
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p "${{ runner.temp }}/digests"
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digest-${{ matrix.arch }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
publish:
|
||||
name: Publish manifest
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digest-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# `latest` follows a release tag, and a manual run that names a version — the way to
|
||||
# publish before the first release. A prerelease (v2026.2.1-beta.1) never takes it.
|
||||
- name: Tags
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.IMAGE }}
|
||||
flavor: latest=false
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=raw,value=${{ inputs.version }},enable=${{ inputs.version != '' }}
|
||||
type=raw,value=latest,enable=${{ inputs.version != '' || (github.event_name == 'push' && !contains(github.ref_name, '-')) }}
|
||||
type=ref,event=branch
|
||||
type=sha,format=short
|
||||
|
||||
- name: Create and push the manifest
|
||||
working-directory: ${{ runner.temp }}/digests
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.IMAGE }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect
|
||||
run: docker buildx imagetools inspect ${{ env.IMAGE }}:${{ steps.meta.outputs.version }}
|
||||
@@ -1,3 +0,0 @@
|
||||
**/bindings/**
|
||||
**/routeTree.gen.ts
|
||||
crates/yaak-templates/pkg/**
|
||||
Generated
+986
-884
File diff suppressed because it is too large.
Load diff
+14
-2
@@ -14,6 +14,7 @@ members = [
|
||||
"crates/yaak-git",
|
||||
"crates/yaak-grpc",
|
||||
"crates/yaak-http",
|
||||
"crates/yaak-lifecycle",
|
||||
"crates/yaak-models",
|
||||
"crates/yaak-plugins",
|
||||
"crates/yaak-sse",
|
||||
@@ -21,11 +22,14 @@ members = [
|
||||
"crates/yaak-templates",
|
||||
"crates/yaak-tls",
|
||||
"crates/yaak-ws",
|
||||
"crates/yaak-web",
|
||||
"crates/yaak-wasm",
|
||||
"crates/yaak-api",
|
||||
"crates/yaak-proxy",
|
||||
# Proxy-specific crates
|
||||
"crates-proxy/yaak-proxy-lib",
|
||||
# Server crates (the browser tier's hosted send executor)
|
||||
"crates-server/yaak-web",
|
||||
"crates-server/yaak-playground",
|
||||
# CLI crates
|
||||
"crates-cli/yaak-cli",
|
||||
# Tauri-specific crates
|
||||
@@ -44,7 +48,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"] }
|
||||
@@ -77,6 +88,7 @@ yaak-crypto = { path = "crates/yaak-crypto" }
|
||||
yaak-git = { path = "crates/yaak-git" }
|
||||
yaak-grpc = { path = "crates/yaak-grpc" }
|
||||
yaak-http = { path = "crates/yaak-http" }
|
||||
yaak-lifecycle = { path = "crates/yaak-lifecycle" }
|
||||
yaak-models = { path = "crates/yaak-models" }
|
||||
yaak-plugins = { path = "crates/yaak-plugins" }
|
||||
yaak-sse = { path = "crates/yaak-sse" }
|
||||
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,49 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# Yaak in a browser, whole: the web client and the server that executes its sends, in one
|
||||
# image serving both from one origin.
|
||||
#
|
||||
# docker run -p 8080:8080 ghcr.io/mountain-loop/yaak-web
|
||||
#
|
||||
# See crates-server/yaak-web/README.md for the knobs.
|
||||
|
||||
FROM node:22-slim AS web
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git python3 make g++ ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
COPY . .
|
||||
# `npm ci` runs a prepare hook (`vp config`) that shells out to git, and there is no .git in
|
||||
# the build context — it is ignored, and in a worktree it is a pointer file anyway.
|
||||
RUN git init -q && git add -A \
|
||||
&& git -c user.email=build@yaak.app -c user.name=build commit -qm build
|
||||
# Empty means the tab posts sends to its own origin, which is what this image serves. Set it
|
||||
# only to build a bundle for a deployment whose server lives somewhere else.
|
||||
ARG VITE_YAAK_WEB_URL=""
|
||||
ENV VITE_YAAK_WEB_URL=$VITE_YAAK_WEB_URL
|
||||
ENV YAAK_TARGET=web
|
||||
# crates/yaak-wasm's wasm package is committed; rebuilding it needs a clang with a WebAssembly
|
||||
# backend, which this image has no reason to carry.
|
||||
ENV SKIP_WASM_BUILD=1
|
||||
RUN npm ci
|
||||
RUN node_modules/.bin/vp -C apps/yaak-client build
|
||||
|
||||
FROM rust:1-bookworm AS server
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
pkg-config libssl-dev protobuf-compiler && rm -rf /var/lib/apt/lists/*
|
||||
COPY . .
|
||||
RUN cargo build --release -p yaak-web
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates libssl3 && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=server /app/target/release/yaak-web /usr/local/bin/yaak-web
|
||||
COPY --from=web /app/dist/apps/yaak-client /srv
|
||||
# 0.0.0.0 so the port is reachable from outside the container. PORT is the variable a
|
||||
# platform-as-a-service assigns, so a deploy that sets it needs nothing else changed.
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=8080
|
||||
EXPOSE 8080
|
||||
USER nobody
|
||||
# Overriding the command (dropping --serve) leaves the stateless send executor:
|
||||
# docker run ghcr.io/mountain-loop/yaak-web yaak-web
|
||||
CMD ["yaak-web", "--serve", "/srv"]
|
||||
@@ -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,33 +1,42 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import type { SettingsTab } from "../components/Settings/Settings";
|
||||
import type { SettingsTab, SettingsTabWithSubtab } from "../components/Settings/Settings";
|
||||
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
||||
import { createFastMutation } from "../hooks/useFastMutation";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import { jotaiStore } from "../lib/jotai";
|
||||
import { router } from "../lib/router";
|
||||
import { rpc } from "../lib/rpc";
|
||||
|
||||
// Allow tab with optional subtab (e.g., "plugins:installed")
|
||||
type SettingsTabWithSubtab = SettingsTab | `${SettingsTab}:${string}` | null;
|
||||
|
||||
export const openSettings = createFastMutation<void, string, SettingsTabWithSubtab>({
|
||||
export const openSettings = createFastMutation<void, string, SettingsTabWithSubtab | null>({
|
||||
mutationKey: ["open_settings"],
|
||||
mutationFn: async (tab) => {
|
||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
||||
if (workspaceId == null) return;
|
||||
|
||||
const to = "/workspaces/$workspaceId/settings" as const;
|
||||
const params = { workspaceId };
|
||||
const search = { tab: (tab ?? undefined) as SettingsTab | undefined };
|
||||
|
||||
// Settings is its own window where the host has windows to give. Where it
|
||||
// doesn't — a browser tab — the same route opens in place, which is the
|
||||
// whole difference: it is already a route, not a separate app.
|
||||
// doesn't — a browser tab — it's a dialog like any other, so opening it
|
||||
// doesn't take you away from the request you were working on.
|
||||
if (!platform.capabilities.multiWindow) {
|
||||
await router.navigate({ to, params, search });
|
||||
// Imported here so Settings stays out of the startup bundle, the way the
|
||||
// route that renders it on desktop already keeps it
|
||||
const { default: Settings } = await import("../components/Settings/Settings");
|
||||
showDialog({
|
||||
id: "settings",
|
||||
size: "md",
|
||||
className: "h-[calc(100vh-5rem)] max-h-150! overflow-hidden",
|
||||
noPadding: true,
|
||||
noScroll: true,
|
||||
// Keyed so opening a specific tab while the dialog is already up moves to it
|
||||
render: ({ hide }) => <Settings key={tab ?? "general"} tab={tab} hide={hide} />,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const location = router.buildLocation({ to, params, search });
|
||||
const location = router.buildLocation({
|
||||
to: "/workspaces/$workspaceId/settings",
|
||||
params: { workspaceId },
|
||||
search: { tab: (tab ?? undefined) as SettingsTab | undefined },
|
||||
});
|
||||
|
||||
await rpc("cmd_new_child_window", {
|
||||
url: location.href,
|
||||
|
||||
@@ -67,7 +67,14 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo
|
||||
)}
|
||||
// If no environments, the button simply opens the dialog.
|
||||
// NOTE: We don't create a new button because we want to reuse the hotkey from the menu items
|
||||
onClick={subEnvironments.length === 0 ? () => editEnvironment(null) : undefined}
|
||||
onClick={
|
||||
subEnvironments.length === 0
|
||||
? (event) => {
|
||||
event.preventDefault();
|
||||
editEnvironment(null);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
{...buttonProps}
|
||||
>
|
||||
<EnvironmentColorIndicator environment={activeEnvironment ?? null} />
|
||||
|
||||
@@ -65,21 +65,22 @@ 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]);
|
||||
|
||||
@@ -5,10 +5,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 +27,10 @@ export function GlobalHooks() {
|
||||
useActiveWorkspaceChangedToast();
|
||||
useSubscribeHotKeys();
|
||||
|
||||
usePlatformEvent("show_home", () => {
|
||||
fireAndForget(router.navigate({ to: "/", search: { home: true } }));
|
||||
});
|
||||
|
||||
useHotKey(
|
||||
"request.rename",
|
||||
async () => {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { linter } from "@codemirror/lint";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { jsoncLanguage } from "@shopify/lang-jsonc";
|
||||
import type { GrpcRequest } from "@yaakapp-internal/models";
|
||||
import { FormattedError, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { type GrpcRequest, patchModel } from "@yaakapp-internal/models";
|
||||
import { Banner, FormattedError, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import {
|
||||
handleRefresh,
|
||||
jsonCompletion,
|
||||
@@ -11,12 +10,20 @@ import {
|
||||
stateExtensions,
|
||||
updateSchema,
|
||||
} from "codemirror-json-schema";
|
||||
import type { JSONSchema7 } from "json-schema";
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { ReflectResponseService } from "../hooks/useGrpc";
|
||||
import { wasUpdatedExternally } from "../hooks/useRequestUpdateKey";
|
||||
import { showAlert } from "../lib/alert";
|
||||
import { showConfirm } from "../lib/confirm";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import type { JsonSchema } from "../lib/jsonSchemaExample";
|
||||
import { buildExampleFromSchema } from "../lib/jsonSchemaExample";
|
||||
import { pluralizeCount } from "../lib/pluralize";
|
||||
import { queryClient } from "../lib/queryClient";
|
||||
import { Button } from "./core/Button";
|
||||
import { Dropdown } from "./core/Dropdown";
|
||||
import type { EditorProps } from "./core/Editor/Editor";
|
||||
import { Editor } from "./core/Editor/LazyEditor";
|
||||
import { GrpcProtoSelectionDialog } from "./GrpcProtoSelectionDialog";
|
||||
@@ -29,6 +36,11 @@ type Props = Pick<EditorProps, "heightMode" | "onChange" | "className" | "forceU
|
||||
protoFiles: string[];
|
||||
};
|
||||
|
||||
type MethodSchema =
|
||||
| { type: "none" }
|
||||
| { type: "schema"; schema: JsonSchema }
|
||||
| { type: "error"; id: string; title: string; body: ReactNode; log: unknown[] };
|
||||
|
||||
export function GrpcEditor({
|
||||
services,
|
||||
reflectionError,
|
||||
@@ -42,21 +54,16 @@ export function GrpcEditor({
|
||||
setEditorView(h);
|
||||
}, []);
|
||||
|
||||
// Find the schema for the selected service and method and update the editor
|
||||
useEffect(() => {
|
||||
if (
|
||||
editorView == null ||
|
||||
services === null ||
|
||||
request.service === null ||
|
||||
request.method === null
|
||||
) {
|
||||
return;
|
||||
// Find the schema for the selected service and method
|
||||
const methodSchema = useMemo<MethodSchema>(() => {
|
||||
if (services === null || request.service === null || request.method === null) {
|
||||
return { type: "none" };
|
||||
}
|
||||
|
||||
const s = services.find((s) => s.name === request.service);
|
||||
if (s == null) {
|
||||
console.log("Failed to find service", { service: request.service, services });
|
||||
showAlert({
|
||||
return {
|
||||
type: "error",
|
||||
id: "grpc-find-service-error",
|
||||
title: "Couldn't Find Service",
|
||||
body: (
|
||||
@@ -64,14 +71,14 @@ export function GrpcEditor({
|
||||
Failed to find service <InlineCode>{request.service}</InlineCode> in schema
|
||||
</>
|
||||
),
|
||||
});
|
||||
return;
|
||||
log: ["Failed to find service", { service: request.service, services }],
|
||||
};
|
||||
}
|
||||
|
||||
const schema = s.methods.find((m) => m.name === request.method)?.schema;
|
||||
if (request.method != null && schema == null) {
|
||||
console.log("Failed to find method", { method: request.method, methods: s?.methods });
|
||||
showAlert({
|
||||
if (schema == null) {
|
||||
return {
|
||||
type: "error",
|
||||
id: "grpc-find-schema-error",
|
||||
title: "Couldn't Find Method",
|
||||
body: (
|
||||
@@ -80,18 +87,15 @@ export function GrpcEditor({
|
||||
<InlineCode>{request.service}</InlineCode> in schema
|
||||
</>
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema == null) {
|
||||
return;
|
||||
log: ["Failed to find method", { method: request.method, methods: s.methods }],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
updateSchema(editorView, JSON.parse(schema));
|
||||
return { type: "schema", schema: JSON.parse(schema) as JsonSchema };
|
||||
} catch (err) {
|
||||
showAlert({
|
||||
return {
|
||||
type: "error",
|
||||
id: "grpc-parse-schema-error",
|
||||
title: "Failed to Parse Schema",
|
||||
body: (
|
||||
@@ -103,9 +107,22 @@ export function GrpcEditor({
|
||||
<FormattedError>{String(err)}</FormattedError>
|
||||
</VStack>
|
||||
),
|
||||
});
|
||||
log: ["Failed to parse schema", err],
|
||||
};
|
||||
}
|
||||
}, [editorView, services, request.method, request.service]);
|
||||
}, [services, request.method, request.service]);
|
||||
|
||||
useEffect(() => {
|
||||
if (methodSchema.type !== "error") return;
|
||||
console.log(...methodSchema.log);
|
||||
showAlert({ id: methodSchema.id, title: methodSchema.title, body: methodSchema.body });
|
||||
}, [methodSchema]);
|
||||
|
||||
// Update the editor whenever the schema changes
|
||||
useEffect(() => {
|
||||
if (editorView == null || methodSchema.type !== "schema") return;
|
||||
updateSchema(editorView, methodSchema.schema as JSONSchema7);
|
||||
}, [editorView, methodSchema]);
|
||||
|
||||
const extraExtensions = useMemo(
|
||||
() => [
|
||||
@@ -124,45 +141,145 @@ export function GrpcEditor({
|
||||
const reflectionUnavailable = reflectionError?.match(/unimplemented/i);
|
||||
reflectionError = reflectionUnavailable ? undefined : reflectionError;
|
||||
|
||||
const handleGenerateExample = useCallback(async () => {
|
||||
if (methodSchema.type !== "schema") return;
|
||||
|
||||
if (request.message.trim() !== "") {
|
||||
const confirmed = await showConfirm({
|
||||
id: "grpc-generate-example",
|
||||
title: "Generate Example",
|
||||
description: "The current message will be replaced with an example.",
|
||||
confirmText: "Generate",
|
||||
});
|
||||
if (!confirmed) return;
|
||||
}
|
||||
|
||||
const message = JSON.stringify(buildExampleFromSchema(methodSchema.schema), null, 2);
|
||||
await patchModel(request, { message });
|
||||
|
||||
// Force the editor to pick up the new message
|
||||
wasUpdatedExternally(request.id);
|
||||
}, [methodSchema, request]);
|
||||
|
||||
// The reflect query is keyed by request, url and proto files, so a prefix invalidate
|
||||
// reaches it without threading a refetch down from the connection layout.
|
||||
const handleReloadSchema = useCallback(
|
||||
() => queryClient.invalidateQueries({ queryKey: ["grpc_reflect", request.id] }),
|
||||
[request.id],
|
||||
);
|
||||
|
||||
const handleShowReflectionError = useCallback(() => {
|
||||
showDialog({
|
||||
id: "grpc-reflection-error",
|
||||
title: "Reflection Failed",
|
||||
size: "sm",
|
||||
render: ({ hide }) => (
|
||||
<>
|
||||
<FormattedError>{reflectionError ?? "unknown"}</FormattedError>
|
||||
<div className="w-full my-4">
|
||||
<Button
|
||||
className="ml-auto"
|
||||
color="primary"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
hide();
|
||||
await handleReloadSchema();
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
});
|
||||
}, [handleReloadSchema, reflectionError]);
|
||||
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
<div key="reflection" className={classNames(services == null && "opacity-100!")}>
|
||||
<Button
|
||||
size="xs"
|
||||
color={
|
||||
reflectionLoading
|
||||
? "secondary"
|
||||
: reflectionUnavailable
|
||||
? "info"
|
||||
: reflectionError
|
||||
? "danger"
|
||||
: "secondary"
|
||||
}
|
||||
isLoading={reflectionLoading}
|
||||
onClick={() => {
|
||||
showDialog({
|
||||
title: "Configure Schema",
|
||||
size: "md",
|
||||
id: "reflection-failed",
|
||||
render: ({ hide }) => <GrpcProtoSelectionDialog onDone={hide} />,
|
||||
});
|
||||
}}
|
||||
// Matches the GraphQL editor: one always-visible control labelled by schema state,
|
||||
// with everything schema-related behind it.
|
||||
<div key="schema" className="opacity-100!">
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
// Hidden for servers without reflection, which isn't an error
|
||||
hidden: !reflectionError,
|
||||
type: "content",
|
||||
label: (
|
||||
<Banner color="danger">
|
||||
<p className="mb-1">Reflection failed</p>
|
||||
<Button
|
||||
size="xs"
|
||||
color="danger"
|
||||
variant="border"
|
||||
onClick={handleShowReflectionError}
|
||||
>
|
||||
View Error
|
||||
</Button>
|
||||
</Banner>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Generate Example Message",
|
||||
leftSlot: <Icon icon="magic_wand" />,
|
||||
disabled: methodSchema.type !== "schema",
|
||||
onSelect: handleGenerateExample,
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Reload Schema",
|
||||
leftSlot: <Icon icon="refresh" spin={reflectionLoading} />,
|
||||
keepOpenOnSelect: true,
|
||||
onSelect: handleReloadSchema,
|
||||
},
|
||||
{
|
||||
label: protoFiles.length > 0 ? "Select Proto Files\u2026" : "Configure Schema\u2026",
|
||||
leftSlot: <Icon icon="settings" />,
|
||||
onSelect: () => {
|
||||
showDialog({
|
||||
title: "Configure Schema",
|
||||
size: "md",
|
||||
id: "grpc-configure-schema",
|
||||
render: ({ hide }) => <GrpcProtoSelectionDialog onDone={hide} />,
|
||||
});
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
{reflectionLoading
|
||||
? "Inspecting Schema"
|
||||
: reflectionUnavailable
|
||||
? "Select Proto Files"
|
||||
: reflectionError
|
||||
? "Server Error"
|
||||
: protoFiles.length > 0
|
||||
? pluralizeCount("File", protoFiles.length)
|
||||
: services != null && protoFiles.length === 0
|
||||
? "Schema Detected"
|
||||
: "Select Schema"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="border"
|
||||
title="Schema"
|
||||
forDropdown
|
||||
isLoading={reflectionLoading}
|
||||
color={reflectionUnavailable ? "info" : reflectionError ? "danger" : "default"}
|
||||
>
|
||||
{reflectionLoading
|
||||
? "Inspecting Schema"
|
||||
: reflectionUnavailable
|
||||
? "Select Proto Files"
|
||||
: reflectionError
|
||||
? "Server Error"
|
||||
: protoFiles.length > 0
|
||||
? pluralizeCount("File", protoFiles.length)
|
||||
: services != null
|
||||
? "Schema Detected"
|
||||
: "Select Schema"}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>,
|
||||
],
|
||||
[protoFiles.length, reflectionError, reflectionLoading, reflectionUnavailable, services],
|
||||
[
|
||||
handleGenerateExample,
|
||||
handleReloadSchema,
|
||||
handleShowReflectionError,
|
||||
methodSchema.type,
|
||||
protoFiles.length,
|
||||
reflectionError,
|
||||
reflectionLoading,
|
||||
reflectionUnavailable,
|
||||
services,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -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(
|
||||
@@ -194,13 +196,6 @@ export function GrpcRequestPane({
|
||||
type: "default",
|
||||
shortLabel: o.label,
|
||||
}))}
|
||||
itemsAfter={[
|
||||
{
|
||||
label: "Refresh",
|
||||
type: "default",
|
||||
leftSlot: <Icon size="sm" icon="refresh" />,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
@@ -165,6 +165,8 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
: 0;
|
||||
}
|
||||
|
||||
const hasDescription = activeRequest.description.trim().length > 0;
|
||||
|
||||
const tabs = useMemo<TabItem[]>(
|
||||
() => [
|
||||
{
|
||||
@@ -260,6 +262,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
{
|
||||
value: TAB_DESCRIPTION,
|
||||
label: "Info",
|
||||
rightSlot: hasDescription && <CountBadge count={true} />,
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -267,6 +270,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
authTab,
|
||||
contentType,
|
||||
handleContentTypeChange,
|
||||
hasDescription,
|
||||
headersTab,
|
||||
numParams,
|
||||
numSettingsOverrides,
|
||||
|
||||
@@ -1,15 +1,38 @@
|
||||
import {
|
||||
type Folder,
|
||||
type ImportDestination,
|
||||
type ImportPlan,
|
||||
type ImportPlanItem,
|
||||
type ImportSource,
|
||||
type Workspace,
|
||||
} from "@yaakapp-internal/models";
|
||||
import { Banner, HStack, Icon, type IconProps, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { Icon, VStack } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useLocalStorage } from "react-use";
|
||||
import { formatDistanceToNowStrict } from "date-fns";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { pluralize } from "../lib/pluralize";
|
||||
import { CommercialUseBanner } from "./CommercialUseBanner";
|
||||
import { Button } from "./core/Button";
|
||||
import { Checkbox } from "./core/Checkbox";
|
||||
import type { CheckboxTreeNode } from "./core/CheckboxTree";
|
||||
import { CheckboxTree } from "./core/CheckboxTree";
|
||||
import { IconTooltip } from "./core/IconTooltip";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import { Select } from "./core/Select";
|
||||
import { SegmentedControl } from "./core/SegmentedControl";
|
||||
|
||||
interface Props {
|
||||
importFile: (filePath: string) => Promise<void>;
|
||||
importUrl: (url: string) => Promise<void>;
|
||||
currentWorkspace: Workspace | null;
|
||||
workspaces: Workspace[];
|
||||
selectedFolder: Folder | null;
|
||||
planFile: (filePath: string, destination: ImportDestination) => Promise<ImportPlan>;
|
||||
planUrl: (url: string, destination: ImportDestination) => Promise<ImportPlan>;
|
||||
listSources: (workspaceId: string) => Promise<ImportSource[]>;
|
||||
findSourcesForOrigin: (args: { filePath?: string; url?: string }) => Promise<ImportSource[]>;
|
||||
commit: (plan: ImportPlan) => Promise<void>;
|
||||
cancel: () => void;
|
||||
onError: (err: unknown) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,10 +54,74 @@ function fileName(path: string): string {
|
||||
return path.split(/[/\\]/).at(-1) || path;
|
||||
}
|
||||
|
||||
export function ImportDataDialog({ importFile, importUrl }: Props) {
|
||||
/**
|
||||
* Loads the current workspace's linked sources before rendering the dialog, so the inner
|
||||
* component can construct its initial state (prefilled path, destination) in one pass instead of
|
||||
* patching it in with effects after the first paint.
|
||||
*/
|
||||
export function ImportDataDialog(props: Props) {
|
||||
const [initialSources, setInitialSources] = useState<ImportSource[] | null>(null);
|
||||
const { currentWorkspace, listSources } = props;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = currentWorkspace == null ? Promise.resolve([]) : listSources(currentWorkspace.id);
|
||||
load
|
||||
.then((sources) => {
|
||||
if (!cancelled) setInitialSources(sources);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setInitialSources([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentWorkspace, listSources]);
|
||||
|
||||
if (initialSources == null) return null;
|
||||
return <LoadedImportDataDialog {...props} initialSources={initialSources} />;
|
||||
}
|
||||
|
||||
function latestSource(sources: ImportSource[]): ImportSource | null {
|
||||
return sources.reduce<ImportSource | null>(
|
||||
(latest, s) => (latest == null || s.lastImportedAt > latest.lastImportedAt ? s : latest),
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
function LoadedImportDataDialog({
|
||||
currentWorkspace,
|
||||
workspaces,
|
||||
selectedFolder,
|
||||
planFile,
|
||||
planUrl,
|
||||
listSources,
|
||||
findSourcesForOrigin,
|
||||
commit,
|
||||
cancel,
|
||||
onError,
|
||||
initialSources,
|
||||
}: Props & { initialSources: ImportSource[] }) {
|
||||
// A workspace with a linked source is probably being re-imported, so start from that source
|
||||
const prefill = latestSource(initialSources);
|
||||
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [plan, setPlan] = useState<ImportPlan | null>(null);
|
||||
const [items, setItems] = useState<ImportPlanItem[]>([]);
|
||||
// null means no explicit choice yet, so the default below applies
|
||||
const [destinationChoice, setDestinationChoice] = useState<"new" | "current" | "other" | null>(
|
||||
null,
|
||||
);
|
||||
const [otherWorkspaceId, setOtherWorkspaceId] = useState<string | null>(null);
|
||||
const [targetSelectedFolder, setTargetSelectedFolder] = useState(selectedFolder != null);
|
||||
const [linkedSources, setLinkedSources] = useState<ImportSource[]>(
|
||||
prefill != null ? initialSources : [],
|
||||
);
|
||||
const [originSources, setOriginSources] = useState<ImportSource[]>(
|
||||
prefill != null ? [prefill] : [],
|
||||
);
|
||||
// A file path or a URL. Both inputs write here, so there is only ever one thing to import
|
||||
const [source, setSource] = useLocalStorage<string | null>("importPathOrUrl", null);
|
||||
const [source, setSource] = useState<string | null>(prefill?.origin ?? null);
|
||||
const [forceUpdateKey, setForceUpdateKey] = useState<number>(0);
|
||||
const [isHovering, setIsHovering] = useState<boolean>(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
@@ -65,25 +152,272 @@ export function ImportDataDialog({ importFile, importUrl }: Props) {
|
||||
});
|
||||
}, [isHovering, setSource]);
|
||||
|
||||
useEffect(() => {
|
||||
if (trimmedSource === "") {
|
||||
setOriginSources([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const timeout = setTimeout(() => {
|
||||
findSourcesForOrigin(filePath != null ? { filePath } : { url: trimmedSource })
|
||||
.then((sources) => {
|
||||
if (!cancelled) setOriginSources(sources);
|
||||
})
|
||||
.catch(() => setOriginSources([]));
|
||||
}, 300);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [trimmedSource, filePath, findSourcesForOrigin]);
|
||||
|
||||
// The one workspace this file is linked to, if there is exactly one.
|
||||
const linkedWorkspace = useMemo(() => {
|
||||
const ids = [...new Set(originSources.map((s) => s.workspaceId))];
|
||||
if (ids.length !== 1) return null;
|
||||
return workspaces.find((w) => w.id === ids[0]) ?? null;
|
||||
}, [originSources, workspaces]);
|
||||
|
||||
// A file linked to the current workspace defaults back into it, so re-importing doesn't
|
||||
// accidentally create a duplicate workspace. A file linked elsewhere only gets a suggestion —
|
||||
// silently targeting a workspace that is neither new nor current is too surprising. An
|
||||
// explicit choice always wins.
|
||||
const destinationKind =
|
||||
destinationChoice ??
|
||||
(linkedWorkspace != null && linkedWorkspace.id === currentWorkspace?.id ? "current" : "new");
|
||||
|
||||
const destinationWorkspaceId =
|
||||
destinationKind === "current"
|
||||
? (currentWorkspace?.id ?? null)
|
||||
: destinationKind === "other"
|
||||
? otherWorkspaceId
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (destinationWorkspaceId == null) {
|
||||
setLinkedSources([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
listSources(destinationWorkspaceId)
|
||||
.then((sources) => {
|
||||
if (!cancelled) setLinkedSources(sources);
|
||||
})
|
||||
.catch(() => setLinkedSources([]));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [destinationWorkspaceId, listSources]);
|
||||
|
||||
const handleSelectFile = async () => {
|
||||
const selected = await platform.dialog.open({ title: "Select File", multiple: false });
|
||||
if (selected == null) return;
|
||||
selectSource(selected);
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
// The selected folder belongs to the workspace being viewed, so it is only offerable when that
|
||||
// is also the destination.
|
||||
const canTargetSelectedFolder = selectedFolder != null && destinationKind === "current";
|
||||
|
||||
const destination = (): ImportDestination => {
|
||||
if (destinationWorkspaceId == null) {
|
||||
return { type: "new_workspace" };
|
||||
}
|
||||
return {
|
||||
type: "existing_workspace",
|
||||
workspaceId: destinationWorkspaceId,
|
||||
folderId: canTargetSelectedFolder && targetSelectedFolder ? selectedFolder.id : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const handlePreview = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (filePath != null) {
|
||||
await importFile(filePath);
|
||||
} else {
|
||||
await importUrl(trimmedSource);
|
||||
}
|
||||
const nextPlan =
|
||||
filePath != null
|
||||
? await planFile(filePath, destination())
|
||||
: await planUrl(trimmedSource, destination());
|
||||
setPlan(nextPlan);
|
||||
setItems(nextPlan.items);
|
||||
} catch (err) {
|
||||
onError(err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCommit = async () => {
|
||||
if (plan == null) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await commit({ ...plan, items });
|
||||
} catch (err) {
|
||||
onError(err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const itemTree = useMemo(() => buildItemTree(items), [items]);
|
||||
|
||||
// A folder row's checkbox carries everything beneath it, deletions included — the row labels
|
||||
// say which of those are destructive. Checking anything also brings back the folders it needs
|
||||
// to live in.
|
||||
const toggleNode = (node: CheckboxTreeNode<TreeRow>, checked: boolean) => {
|
||||
const targets = new Set(togglableItems(node).map((i) => i.modelId));
|
||||
if (checked && node.data.kind === "item") {
|
||||
const byId = new Map(items.map((i) => [i.modelId, i]));
|
||||
for (const ancestor of ancestorsOf(node.data.item, byId)) {
|
||||
if (isMissingFolder(ancestor)) targets.add(ancestor.modelId);
|
||||
}
|
||||
}
|
||||
setItems((prev) => prev.map((i) => (targets.has(i.modelId) ? { ...i, selected: checked } : i)));
|
||||
};
|
||||
|
||||
const resolveConflict = (modelId: string, resolution: "keep_mine" | "take_source") => {
|
||||
setItems((prev) =>
|
||||
prev.map((item) => (item.modelId === modelId ? { ...item, resolution } : item)),
|
||||
);
|
||||
};
|
||||
|
||||
// Deleting a folder takes its contents with it, so those rows have nothing left to decide.
|
||||
const disabledIds = useMemo(() => {
|
||||
const disabled = new Set<string>();
|
||||
const byId = new Map(items.map((i) => [i.modelId, i]));
|
||||
for (const item of items) {
|
||||
if (item.action !== "delete") continue;
|
||||
for (const parent of ancestorsOf(item, byId)) {
|
||||
if (parent.action === "delete" && parent.selected) {
|
||||
disabled.add(item.modelId);
|
||||
}
|
||||
}
|
||||
}
|
||||
return disabled;
|
||||
}, [items]);
|
||||
|
||||
if (plan != null) {
|
||||
const unchanged = items.filter((i) => i.action === "unchanged");
|
||||
const footerNote =
|
||||
unchanged.length > 0
|
||||
? `${unchanged.length} ${pluralize("resource", unchanged.length)} unchanged`
|
||||
: "";
|
||||
const changeCount = items.filter((item) => {
|
||||
if (disabledIds.has(item.modelId)) return false;
|
||||
if (item.action === "conflict") return item.resolution === "take_source";
|
||||
if (item.action === "unchanged") return false;
|
||||
return item.selected;
|
||||
}).length;
|
||||
|
||||
// The row's label carries what kind of destination it is, so the value can just be its name
|
||||
const [destinationLabel, destinationValue] = ((): [string, string] => {
|
||||
if (plan.destination.type === "new_workspace") {
|
||||
const names = plan.resources.workspaces.map((w) => w.name).filter((n) => n !== "");
|
||||
if (names.length === 0) return ["New workspace", "Untitled"];
|
||||
return [pluralize("New workspace", names.length), names.join(", ")];
|
||||
}
|
||||
const { workspaceId, folderId } = plan.destination;
|
||||
const name = workspaces.find((w) => w.id === workspaceId)?.name ?? "Unknown workspace";
|
||||
return [
|
||||
"Destination",
|
||||
folderId != null && folderId === selectedFolder?.id
|
||||
? `${name} / ${selectedFolder.name}`
|
||||
: name,
|
||||
];
|
||||
})();
|
||||
|
||||
// The destination workspace roots the tree. It is not a plan item — commit always applies
|
||||
// it — so its checkbox only aggregates the subtree.
|
||||
const workspaceRoot: CheckboxTreeNode<TreeRow> = (() => {
|
||||
const planned = plan.resources.workspaces[0];
|
||||
const planDestination = plan.destination;
|
||||
const existing =
|
||||
planDestination.type === "existing_workspace"
|
||||
? workspaces.find((w) => w.id === planDestination.workspaceId)
|
||||
: null;
|
||||
return {
|
||||
key: existing?.id ?? planned?.id ?? "workspace",
|
||||
data: {
|
||||
kind: "destination",
|
||||
label: existing?.name ?? planned?.name ?? "New workspace",
|
||||
isNew: planDestination.type === "new_workspace",
|
||||
},
|
||||
children: itemTree,
|
||||
};
|
||||
})();
|
||||
|
||||
return (
|
||||
<VStack space={4} className="pb-4">
|
||||
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
|
||||
<PreviewRow label="Detected format" value={plan.importer} />
|
||||
<PreviewRow label={destinationLabel} value={destinationValue} />
|
||||
</div>
|
||||
|
||||
{plan.warnings.map((warning) => (
|
||||
<Banner
|
||||
key={`${warning.title}:${warning.detail}`}
|
||||
color={warning.level === "warning" ? "warning" : "info"}
|
||||
className="flex items-start gap-2.5"
|
||||
>
|
||||
<Icon
|
||||
icon={warning.level === "warning" ? "alert_triangle" : "info"}
|
||||
size="sm"
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">{warning.title}</div>
|
||||
<div className="text-xs text-text-subtle mt-0.5">{warning.detail}</div>
|
||||
</div>
|
||||
</Banner>
|
||||
))}
|
||||
|
||||
<div className="rounded-lg border border-border-subtle px-3 py-2 overflow-y-auto max-h-[40vh]">
|
||||
<CheckboxTree
|
||||
node={workspaceRoot}
|
||||
checked={nodeCheckedStatus}
|
||||
onCheck={toggleNode}
|
||||
isCheckboxDisabled={(n) => disabledIds.has(n.key)}
|
||||
isCollapsedByDefault={(n) => n.data.kind === "item" && n.data.item.action === "ignored"}
|
||||
isRelevant={(n) =>
|
||||
n.data.kind === "destination" ||
|
||||
(n.data.kind === "item" && n.data.item.action !== "unchanged")
|
||||
}
|
||||
renderRow={(n) => <ImportTreeRow row={n.data} onResolveConflict={resolveConflict} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<HStack space={2} alignItems="center" className="mt-3">
|
||||
{footerNote !== "" && <div className="text-xs text-text-subtle">{footerNote}</div>}
|
||||
<Button
|
||||
className="ml-auto"
|
||||
color="secondary"
|
||||
variant="border"
|
||||
disabled={isLoading}
|
||||
onClick={() => {
|
||||
setPlan(null);
|
||||
setItems([]);
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button color="primary" isLoading={isLoading} onClick={handleCommit}>
|
||||
{isLoading
|
||||
? "Importing"
|
||||
: changeCount > 0
|
||||
? `Apply ${changeCount} ${changeCount === 1 ? "Change" : "Changes"}`
|
||||
: "Done"}
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
const lastImported =
|
||||
originSources.find((s) => s.workspaceId === destinationWorkspaceId) ??
|
||||
linkedSources.reduce<ImportSource | null>(
|
||||
(latest, s) => (latest == null || s.lastImportedAt > latest.lastImportedAt ? s : latest),
|
||||
null,
|
||||
);
|
||||
|
||||
return (
|
||||
<VStack ref={ref} space={4} className="pb-4">
|
||||
<CommercialUseBanner source="data-import" title="Importing work data?" />
|
||||
@@ -94,7 +428,9 @@ export function ImportDataDialog({ importFile, importUrl }: Props) {
|
||||
className={classNames(
|
||||
"w-full rounded-lg border border-dashed px-4 py-6",
|
||||
"flex flex-col items-center gap-1 text-center",
|
||||
isHovering ? "border-notice bg-surface-highlight" : "border-border hover:border-text",
|
||||
isHovering
|
||||
? "border-notice bg-surface-highlight"
|
||||
: "border-border hover:border-text-subtle",
|
||||
)}
|
||||
>
|
||||
<Icon icon="folder_input" className="text-text-subtlest w-8! h-8! mb-2" />
|
||||
@@ -111,29 +447,349 @@ export function ImportDataDialog({ importFile, importUrl }: Props) {
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-text-subtlest">
|
||||
Supports OpenAPI, Swagger, Postman, Insomnia, and curl
|
||||
Supports OpenAPI, Swagger, Postman, Insomnia, curl, and Yaak exports
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<VStack space={2}>
|
||||
<PlainInput
|
||||
label="Or enter a file path or URL"
|
||||
<PlainInput
|
||||
label="Or enter a file path or URL"
|
||||
size="sm"
|
||||
placeholder="https://example.com/openapi.json"
|
||||
defaultValue={source ?? ""}
|
||||
forceUpdateKey={String(forceUpdateKey)}
|
||||
onChange={setSource}
|
||||
/>
|
||||
|
||||
<VStack space={2} className={classNames(workspaces.length === 0 && "hidden")}>
|
||||
<Select
|
||||
name="import-destination-kind"
|
||||
label="Import location"
|
||||
size="sm"
|
||||
placeholder="https://example.com/openapi.json"
|
||||
defaultValue={source ?? ""}
|
||||
forceUpdateKey={String(forceUpdateKey)}
|
||||
onChange={setSource}
|
||||
value={destinationKind}
|
||||
onChange={setDestinationChoice}
|
||||
options={[
|
||||
{ value: "new", label: "New Workspace" },
|
||||
...(currentWorkspace != null
|
||||
? [{ value: "current" as const, label: "Current Workspace" }]
|
||||
: []),
|
||||
{ value: "other", label: "Other Workspace" },
|
||||
]}
|
||||
/>
|
||||
{destinationKind === "other" && (
|
||||
<Select
|
||||
name="import-destination-workspace"
|
||||
label="Workspace"
|
||||
hideLabel
|
||||
size="sm"
|
||||
value={otherWorkspaceId ?? ""}
|
||||
onChange={(id) => setOtherWorkspaceId(id === "" ? null : id)}
|
||||
filterable
|
||||
options={[
|
||||
{ value: "", label: "Select a workspace" },
|
||||
...workspaces
|
||||
.filter((w) => w.id !== currentWorkspace?.id)
|
||||
.map((w) => ({ value: w.id, label: w.name })),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
{lastImported != null && destinationWorkspaceId != null ? (
|
||||
<div className="text-xs text-text-subtle">
|
||||
Last imported from {lastImported.originLabel} ·{" "}
|
||||
{formatDistanceToNowStrict(`${lastImported.lastImportedAt}Z`, { addSuffix: true })}
|
||||
</div>
|
||||
) : linkedWorkspace != null && linkedWorkspace.id !== destinationWorkspaceId ? (
|
||||
<div className="text-xs text-text-subtle">
|
||||
This file was last imported into{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="underline hocus:text-text"
|
||||
onClick={() => {
|
||||
if (linkedWorkspace.id === currentWorkspace?.id) {
|
||||
setDestinationChoice("current");
|
||||
} else {
|
||||
setDestinationChoice("other");
|
||||
setOtherWorkspaceId(linkedWorkspace.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{linkedWorkspace.name}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{canTargetSelectedFolder && (
|
||||
<Checkbox
|
||||
checked={targetSelectedFolder}
|
||||
title={`Place root resources in selected folder “${selectedFolder.name}”`}
|
||||
onChange={setTargetSelectedFolder}
|
||||
/>
|
||||
)}
|
||||
</VStack>
|
||||
|
||||
<HStack space={2} justifyContent="end">
|
||||
<Button color="secondary" variant="border" disabled={isLoading} onClick={cancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
disabled={trimmedSource === "" || isLoading}
|
||||
disabled={
|
||||
trimmedSource === "" ||
|
||||
isLoading ||
|
||||
(destinationKind === "other" && otherWorkspaceId == null)
|
||||
}
|
||||
isLoading={isLoading}
|
||||
size="sm"
|
||||
onClick={handleImport}
|
||||
onClick={handlePreview}
|
||||
>
|
||||
{isLoading ? "Importing" : "Import"}
|
||||
{isLoading ? "Analyzing" : "Preview Import"}
|
||||
</Button>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function ImportTreeRow({
|
||||
row,
|
||||
onResolveConflict,
|
||||
}: {
|
||||
row: TreeRow;
|
||||
onResolveConflict: (modelId: string, resolution: "keep_mine" | "take_source") => void;
|
||||
}) {
|
||||
if (row.kind !== "item") {
|
||||
return (
|
||||
<>
|
||||
<Icon color="secondary" icon={row.kind === "destination" ? "house" : row.icon} />
|
||||
<div className="truncate flex-1">{row.label}</div>
|
||||
{row.kind === "destination" && row.isNew && (
|
||||
<ActionChip label="new" help="Created by this import" className="text-success" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const { item } = row;
|
||||
const label = actionLabel(item);
|
||||
return (
|
||||
<>
|
||||
{item.model === "folder" || item.model === "environment" ? (
|
||||
<Icon color="secondary" icon={item.model === "folder" ? "folder" : "variable"} />
|
||||
) : (
|
||||
<span aria-hidden className="w-4" />
|
||||
)}
|
||||
<div className="truncate flex-1">{item.name}</div>
|
||||
{item.action === "conflict" ? (
|
||||
<div className="shrink-0">
|
||||
<SegmentedControl
|
||||
name={`conflict-${item.modelId}`}
|
||||
label={`Resolve conflict for ${item.name}`}
|
||||
hideLabel
|
||||
size="2xs"
|
||||
help={actionHelp(item)}
|
||||
value={item.resolution ?? "keep_mine"}
|
||||
onChange={(v) => onResolveConflict(item.modelId, v)}
|
||||
options={[
|
||||
{ value: "keep_mine", label: "Keep mine" },
|
||||
{ value: "take_source", label: "Take source" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
label != null && (
|
||||
<ActionChip
|
||||
label={label}
|
||||
help={actionHelp(item)}
|
||||
className={classNames(
|
||||
item.action === "create" && "text-success",
|
||||
item.action === "update" && "text-info",
|
||||
item.action === "delete" && "text-danger",
|
||||
item.action === "keep_local" && item.selected && "text-warning",
|
||||
item.action === "ignored" && "text-text-subtlest",
|
||||
)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionChip({
|
||||
label,
|
||||
help,
|
||||
className,
|
||||
}: {
|
||||
label: string;
|
||||
help: string | null;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<InlineCode
|
||||
className={classNames(
|
||||
"py-0 bg-transparent w-32 shrink-0 whitespace-nowrap text-xs",
|
||||
"inline-flex items-center justify-center gap-1.5",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{help != null && <IconTooltip content={help} iconSize="xs" />}
|
||||
</InlineCode>
|
||||
);
|
||||
}
|
||||
|
||||
function actionLabel(item: ImportPlanItem): string | null {
|
||||
switch (item.action) {
|
||||
case "create":
|
||||
return "new";
|
||||
case "update":
|
||||
return "updated";
|
||||
case "delete":
|
||||
return "removed";
|
||||
case "keep_local":
|
||||
return "edited";
|
||||
case "ignored":
|
||||
return "ignored";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function actionHelp(item: ImportPlanItem): string | null {
|
||||
const help = (text: string) =>
|
||||
item.changedFields.length > 0
|
||||
? `${text} · ${item.changedFields.map(fieldLabel).join(", ")}`
|
||||
: text;
|
||||
switch (item.action) {
|
||||
case "create":
|
||||
return "Not in this workspace yet";
|
||||
case "update":
|
||||
return item.reason === "moved_into_ignored_folder"
|
||||
? "Moved into a folder that isn't imported. Importing that folder brings it along"
|
||||
: help("Changed in the source since the last import");
|
||||
case "delete":
|
||||
return "Gone from the source since the last import. Checking it deletes it here";
|
||||
case "keep_local":
|
||||
return help("Changed here since the last import. Checking it reverts to the source");
|
||||
case "conflict":
|
||||
return help("Changed here and in the source since the last import");
|
||||
case "ignored":
|
||||
return "Not in this workspace. Imports leave it alone until you check it";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function fieldLabel(field: string): string {
|
||||
return field.replace(/([A-Z])/g, " $1").toLowerCase();
|
||||
}
|
||||
|
||||
/** Every plan item above `item`, nearest first. */
|
||||
function ancestorsOf(item: ImportPlanItem, byId: Map<string, ImportPlanItem>): ImportPlanItem[] {
|
||||
const ancestors: ImportPlanItem[] = [];
|
||||
const seen = new Set<string>();
|
||||
let parentId = item.parentId;
|
||||
while (parentId != null && !seen.has(parentId)) {
|
||||
seen.add(parentId);
|
||||
const parent = byId.get(parentId);
|
||||
if (parent == null) break;
|
||||
ancestors.push(parent);
|
||||
parentId = parent.parentId;
|
||||
}
|
||||
return ancestors;
|
||||
}
|
||||
|
||||
/**
|
||||
* A row of the preview tree. Most are plan items, but the destination workspace and the group the
|
||||
* workspace's environments sit in are headings: they aggregate their children and decide nothing
|
||||
* themselves.
|
||||
*/
|
||||
type TreeRow =
|
||||
| { kind: "destination"; label: string; isNew: boolean }
|
||||
| { kind: "group"; label: string; icon: IconProps["icon"] }
|
||||
| { kind: "item"; item: ImportPlanItem };
|
||||
|
||||
function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<TreeRow>[] {
|
||||
const byId = new Map(items.map((i) => [i.modelId, i]));
|
||||
const childrenOf = new Map<string, ImportPlanItem[]>();
|
||||
const roots: ImportPlanItem[] = [];
|
||||
for (const item of items) {
|
||||
if (item.parentId != null && byId.has(item.parentId)) {
|
||||
const siblings = childrenOf.get(item.parentId) ?? [];
|
||||
siblings.push(item);
|
||||
childrenOf.set(item.parentId, siblings);
|
||||
} else {
|
||||
roots.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
const byKind = (list: ImportPlanItem[]) => [
|
||||
...list.filter((i) => i.model === "environment"),
|
||||
...list.filter((i) => i.model === "folder"),
|
||||
...list.filter((i) => i.model !== "environment" && i.model !== "folder"),
|
||||
];
|
||||
|
||||
const toNode = (item: ImportPlanItem, seen: Set<string>): CheckboxTreeNode<TreeRow> => ({
|
||||
key: item.modelId,
|
||||
data: { kind: "item", item },
|
||||
children: seen.has(item.modelId)
|
||||
? []
|
||||
: byKind(childrenOf.get(item.modelId) ?? []).map((c) =>
|
||||
toNode(c, new Set([...seen, item.modelId])),
|
||||
),
|
||||
});
|
||||
|
||||
// The workspace's environments have nothing to sit under — a sub-environment is a sibling of
|
||||
// the base one, not its child — so a heading groups them into one thing to turn on and off.
|
||||
const environments = roots.filter((i) => i.model === "environment");
|
||||
const others = byKind(roots.filter((i) => i.model !== "environment"));
|
||||
const nodes = others.map((r) => toNode(r, new Set()));
|
||||
if (environments.length === 0) return nodes;
|
||||
return [
|
||||
{
|
||||
key: "group:environments",
|
||||
data: { kind: "group", label: "Variables", icon: "variable" },
|
||||
children: environments.map((e) => toNode(e, new Set())),
|
||||
},
|
||||
...nodes,
|
||||
];
|
||||
}
|
||||
|
||||
function collectRows(node: CheckboxTreeNode<TreeRow>): TreeRow[] {
|
||||
return [node.data, ...node.children.flatMap(collectRows)];
|
||||
}
|
||||
|
||||
/** A folder that isn't there yet, so anything inside it needs it brought in first. */
|
||||
function isMissingFolder(item: ImportPlanItem): boolean {
|
||||
return item.model === "folder" && (item.action === "create" || item.action === "ignored");
|
||||
}
|
||||
|
||||
/**
|
||||
* The plan item a row's checkbox decides, if it decides one. An unchanged resource has nothing to
|
||||
* decide and a conflict is decided by its own control, so neither takes a checkbox — nor rides
|
||||
* along with a parent's.
|
||||
*/
|
||||
function togglableItem(row: TreeRow): ImportPlanItem | null {
|
||||
if (row.kind !== "item") return null;
|
||||
const { item } = row;
|
||||
return item.action === "unchanged" || item.action === "conflict" ? null : item;
|
||||
}
|
||||
|
||||
function togglableItems(node: CheckboxTreeNode<TreeRow>): ImportPlanItem[] {
|
||||
return collectRows(node)
|
||||
.map(togglableItem)
|
||||
.filter((i) => i != null);
|
||||
}
|
||||
|
||||
function nodeCheckedStatus(node: CheckboxTreeNode<TreeRow>): boolean | "indeterminate" | "hidden" {
|
||||
const covered = togglableItems(node);
|
||||
if (covered.length === 0) return "hidden";
|
||||
const selected = covered.filter((i) => i.selected).length;
|
||||
if (selected === covered.length) return true;
|
||||
if (selected === 0) return false;
|
||||
return "indeterminate";
|
||||
}
|
||||
|
||||
function PreviewRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 px-3 py-2 text-sm">
|
||||
<span className="text-text-subtle">{label}</span>
|
||||
<span className="text-right font-medium">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,9 @@ import type {
|
||||
Folder,
|
||||
GrpcRequest,
|
||||
HttpRequest,
|
||||
HttpVersion,
|
||||
InheritedBoolSetting,
|
||||
InheritedHttpVersionSetting,
|
||||
InheritedIntSetting,
|
||||
WebsocketRequest,
|
||||
Workspace,
|
||||
@@ -13,6 +15,7 @@ import {
|
||||
modelSupportsSetting,
|
||||
type RequestSettingDefinition,
|
||||
SETTING_FOLLOW_REDIRECTS,
|
||||
SETTING_HTTP_VERSION,
|
||||
SETTING_REQUEST_MESSAGE_SIZE,
|
||||
SETTING_REQUEST_TIMEOUT,
|
||||
SETTING_SEND_COOKIES,
|
||||
@@ -21,6 +24,7 @@ import {
|
||||
} from "../lib/requestSettings";
|
||||
import { Checkbox } from "./core/Checkbox";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import { Select } from "./core/Select";
|
||||
import {
|
||||
SettingOverrideRow,
|
||||
SettingRow,
|
||||
@@ -38,37 +42,21 @@ interface Props {
|
||||
model: ModelWithSettings;
|
||||
}
|
||||
|
||||
type ModelWithSettings =
|
||||
| Workspace
|
||||
| Folder
|
||||
| HttpRequest
|
||||
| WebsocketRequest
|
||||
| GrpcRequest;
|
||||
type ModelWithSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest;
|
||||
type ModelWithHttpSettings = Workspace | Folder | HttpRequest;
|
||||
type ModelWithTlsSettings =
|
||||
| Workspace
|
||||
| Folder
|
||||
| HttpRequest
|
||||
| WebsocketRequest
|
||||
| GrpcRequest;
|
||||
type ModelWithCookieSettings =
|
||||
| Workspace
|
||||
| Folder
|
||||
| HttpRequest
|
||||
| WebsocketRequest;
|
||||
type ModelWithMessageSizeSettings =
|
||||
| Workspace
|
||||
| Folder
|
||||
| WebsocketRequest
|
||||
| GrpcRequest;
|
||||
type ModelWithTlsSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest;
|
||||
type ModelWithCookieSettings = Workspace | Folder | HttpRequest | WebsocketRequest;
|
||||
type ModelWithMessageSizeSettings = Workspace | Folder | WebsocketRequest | GrpcRequest;
|
||||
type BooleanSetting = boolean | InheritedBoolSetting;
|
||||
type IntegerSetting = number | InheritedIntSetting;
|
||||
type HttpVersionSetting = HttpVersion | InheritedHttpVersionSetting;
|
||||
type CookieSettingsPatch = {
|
||||
settingSendCookies?: ModelWithCookieSettings["settingSendCookies"];
|
||||
settingStoreCookies?: ModelWithCookieSettings["settingStoreCookies"];
|
||||
};
|
||||
type HttpSettingsPatch = {
|
||||
settingFollowRedirects?: ModelWithHttpSettings["settingFollowRedirects"];
|
||||
settingHttpVersion?: ModelWithHttpSettings["settingHttpVersion"];
|
||||
settingRequestTimeout?: ModelWithHttpSettings["settingRequestTimeout"];
|
||||
};
|
||||
type TlsSettingsPatch = {
|
||||
@@ -78,10 +66,7 @@ type MessageSizeSettingsPatch = {
|
||||
settingRequestMessageSize?: ModelWithMessageSizeSettings["settingRequestMessageSize"];
|
||||
};
|
||||
|
||||
export function ModelSettingsEditor({
|
||||
model,
|
||||
showSectionTitles = false,
|
||||
}: Props) {
|
||||
export function ModelSettingsEditor({ model, showSectionTitles = false }: Props) {
|
||||
const ancestors = useModelAncestors(model);
|
||||
const supportsHttpSettings = modelSupportsHttpSettings(model);
|
||||
const supportsCookieSettings = modelSupportsCookieSettings(model);
|
||||
@@ -154,12 +139,26 @@ export function ModelSettingsEditor({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{supportsHttpSettings && (
|
||||
<HttpVersionSettingRow
|
||||
settingDefinition={SETTING_HTTP_VERSION}
|
||||
setting={model.settingHttpVersion}
|
||||
inheritedValue={resolveInheritedValue(
|
||||
ancestors,
|
||||
SETTING_HTTP_VERSION.modelKey,
|
||||
model.settingHttpVersion,
|
||||
)}
|
||||
onChange={(settingHttpVersion) =>
|
||||
patchHttpSettings(model, {
|
||||
settingHttpVersion,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</SettingsSection>
|
||||
)}
|
||||
{supportsCookieSettings && (
|
||||
<SettingsSection
|
||||
title={supportsTlsSettings || showSectionTitles ? "Cookies" : null}
|
||||
>
|
||||
<SettingsSection title={supportsTlsSettings || showSectionTitles ? "Cookies" : null}>
|
||||
<BooleanSettingRow
|
||||
settingDefinition={SETTING_SEND_COOKIES}
|
||||
setting={model.settingSendCookies}
|
||||
@@ -195,7 +194,7 @@ export function ModelSettingsEditor({
|
||||
}
|
||||
|
||||
export function countOverriddenSettings(model: ModelWithSettings) {
|
||||
const settings: (BooleanSetting | IntegerSetting)[] = [];
|
||||
const settings: (BooleanSetting | IntegerSetting | HttpVersionSetting)[] = [];
|
||||
|
||||
if (modelSupportsCookieSettings(model)) {
|
||||
settings.push(model.settingSendCookies, model.settingStoreCookies);
|
||||
@@ -204,22 +203,22 @@ export function countOverriddenSettings(model: ModelWithSettings) {
|
||||
settings.push(model.settingValidateCertificates);
|
||||
|
||||
if (modelSupportsHttpSettings(model)) {
|
||||
settings.push(model.settingFollowRedirects, model.settingRequestTimeout);
|
||||
settings.push(
|
||||
model.settingFollowRedirects,
|
||||
model.settingRequestTimeout,
|
||||
model.settingHttpVersion,
|
||||
);
|
||||
}
|
||||
|
||||
if (modelSupportsMessageSizeSettings(model)) {
|
||||
settings.push(model.settingRequestMessageSize);
|
||||
}
|
||||
|
||||
return settings.filter(
|
||||
(setting) => isInheritedSetting(setting) && setting.enabled === true,
|
||||
).length;
|
||||
return settings.filter((setting) => isInheritedSetting(setting) && setting.enabled === true)
|
||||
.length;
|
||||
}
|
||||
|
||||
function patchCookieSettings(
|
||||
model: ModelWithCookieSettings,
|
||||
patch: Partial<CookieSettingsPatch>,
|
||||
) {
|
||||
function patchCookieSettings(model: ModelWithCookieSettings, patch: Partial<CookieSettingsPatch>) {
|
||||
switch (model.model) {
|
||||
case "workspace":
|
||||
return patchModel(model, patch as Partial<Workspace>);
|
||||
@@ -232,10 +231,7 @@ function patchCookieSettings(
|
||||
}
|
||||
}
|
||||
|
||||
function patchHttpSettings(
|
||||
model: ModelWithHttpSettings,
|
||||
patch: Partial<HttpSettingsPatch>,
|
||||
) {
|
||||
function patchHttpSettings(model: ModelWithHttpSettings, patch: Partial<HttpSettingsPatch>) {
|
||||
switch (model.model) {
|
||||
case "workspace":
|
||||
return patchModel(model, patch as Partial<Workspace>);
|
||||
@@ -246,10 +242,7 @@ function patchHttpSettings(
|
||||
}
|
||||
}
|
||||
|
||||
function patchTlsSettings(
|
||||
model: ModelWithTlsSettings,
|
||||
patch: Partial<TlsSettingsPatch>,
|
||||
) {
|
||||
function patchTlsSettings(model: ModelWithTlsSettings, patch: Partial<TlsSettingsPatch>) {
|
||||
switch (model.model) {
|
||||
case "workspace":
|
||||
return patchModel(model, patch as Partial<Workspace>);
|
||||
@@ -280,21 +273,15 @@ function patchMessageSizeSettings(
|
||||
}
|
||||
}
|
||||
|
||||
function modelSupportsHttpSettings(
|
||||
model: ModelWithSettings,
|
||||
): model is ModelWithHttpSettings {
|
||||
function modelSupportsHttpSettings(model: ModelWithSettings): model is ModelWithHttpSettings {
|
||||
return modelSupportsSetting(model, SETTING_REQUEST_TIMEOUT);
|
||||
}
|
||||
|
||||
function modelSupportsCookieSettings(
|
||||
model: ModelWithSettings,
|
||||
): model is ModelWithCookieSettings {
|
||||
function modelSupportsCookieSettings(model: ModelWithSettings): model is ModelWithCookieSettings {
|
||||
return modelSupportsSetting(model, SETTING_SEND_COOKIES);
|
||||
}
|
||||
|
||||
function modelSupportsTlsSettings(
|
||||
model: ModelWithSettings,
|
||||
): model is ModelWithTlsSettings {
|
||||
function modelSupportsTlsSettings(model: ModelWithSettings): model is ModelWithTlsSettings {
|
||||
return modelSupportsSetting(model, SETTING_VALIDATE_CERTIFICATES);
|
||||
}
|
||||
|
||||
@@ -317,11 +304,7 @@ function BooleanSettingRow({
|
||||
}) {
|
||||
const inherited = isInheritedSetting(setting);
|
||||
const overridden = inherited ? setting.enabled === true : false;
|
||||
const value = inherited
|
||||
? overridden
|
||||
? setting.value
|
||||
: inheritedValue
|
||||
: setting;
|
||||
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
|
||||
|
||||
if (!inherited) {
|
||||
return (
|
||||
@@ -352,6 +335,63 @@ function BooleanSettingRow({
|
||||
);
|
||||
}
|
||||
|
||||
const HTTP_VERSION_OPTIONS: { label: string; value: HttpVersion }[] = [
|
||||
{ label: "Automatic", value: "auto" },
|
||||
{ label: "HTTP/1.1", value: "http1" },
|
||||
{ label: "HTTP/2", value: "http2" },
|
||||
];
|
||||
|
||||
function HttpVersionSettingRow({
|
||||
inheritedValue,
|
||||
setting,
|
||||
settingDefinition,
|
||||
onChange,
|
||||
}: {
|
||||
inheritedValue: HttpVersion;
|
||||
setting: HttpVersionSetting;
|
||||
settingDefinition: RequestSettingDefinition<"settingHttpVersion">;
|
||||
onChange: (setting: HttpVersionSetting) => void;
|
||||
}) {
|
||||
const inherited = isInheritedSetting(setting);
|
||||
const overridden = inherited ? setting.enabled === true : false;
|
||||
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
|
||||
|
||||
if (!inherited) {
|
||||
return (
|
||||
<SettingRow title={settingDefinition.title} description={settingDefinition.description}>
|
||||
<Select
|
||||
hideLabel
|
||||
name={settingDefinition.modelKey}
|
||||
label={settingDefinition.title}
|
||||
size="sm"
|
||||
value={value}
|
||||
options={HTTP_VERSION_OPTIONS}
|
||||
onChange={(value) => onChange(value)}
|
||||
/>
|
||||
</SettingRow>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingOverrideRow
|
||||
title={settingDefinition.title}
|
||||
description={settingDefinition.description}
|
||||
overridden={overridden}
|
||||
onResetOverride={() => onChange({ ...setting, enabled: false })}
|
||||
>
|
||||
<Select
|
||||
hideLabel
|
||||
name={settingDefinition.modelKey}
|
||||
label={settingDefinition.title}
|
||||
size="sm"
|
||||
value={value}
|
||||
options={HTTP_VERSION_OPTIONS}
|
||||
onChange={(value) => onChange({ ...setting, enabled: true, value })}
|
||||
/>
|
||||
</SettingOverrideRow>
|
||||
);
|
||||
}
|
||||
|
||||
function IntegerSettingRow({
|
||||
inheritedValue,
|
||||
setting,
|
||||
@@ -365,18 +405,11 @@ function IntegerSettingRow({
|
||||
}) {
|
||||
const inherited = isInheritedSetting(setting);
|
||||
const overridden = inherited ? setting.enabled === true : false;
|
||||
const value = inherited
|
||||
? overridden
|
||||
? setting.value
|
||||
: inheritedValue
|
||||
: setting;
|
||||
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
|
||||
|
||||
if (!inherited) {
|
||||
return (
|
||||
<SettingRow
|
||||
title={settingDefinition.title}
|
||||
description={settingDefinition.description}
|
||||
>
|
||||
<SettingRow title={settingDefinition.title} description={settingDefinition.description}>
|
||||
<NumberUnitInput
|
||||
name={settingDefinition.modelKey}
|
||||
label={settingDefinition.title}
|
||||
@@ -429,20 +462,13 @@ function MessageSizeSettingRow({
|
||||
}) {
|
||||
const inherited = isInheritedSetting(setting);
|
||||
const overridden = inherited ? setting.enabled === true : false;
|
||||
const value = inherited
|
||||
? overridden
|
||||
? setting.value
|
||||
: inheritedValue
|
||||
: setting;
|
||||
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
|
||||
const displayValue = formatMegabytes(value);
|
||||
const placeholder = "0";
|
||||
|
||||
if (!inherited) {
|
||||
return (
|
||||
<SettingRow
|
||||
title={settingDefinition.title}
|
||||
description={settingDefinition.description}
|
||||
>
|
||||
<SettingRow title={settingDefinition.title} description={settingDefinition.description}>
|
||||
<MessageSizeInput
|
||||
name={settingDefinition.modelKey}
|
||||
label={settingDefinition.title}
|
||||
@@ -567,13 +593,18 @@ function resolveInheritedValue(
|
||||
key: BooleanWorkspaceSettingKey,
|
||||
fallback: BooleanSetting,
|
||||
): boolean;
|
||||
function resolveInheritedValue(
|
||||
ancestors: (Folder | Workspace)[],
|
||||
key: "settingHttpVersion",
|
||||
fallback: HttpVersionSetting,
|
||||
): HttpVersion;
|
||||
function resolveInheritedValue(
|
||||
ancestors: (Folder | Workspace)[],
|
||||
key: keyof WorkspaceSettings,
|
||||
fallback: BooleanSetting | IntegerSetting,
|
||||
fallback: BooleanSetting | IntegerSetting | HttpVersionSetting,
|
||||
) {
|
||||
for (const ancestor of ancestors) {
|
||||
const setting = ancestor[key] as BooleanSetting | IntegerSetting;
|
||||
const setting = ancestor[key] as BooleanSetting | IntegerSetting | HttpVersionSetting;
|
||||
if (isInheritedSetting(setting)) {
|
||||
if (setting.enabled === true) {
|
||||
return setting.value;
|
||||
@@ -589,6 +620,7 @@ function resolveInheritedValue(
|
||||
type WorkspaceSettings = Pick<
|
||||
Workspace,
|
||||
| "settingFollowRedirects"
|
||||
| "settingHttpVersion"
|
||||
| "settingRequestMessageSize"
|
||||
| "settingRequestTimeout"
|
||||
| "settingSendCookies"
|
||||
@@ -598,14 +630,12 @@ type WorkspaceSettings = Pick<
|
||||
|
||||
type BooleanWorkspaceSettingKey = Exclude<
|
||||
keyof WorkspaceSettings,
|
||||
"settingRequestTimeout" | "settingRequestMessageSize"
|
||||
"settingRequestTimeout" | "settingRequestMessageSize" | "settingHttpVersion"
|
||||
>;
|
||||
|
||||
function formatMegabytes(bytes: number) {
|
||||
const megabytes = bytes / BYTES_PER_MB;
|
||||
return Number.isInteger(megabytes)
|
||||
? `${megabytes}`
|
||||
: megabytes.toFixed(3).replace(/\.?0+$/, "");
|
||||
return Number.isInteger(megabytes) ? `${megabytes}` : megabytes.toFixed(3).replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
function parseMegabytes(value: string) {
|
||||
@@ -626,9 +656,5 @@ function isValidInteger(value: string) {
|
||||
function isValidMegabytes(value: string) {
|
||||
if (value === "") return true;
|
||||
const megabytes = Number(value);
|
||||
return (
|
||||
Number.isFinite(megabytes) &&
|
||||
megabytes >= 0 &&
|
||||
megabytes <= MAX_MESSAGE_SIZE_MB
|
||||
);
|
||||
return Number.isFinite(megabytes) && megabytes >= 0 && megabytes <= MAX_MESSAGE_SIZE_MB;
|
||||
}
|
||||
@@ -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, 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;
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useSearch } from "@tanstack/react-router";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { useLicense } from "@yaakapp-internal/license";
|
||||
import { pluginsAtom, settingsAtom } from "@yaakapp-internal/models";
|
||||
@@ -20,6 +19,8 @@ import { SettingsProxy } from "./SettingsProxy";
|
||||
import { SettingsTheme } from "./SettingsTheme";
|
||||
|
||||
interface Props {
|
||||
tab?: SettingsTabWithSubtab | null;
|
||||
/** Set when Settings is in a dialog rather than owning a window. */
|
||||
hide?: () => void;
|
||||
}
|
||||
|
||||
@@ -42,25 +43,19 @@ const tabs = [
|
||||
TAB_LICENSE,
|
||||
] as const;
|
||||
export type SettingsTab = (typeof tabs)[number];
|
||||
export type SettingsTabWithSubtab = SettingsTab | `${SettingsTab}:${string}`;
|
||||
|
||||
export default function Settings({ hide }: Props) {
|
||||
const { tab: tabFromQuery } = useSearch({ from: "/workspaces/$workspaceId/settings" });
|
||||
export default function Settings({ tab, hide }: Props) {
|
||||
// Parse tab and subtab (e.g., "plugins:installed")
|
||||
const [mainTab, subtab] = tabFromQuery?.split(":") ?? [];
|
||||
const [mainTab, subtab] = tab?.split(":") ?? [];
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const plugins = useAtomValue(pluginsAtom);
|
||||
const licenseCheck = useLicense();
|
||||
|
||||
// Close settings window on escape
|
||||
// Close settings window on escape. In a dialog, the dialog handles Escape itself.
|
||||
// TODO: Could this be put in a better place? Eg. in Rust key listener when creating the window
|
||||
useKeyPressEvent("Escape", async () => {
|
||||
if (hide != null) {
|
||||
// It's being shown in a dialog, so close the dialog
|
||||
hide();
|
||||
} else {
|
||||
// It's being shown in a window, so close the window
|
||||
await platform.window.close();
|
||||
}
|
||||
if (hide == null) await platform.window.close();
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -90,7 +85,7 @@ export default function Settings({ hide }: Props) {
|
||||
)}
|
||||
<Tabs
|
||||
layout="horizontal"
|
||||
defaultValue={mainTab || tabFromQuery}
|
||||
defaultValue={mainTab}
|
||||
addBorders
|
||||
tabListClassName="min-w-40 bg-surface x-theme-sidebar border-r border-border pl-3"
|
||||
label="Settings"
|
||||
|
||||
@@ -124,7 +124,7 @@ export function SettingsHotkeys() {
|
||||
<HotkeyRow
|
||||
key={action}
|
||||
action={action}
|
||||
currentKeys={hotkeys[action]}
|
||||
currentKeys={hotkeys[action] ?? []}
|
||||
defaultKeys={defaultHotkeys[action]}
|
||||
onSave={async (keys) => {
|
||||
const newHotkeys = { ...settings.hotkeys };
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Icon } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import type { CheckboxProps } from "./Checkbox";
|
||||
import { Checkbox } from "./Checkbox";
|
||||
|
||||
export interface CheckboxTreeNode<T> {
|
||||
key: string;
|
||||
data: T;
|
||||
children: CheckboxTreeNode<T>[];
|
||||
}
|
||||
|
||||
interface Props<T> {
|
||||
node: CheckboxTreeNode<T>;
|
||||
depth?: number;
|
||||
/** Return "hidden" to render row alignment space instead of a checkbox */
|
||||
checked: (node: CheckboxTreeNode<T>) => CheckboxProps["checked"] | "hidden";
|
||||
onCheck: (node: CheckboxTreeNode<T>, checked: boolean) => void;
|
||||
checkboxTitle?: (node: CheckboxTreeNode<T>) => string;
|
||||
isCheckboxDisabled?: (node: CheckboxTreeNode<T>) => boolean;
|
||||
/** An irrelevant row is hidden unless one of its descendants is relevant */
|
||||
isRelevant: (node: CheckboxTreeNode<T>) => boolean;
|
||||
/** A node that starts collapsed, so a large subtree doesn't crowd out the rest */
|
||||
isCollapsedByDefault?: (node: CheckboxTreeNode<T>) => boolean;
|
||||
renderRow: (node: CheckboxTreeNode<T>) => ReactNode;
|
||||
onSelectRow?: (node: CheckboxTreeNode<T>) => void;
|
||||
canSelectRow?: (node: CheckboxTreeNode<T>) => boolean;
|
||||
isRowSelected?: (node: CheckboxTreeNode<T>) => boolean;
|
||||
}
|
||||
|
||||
export function CheckboxTree<T>(props: Props<T>) {
|
||||
const { node, depth = 0 } = props;
|
||||
const [collapsed, setCollapsed] = useState<boolean>(
|
||||
() => props.isCollapsedByDefault?.(node) ?? false,
|
||||
);
|
||||
if (!hasRelevantNode(node, props.isRelevant)) return null;
|
||||
|
||||
const checked = props.checked(node);
|
||||
const selected = props.isRowSelected?.(node) ?? false;
|
||||
const selectable = props.onSelectRow != null && (props.canSelectRow?.(node) ?? true);
|
||||
const hasVisibleChildren = node.children.some((c) => hasRelevantNode(c, props.isRelevant));
|
||||
const rowContent = (
|
||||
<div className="flex-1 min-w-0 flex items-center gap-1 px-1 py-0.5 text-left">
|
||||
{props.renderRow(node)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
depth > 0 && "pl-4 ml-2 border-l border-dashed border-border-subtle relative",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
"relative flex gap-1 w-full h-xs items-center",
|
||||
selected ? "text-text" : "text-text-subtle",
|
||||
)}
|
||||
>
|
||||
{selected && (
|
||||
<div className="absolute left-[-100vw] right-0 top-0 bottom-0 bg-surface-active opacity-30 -z-10" />
|
||||
)}
|
||||
{hasVisibleChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={collapsed ? "Expand" : "Collapse"}
|
||||
aria-expanded={!collapsed}
|
||||
className="shrink-0 text-text-subtlest hocus:text-text"
|
||||
onClick={() => setCollapsed((v) => !v)}
|
||||
>
|
||||
<Icon size="sm" icon={collapsed ? "chevron_right" : "chevron_down"} />
|
||||
</button>
|
||||
) : (
|
||||
<span aria-hidden className="w-4 shrink-0" />
|
||||
)}
|
||||
{checked === "hidden" ? (
|
||||
<span aria-hidden className="w-4 mr-0.5 shrink-0" />
|
||||
) : (
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
title={props.checkboxTitle?.(node) ?? "Toggle"}
|
||||
hideLabel
|
||||
disabled={props.isCheckboxDisabled?.(node)}
|
||||
onChange={(checked) => props.onCheck(node, checked)}
|
||||
/>
|
||||
)}
|
||||
{selectable ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 min-w-0 flex text-left"
|
||||
onClick={() => props.onSelectRow?.(node)}
|
||||
>
|
||||
{rowContent}
|
||||
</button>
|
||||
) : (
|
||||
rowContent
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!collapsed &&
|
||||
node.children.map((child) => (
|
||||
<CheckboxTree key={child.key} {...props} node={child} depth={depth + 1} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function hasRelevantNode<T>(
|
||||
node: CheckboxTreeNode<T>,
|
||||
isRelevant: (node: CheckboxTreeNode<T>) => boolean,
|
||||
): boolean {
|
||||
return isRelevant(node) || node.children.some((c) => hasRelevantNode(c, isRelevant));
|
||||
}
|
||||
@@ -62,7 +62,7 @@ export function Confirm({
|
||||
/>
|
||||
)}
|
||||
<HStack space={2} justifyContent="start" className="mt-2 mb-4 flex-row-reverse">
|
||||
<Button type="submit" color={color} disabled={!didConfirm}>
|
||||
<Button autoFocus={!requireTyping} type="submit" color={color} disabled={!didConfirm}>
|
||||
{confirmText ?? "Confirm"}
|
||||
</Button>
|
||||
<Button onClick={handleHide} variant="border">
|
||||
|
||||
@@ -10,11 +10,13 @@ export interface DialogProps {
|
||||
children: ReactNode;
|
||||
open: boolean;
|
||||
onClose?: () => void;
|
||||
disableBackdropClose?: boolean;
|
||||
/** Block dismissal from the backdrop, Escape key, and built-in close button. */
|
||||
disableClose?: boolean;
|
||||
title?: ReactNode;
|
||||
description?: ReactNode;
|
||||
className?: string;
|
||||
size?: DialogSize;
|
||||
/** Hide the built-in close button without changing backdrop or Escape behavior. */
|
||||
hideX?: boolean;
|
||||
noPadding?: boolean;
|
||||
noScroll?: boolean;
|
||||
@@ -27,7 +29,7 @@ export function Dialog({
|
||||
size = "full",
|
||||
open,
|
||||
onClose,
|
||||
disableBackdropClose,
|
||||
disableClose,
|
||||
title,
|
||||
description,
|
||||
hideX,
|
||||
@@ -42,7 +44,7 @@ export function Dialog({
|
||||
);
|
||||
|
||||
return (
|
||||
<Overlay open={open} onClose={disableBackdropClose ? undefined : onClose} portalName="dialog">
|
||||
<Overlay open={open} onClose={disableClose ? undefined : onClose} portalName="dialog">
|
||||
<div
|
||||
role="dialog"
|
||||
className={classNames(
|
||||
@@ -58,7 +60,7 @@ export function Dialog({
|
||||
// NOTE: We handle Escape on the element itself so that it doesn't close multiple
|
||||
// dialogs and can be intercepted by children if needed.
|
||||
if (e.key === "Escape") {
|
||||
onClose?.();
|
||||
if (!disableClose) onClose?.();
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
@@ -110,7 +112,7 @@ export function Dialog({
|
||||
</div>
|
||||
|
||||
{/*Put close at the end so that it's the last thing to be tabbed to*/}
|
||||
{!hideX && (
|
||||
{!disableClose && !hideX && (
|
||||
<div className="ml-auto absolute right-1 top-1">
|
||||
<IconButton
|
||||
className="opacity-70 hover:opacity-100"
|
||||
|
||||
@@ -601,6 +601,8 @@ function getExtensions({
|
||||
EditorView.contentAttributes.of({
|
||||
autocapitalize: "off",
|
||||
autocorrect: "off",
|
||||
// Keeps macOS Writing Tools from offering to write code for us
|
||||
writingsuggestions: "false",
|
||||
}),
|
||||
EditorView.domEventHandlers({
|
||||
focus: () => {
|
||||
|
||||
@@ -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,202 @@
|
||||
import { SearchQuery } from "@codemirror/search";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import {
|
||||
currentMatch,
|
||||
literalSearch,
|
||||
MAX_COUNT,
|
||||
MatchCounter,
|
||||
normalizeDoc,
|
||||
normalizeSearch,
|
||||
scanNormalized,
|
||||
scanQuery,
|
||||
} from "./searchMatchCount";
|
||||
|
||||
type QueryConfig = ConstructorParameters<typeof SearchQuery>[0];
|
||||
|
||||
const stateOf = (doc: string) => EditorState.create({ doc });
|
||||
|
||||
/**
|
||||
* The matches the counter finds, having checked them against the search panel's own cursor.
|
||||
*
|
||||
* The cursor decides which ranges the editor highlights and which one `find next` lands on, so
|
||||
* a count that doesn't agree with it is a wrong count, however fast it was to produce.
|
||||
*/
|
||||
function matchesOf(doc: string, config: QueryConfig) {
|
||||
const state = stateOf(doc);
|
||||
const query = new SearchQuery(config);
|
||||
const matches = new MatchCounter().matches(state, query);
|
||||
expect(matches).toEqual(scanQuery(state, query));
|
||||
return matches;
|
||||
}
|
||||
|
||||
const countOf = (doc: string, config: QueryConfig) => matchesOf(doc, config).length;
|
||||
|
||||
describe("counting", () => {
|
||||
test("counts every match, whatever the case", () => {
|
||||
expect(countOf("one Two three two", { search: "two" })).toBe(2);
|
||||
expect(countOf("one Two three two", { search: "two", caseSensitive: true })).toBe(1);
|
||||
});
|
||||
|
||||
test("skips matches overlapping an earlier one", () => {
|
||||
expect(countOf("aaaaa", { search: "aa" })).toBe(2);
|
||||
expect(countOf("ababa", { search: "aba" })).toBe(1);
|
||||
});
|
||||
|
||||
test("treats a query as text, not as a pattern", () => {
|
||||
expect(countOf("a.b axb", { search: "a.b" })).toBe(1);
|
||||
});
|
||||
|
||||
test("unquotes escapes unless the query is literal", () => {
|
||||
expect(countOf("one\ntwo\nthree", { search: "\\n" })).toBe(2);
|
||||
expect(countOf("one\\ntwo", { search: "\\n", literal: true })).toBe(1);
|
||||
});
|
||||
|
||||
test("counts regexp and whole word queries through the cursor", () => {
|
||||
expect(literalSearch(new SearchQuery({ search: "a", regexp: true }))).toBe(null);
|
||||
expect(literalSearch(new SearchQuery({ search: "a", wholeWord: true }))).toBe(null);
|
||||
expect(countOf("a1 b2 c3", { search: "[a-z]\\d", regexp: true })).toBe(3);
|
||||
expect(countOf("cat cats cat", { search: "cat", wholeWord: true })).toBe(2);
|
||||
});
|
||||
|
||||
test("stops counting at the cap", () => {
|
||||
expect(countOf("x".repeat(MAX_COUNT + 100), { search: "x" })).toBe(MAX_COUNT + 1);
|
||||
});
|
||||
|
||||
test("reports where the matches are", () => {
|
||||
expect(matchesOf("ab..ab", { search: "ab" })).toEqual([
|
||||
{ from: 0, to: 2 },
|
||||
{ from: 4, to: 6 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("finds nothing to match with an empty needle", () => {
|
||||
expect(scanNormalized(normalizeDoc("abc", false), "")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalization", () => {
|
||||
test("finds what a character decomposes into", () => {
|
||||
// The é is one character holding an `e`, and the match covers the whole of it
|
||||
expect(matchesOf("café", { search: "e" })).toEqual([{ from: 3, to: 4 }]);
|
||||
expect(matchesOf("file", { search: "fi" })).toEqual([{ from: 0, to: 1 }]);
|
||||
expect(matchesOf("a…b", { search: "..." })).toEqual([{ from: 1, to: 2 }]);
|
||||
expect(countOf("one two", { search: "one two" })).toBe(1);
|
||||
expect(countOf("full width", { search: "full" })).toBe(1);
|
||||
});
|
||||
|
||||
test("matches a decomposed query against composed text, and the reverse", () => {
|
||||
expect(countOf("café", { search: "café" })).toBe(1);
|
||||
expect(countOf("café", { search: "café" })).toBe(1);
|
||||
expect(countOf("café", { search: "café" })).toBe(1);
|
||||
});
|
||||
|
||||
test("keeps offsets straight after an expansion", () => {
|
||||
expect(matchesOf("é.é.end", { search: "end" })).toEqual([{ from: 4, to: 7 }]);
|
||||
expect(matchesOf("fififi stop", { search: "stop" })).toEqual([{ from: 4, to: 8 }]);
|
||||
});
|
||||
|
||||
test("normalizes the query whole, the document by character", () => {
|
||||
expect(normalizeSearch("CAFÉ", false)).toBe("café");
|
||||
expect(normalizeSearch("CAFÉ", true)).toBe("CAFÉ");
|
||||
// Whole-string NFKD would fold this to a final sigma, which the cursor never does
|
||||
expect(normalizeDoc("ΟΔΟΣ", false).text).toBe("οδοσ");
|
||||
});
|
||||
|
||||
test("leaves a document that normalizes to itself untouched", () => {
|
||||
const { text, expansions } = normalizeDoc("plain 日本 🎉 text", false);
|
||||
expect(text).toBe("plain 日本 🎉 text");
|
||||
expect(expansions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("current match", () => {
|
||||
const matches = [
|
||||
{ from: 0, to: 2 },
|
||||
{ from: 4, to: 6 },
|
||||
{ from: 8, to: 10 },
|
||||
];
|
||||
|
||||
test("counts from one, and reports 0 off a match", () => {
|
||||
expect(currentMatch(matches, { from: 4, to: 6 })).toBe(2);
|
||||
expect(currentMatch(matches, { from: 8, to: 10 })).toBe(3);
|
||||
expect(currentMatch(matches, { from: 5, to: 5 })).toBe(2);
|
||||
expect(currentMatch(matches, { from: 2, to: 3 })).toBe(0);
|
||||
expect(currentMatch(matches, { from: 4, to: 7 })).toBe(0);
|
||||
expect(currentMatch([], { from: 0, to: 0 })).toBe(0);
|
||||
});
|
||||
|
||||
test("moving the selection doesn't scan again", () => {
|
||||
const state = stateOf("a1 b2 c3");
|
||||
const query = new SearchQuery({ search: "\\d", regexp: true });
|
||||
const counter = new MatchCounter();
|
||||
const found = counter.matches(state, query);
|
||||
|
||||
// The document a selection-only transaction leaves behind is the one already scanned
|
||||
const moved = state.update({ selection: { anchor: 4, head: 5 } }).state;
|
||||
expect(counter.matches(moved, query)).toBe(found);
|
||||
expect(currentMatch(found, moved.selection.main)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The mapping from normalized offsets back to document offsets is the part of this that can go
|
||||
* quietly wrong, and only on input nobody thinks to write a case for. So generate the input.
|
||||
*/
|
||||
describe("against the cursor, on awkward text", () => {
|
||||
const ALPHABET = [
|
||||
..."abcABC .\\\n".split(""),
|
||||
"é",
|
||||
"é",
|
||||
"fi",
|
||||
"…",
|
||||
" ",
|
||||
"İ",
|
||||
"Σ",
|
||||
"ς",
|
||||
"日",
|
||||
"🎉",
|
||||
"Ⅻ",
|
||||
"f",
|
||||
"①",
|
||||
"́",
|
||||
];
|
||||
|
||||
/** Seeded, so a failure is the same failure next run */
|
||||
function random(seed: number) {
|
||||
let state = seed;
|
||||
return () => {
|
||||
state = (state * 1664525 + 1013904223) >>> 0;
|
||||
return state / 2 ** 32;
|
||||
};
|
||||
}
|
||||
|
||||
for (const caseSensitive of [false, true]) {
|
||||
test(`agrees on every generated document (caseSensitive: ${caseSensitive})`, () => {
|
||||
const next = random(caseSensitive ? 20260831 : 7);
|
||||
|
||||
for (let round = 0; round < 400; round++) {
|
||||
const doc = Array.from(
|
||||
{ length: 2 + Math.floor(next() * 60) },
|
||||
() => ALPHABET[Math.floor(next() * ALPHABET.length)]!,
|
||||
).join("");
|
||||
|
||||
// Half the queries are lifted out of the document, so matches are actually found
|
||||
const start = Math.floor(next() * doc.length);
|
||||
const search =
|
||||
next() < 0.5
|
||||
? doc.slice(start, start + 1 + Math.floor(next() * 3))
|
||||
: Array.from(
|
||||
{ length: 1 + Math.floor(next() * 2) },
|
||||
() => ALPHABET[Math.floor(next() * ALPHABET.length)]!,
|
||||
).join("");
|
||||
if (search === "") continue;
|
||||
|
||||
const state = stateOf(doc);
|
||||
const query = new SearchQuery({ search, caseSensitive });
|
||||
const where = `doc=${JSON.stringify(doc)} search=${JSON.stringify(search)}`;
|
||||
expect(new MatchCounter().matches(state, query), where).toEqual(scanQuery(state, query));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,7 +1,232 @@
|
||||
import { getSearchQuery, searchPanelOpen } from "@codemirror/search";
|
||||
import type { Extension } from "@codemirror/state";
|
||||
import { getSearchQuery, type SearchQuery, searchPanelOpen } from "@codemirror/search";
|
||||
import type { EditorState, Extension, Text } from "@codemirror/state";
|
||||
import { type EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
|
||||
|
||||
/** Matches are counted no further than this, since an exact total stops being useful long before */
|
||||
export const MAX_COUNT = 9999;
|
||||
|
||||
/** What normalizing rewrites: anything outside ASCII, plus the case it folds */
|
||||
const REWRITTEN = /\P{ASCII}|[A-Z]+/gu;
|
||||
const REWRITTEN_CASE_SENSITIVE = /\P{ASCII}/gu;
|
||||
|
||||
export interface Match {
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
/** A character whose normalized form is a different length, shifting every offset past it */
|
||||
interface Expansion {
|
||||
normFrom: number;
|
||||
normTo: number;
|
||||
docFrom: number;
|
||||
docTo: number;
|
||||
}
|
||||
|
||||
/** A document as SearchCursor compares it, with what's needed to get back to real offsets */
|
||||
export interface NormalizedDoc {
|
||||
text: string;
|
||||
expansions: Expansion[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites a document the way SearchCursor does — NFKD, then a case fold unless the search is
|
||||
* case-sensitive — in a single pass rather than one call per code point.
|
||||
*
|
||||
* The cursor spends 90% of its time asking ICU about one character at a time, which is what
|
||||
* makes counting matches in a large response slow. Doing it a character at a time still matters
|
||||
* for the result, since it keeps NFKD from reordering marks across characters, so the
|
||||
* granularity stays and only the repeated work goes: each distinct character is normalized once
|
||||
* and the answer reused, and ASCII runs never reach ICU at all.
|
||||
*/
|
||||
export function normalizeDoc(text: string, caseSensitive: boolean): NormalizedDoc {
|
||||
const rewritten = new Map<string, string>();
|
||||
const expansions: Expansion[] = [];
|
||||
let shift = 0;
|
||||
|
||||
const normalized = text.replace(
|
||||
caseSensitive ? REWRITTEN_CASE_SENSITIVE : REWRITTEN,
|
||||
(chunk: string, at: number) => {
|
||||
// An ASCII run only ever folds case, which can't change its length
|
||||
if (chunk.charCodeAt(0) < 0x80) return chunk.toLowerCase();
|
||||
|
||||
let out = rewritten.get(chunk);
|
||||
if (out === undefined) {
|
||||
out = chunk.normalize("NFKD");
|
||||
if (!caseSensitive) out = out.toLowerCase();
|
||||
rewritten.set(chunk, out);
|
||||
}
|
||||
|
||||
if (out.length !== chunk.length) {
|
||||
expansions.push({
|
||||
normFrom: at + shift,
|
||||
normTo: at + shift + out.length,
|
||||
docFrom: at,
|
||||
docTo: at + chunk.length,
|
||||
});
|
||||
shift += out.length - chunk.length;
|
||||
}
|
||||
|
||||
return out;
|
||||
},
|
||||
);
|
||||
|
||||
return { text: normalized, expansions };
|
||||
}
|
||||
|
||||
/** The query as SearchCursor compares it, which it normalizes whole rather than by character */
|
||||
export function normalizeSearch(search: string, caseSensitive: boolean): string {
|
||||
const normalized = search.normalize("NFKD");
|
||||
return caseSensitive ? normalized : normalized.toLowerCase();
|
||||
}
|
||||
|
||||
/** Every occurrence of `needle`, skipping matches that overlap an earlier one */
|
||||
export function scanNormalized(doc: NormalizedDoc, needle: string): Match[] {
|
||||
const matches: Match[] = [];
|
||||
if (needle === "") return matches;
|
||||
|
||||
const { text } = doc;
|
||||
let pos = text.indexOf(needle);
|
||||
while (pos >= 0) {
|
||||
let end = pos + needle.length;
|
||||
// However the query was cut, a match ends on a whole code point, as the cursor's do
|
||||
if (isLowSurrogate(text.charCodeAt(end))) end++;
|
||||
|
||||
matches.push({ from: docStart(doc, pos), to: docEnd(doc, end) });
|
||||
if (matches.length > MAX_COUNT) break;
|
||||
|
||||
pos = text.indexOf(needle, resumeAfter(doc, end));
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/** The same through the query's own cursor, which handles regexps and whole words */
|
||||
export function scanQuery(state: EditorState, query: SearchQuery): Match[] {
|
||||
const matches: Match[] = [];
|
||||
const cursor = query.getCursor(state);
|
||||
|
||||
for (let result = cursor.next(); !result.done; result = cursor.next()) {
|
||||
matches.push({ from: result.value.from, to: result.value.to });
|
||||
if (matches.length > MAX_COUNT) break;
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
const isLowSurrogate = (code: number) => code >= 0xdc00 && code <= 0xdfff;
|
||||
|
||||
/** The last character expansion beginning at or before `offset`, if there is one */
|
||||
function expansionAt({ expansions }: NormalizedDoc, offset: number): Expansion | null {
|
||||
let low = 0;
|
||||
let high = expansions.length - 1;
|
||||
let found: Expansion | null = null;
|
||||
|
||||
while (low <= high) {
|
||||
const mid = (low + high) >> 1;
|
||||
if (expansions[mid]!.normFrom <= offset) {
|
||||
found = expansions[mid]!;
|
||||
low = mid + 1;
|
||||
} else {
|
||||
high = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
function docStart(doc: NormalizedDoc, offset: number): number {
|
||||
const expansion = expansionAt(doc, offset);
|
||||
if (expansion == null) return offset;
|
||||
// A match starting inside a character's expansion starts at the character
|
||||
return offset < expansion.normTo
|
||||
? expansion.docFrom
|
||||
: offset - (expansion.normTo - expansion.docTo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where scanning picks up after a match ending at `offset`.
|
||||
*
|
||||
* The cursor moves through the document a character at a time, so once a match ends inside a
|
||||
* character's expansion the rest of that expansion is behind it — "…" holds three dots but only
|
||||
* ever counts as one match of ".".
|
||||
*/
|
||||
function resumeAfter(doc: NormalizedDoc, offset: number): number {
|
||||
const expansion = expansionAt(doc, offset);
|
||||
return expansion != null && offset > expansion.normFrom && offset < expansion.normTo
|
||||
? expansion.normTo
|
||||
: offset;
|
||||
}
|
||||
|
||||
function docEnd(doc: NormalizedDoc, offset: number): number {
|
||||
const expansion = expansionAt(doc, offset);
|
||||
if (expansion == null) return offset;
|
||||
if (offset <= expansion.normFrom) return expansion.docFrom;
|
||||
// A match ending inside a character's expansion covers the whole character
|
||||
return offset < expansion.normTo
|
||||
? expansion.docTo
|
||||
: offset - (expansion.normTo - expansion.docTo);
|
||||
}
|
||||
|
||||
/** Position of the match holding the selection, counting from one, or 0 when it isn't on one */
|
||||
export function currentMatch(matches: Match[], selection: { from: number; to: number }): number {
|
||||
let index = 0;
|
||||
for (const match of matches) {
|
||||
index++;
|
||||
if (match.from <= selection.from && match.to >= selection.to) return index;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** The text a plain query looks for, or null when only the cursor can answer it */
|
||||
export function literalSearch(query: SearchQuery): string | null {
|
||||
if (query.regexp || query.wholeWord || query.test != null) return null;
|
||||
// Mirrors SearchQuery's own unquoting, which the published type doesn't expose
|
||||
return query.literal
|
||||
? query.search
|
||||
: query.search.replace(/\\([nrt\\])/g, (_, ch) =>
|
||||
ch === "n" ? "\n" : ch === "r" ? "\r" : ch === "t" ? "\t" : "\\",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the matches for the search panel, keeping the normalized document and the matches it
|
||||
* last found, so neither moving the selection nor typing another character starts over.
|
||||
*/
|
||||
export class MatchCounter {
|
||||
private doc: { doc: Text; caseSensitive: boolean; normalized: NormalizedDoc } | null = null;
|
||||
private last: { doc: Text; query: SearchQuery; matches: Match[] } | null = null;
|
||||
|
||||
matches(state: EditorState, query: SearchQuery): Match[] {
|
||||
const last = this.last;
|
||||
if (last != null && last.doc === state.doc && last.query.eq(query)) {
|
||||
return last.matches;
|
||||
}
|
||||
|
||||
const matches = this.scan(state, query);
|
||||
this.last = { doc: state.doc, query, matches };
|
||||
return matches;
|
||||
}
|
||||
|
||||
private scan(state: EditorState, query: SearchQuery): Match[] {
|
||||
const search = literalSearch(query);
|
||||
if (search == null) return scanQuery(state, query);
|
||||
|
||||
const doc = this.normalizedDoc(state.doc, query.caseSensitive);
|
||||
return scanNormalized(doc, normalizeSearch(search, query.caseSensitive));
|
||||
}
|
||||
|
||||
private normalizedDoc(doc: Text, caseSensitive: boolean): NormalizedDoc {
|
||||
const cached = this.doc;
|
||||
if (cached != null && cached.doc === doc && cached.caseSensitive === caseSensitive) {
|
||||
return cached.normalized;
|
||||
}
|
||||
|
||||
const normalized = normalizeDoc(doc.toString(), caseSensitive);
|
||||
this.doc = { doc, caseSensitive, normalized };
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A CodeMirror extension that displays the total number of search matches
|
||||
* inside the built-in search panel.
|
||||
@@ -10,6 +235,7 @@ export function searchMatchCount(): Extension {
|
||||
return ViewPlugin.fromClass(
|
||||
class {
|
||||
private countEl: HTMLElement | null = null;
|
||||
private counter = new MatchCounter();
|
||||
|
||||
constructor(private view: EditorView) {
|
||||
this.updateCount();
|
||||
@@ -38,38 +264,21 @@ export function searchMatchCount(): Extension {
|
||||
}
|
||||
|
||||
this.ensureCountEl();
|
||||
if (this.countEl == null) return;
|
||||
|
||||
if (!query.search) {
|
||||
if (this.countEl) {
|
||||
this.countEl.textContent = "0/0";
|
||||
}
|
||||
this.countEl.textContent = "0/0";
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = state.selection.main;
|
||||
let count = 0;
|
||||
let currentIndex = 0;
|
||||
const MAX_COUNT = 9999;
|
||||
const cursor = query.getCursor(state);
|
||||
for (let result = cursor.next(); !result.done; result = cursor.next()) {
|
||||
count++;
|
||||
const match = result.value;
|
||||
if (match.from <= selection.from && match.to >= selection.to) {
|
||||
currentIndex = count;
|
||||
}
|
||||
if (count > MAX_COUNT) break;
|
||||
}
|
||||
|
||||
if (this.countEl) {
|
||||
if (count > MAX_COUNT) {
|
||||
this.countEl.textContent = `${MAX_COUNT}+`;
|
||||
} else if (count === 0) {
|
||||
this.countEl.textContent = "0/0";
|
||||
} else if (currentIndex > 0) {
|
||||
this.countEl.textContent = `${currentIndex}/${count}`;
|
||||
} else {
|
||||
this.countEl.textContent = `0/${count}`;
|
||||
}
|
||||
const matches = this.counter.matches(state, query);
|
||||
if (matches.length > MAX_COUNT) {
|
||||
this.countEl.textContent = `${MAX_COUNT}+`;
|
||||
} else if (matches.length === 0) {
|
||||
this.countEl.textContent = "0/0";
|
||||
} else {
|
||||
const current = currentMatch(matches, state.selection.main);
|
||||
this.countEl.textContent = `${current}/${matches.length}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -113,7 +113,15 @@ function toPairData({ commitName: _commitName, ...pair }: EditablePairWithId): P
|
||||
/** Max number of pairs to show before prompting the user to reveal the rest */
|
||||
const MAX_INITIAL_PAIRS = 30;
|
||||
|
||||
export function PairEditor({
|
||||
// Keyed on `stateKey` so no state survives a change of owner. Row ids alone can't tell owners
|
||||
// apart — two pair sets can share ids (eg. one duplicated from the other), and the same-rows
|
||||
// fast path below would swap in the new data without rebuilding the row editors, leaving any
|
||||
// still-focused input showing the old owner's text.
|
||||
export function PairEditor(props: PairEditorProps) {
|
||||
return <PairEditorInner key={props.stateKey} {...props} />;
|
||||
}
|
||||
|
||||
function PairEditorInner({
|
||||
allowFileValues,
|
||||
allowMultilineValues,
|
||||
className,
|
||||
|
||||
@@ -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"))}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -119,9 +119,13 @@ export function Select<T extends string>({
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
className="w-full text-sm font-mono"
|
||||
className={classNames(
|
||||
"w-full text-sm font-mono",
|
||||
disabled && "border-dotted",
|
||||
isInvalidSelection && "border-danger",
|
||||
)}
|
||||
justify="start"
|
||||
variant="border"
|
||||
variant="input"
|
||||
size={size}
|
||||
leftSlot={leftSlot}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -21,6 +21,8 @@ import { CommercialUseBanner } from "../CommercialUseBanner";
|
||||
import { Button } from "../core/Button";
|
||||
import type { CheckboxProps } from "../core/Checkbox";
|
||||
import { Checkbox } from "../core/Checkbox";
|
||||
import type { CheckboxTreeNode } from "../core/CheckboxTree";
|
||||
import { CheckboxTree } from "../core/CheckboxTree";
|
||||
import { DiffViewer } from "../core/Editor/DiffViewer";
|
||||
import { Input } from "../core/Input";
|
||||
import { Separator } from "../core/Separator";
|
||||
@@ -43,10 +45,7 @@ interface CommitTreeNode {
|
||||
|
||||
export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
||||
const callbacks = useGitCallbacks(syncDir);
|
||||
const [{ status }, { commit, commitAndPush, add, unstage, restore }] = useGit(
|
||||
syncDir,
|
||||
callbacks,
|
||||
);
|
||||
const [{ status }, { commit, commitAndPush, add, unstage, restore }] = useGit(syncDir, callbacks);
|
||||
const [isPushing, setIsPushing] = useState(false);
|
||||
const [commitError, setCommitError] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string>("");
|
||||
@@ -143,6 +142,15 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
||||
return next(workspace, []);
|
||||
}, [workspace, internalEntries]);
|
||||
|
||||
const treeNode: CheckboxTreeNode<CommitTreeNode> | null = useMemo(() => {
|
||||
const toTreeNode = (n: CommitTreeNode): CheckboxTreeNode<CommitTreeNode> => ({
|
||||
key: n.status.relaPath + n.status.status + n.status.staged,
|
||||
data: n,
|
||||
children: n.children.map(toTreeNode),
|
||||
});
|
||||
return tree == null ? null : toTreeNode(tree);
|
||||
}, [tree]);
|
||||
|
||||
const checkNode = useCallback(
|
||||
(treeNode: CommitTreeNode) => {
|
||||
const checked = nodeCheckedStatus(treeNode);
|
||||
@@ -190,7 +198,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
||||
[restore],
|
||||
);
|
||||
|
||||
if (tree == null) {
|
||||
if (tree == null || treeNode == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -221,12 +229,18 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
||||
style={innerStyle}
|
||||
className="h-full overflow-y-auto pb-3 pr-0.5 transform-cpu"
|
||||
>
|
||||
<TreeNodeChildren
|
||||
node={tree}
|
||||
depth={0}
|
||||
onCheck={checkNode}
|
||||
onSelect={handleSelectChild}
|
||||
selectedPath={selectedEntry?.relaPath ?? null}
|
||||
<CheckboxTree
|
||||
node={treeNode}
|
||||
checked={(n) => nodeCheckedStatus(n.data)}
|
||||
onCheck={(n) => checkNode(n.data)}
|
||||
checkboxTitle={(n) =>
|
||||
nodeCheckedStatus(n.data) ? "Unstage change" : "Stage change"
|
||||
}
|
||||
isRelevant={(n) => n.data.status.status !== "current"}
|
||||
canSelectRow={(n) => n.data.status.status !== "current"}
|
||||
onSelectRow={(n) => handleSelectChild(n.data.status)}
|
||||
isRowSelected={(n) => selectedEntry?.relaPath === n.data.status.relaPath}
|
||||
renderRow={(n) => <CommitTreeRow node={n.data} />}
|
||||
/>
|
||||
{externalEntries.find((e) => e.status !== "current") && (
|
||||
<>
|
||||
@@ -244,10 +258,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
||||
</div>
|
||||
)}
|
||||
secondSlot={({ style: innerStyle }) => (
|
||||
<div
|
||||
style={innerStyle}
|
||||
className="grid grid-rows-[minmax(0,1fr)_auto] gap-3 pb-2"
|
||||
>
|
||||
<div style={innerStyle} className="grid grid-rows-[minmax(0,1fr)_auto] gap-3 pb-2">
|
||||
<Input
|
||||
className="text-base! font-sans rounded-md"
|
||||
placeholder="Commit message..."
|
||||
@@ -301,96 +312,39 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
function TreeNodeChildren({
|
||||
node,
|
||||
depth,
|
||||
onCheck,
|
||||
onSelect,
|
||||
selectedPath,
|
||||
}: {
|
||||
node: CommitTreeNode | null;
|
||||
depth: number;
|
||||
onCheck: (node: CommitTreeNode, checked: boolean) => void;
|
||||
onSelect: (entry: GitStatusEntry) => void;
|
||||
selectedPath: string | null;
|
||||
}) {
|
||||
if (node === null) return null;
|
||||
if (!isNodeRelevant(node)) return null;
|
||||
|
||||
const checked = nodeCheckedStatus(node);
|
||||
const isSelected = selectedPath === node.status.relaPath;
|
||||
|
||||
function CommitTreeRow({ node }: { node: CommitTreeNode }) {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
depth > 0 && "pl-4 ml-2 border-l border-dashed border-border-subtle relative",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
"relative flex gap-1 w-full h-xs items-center",
|
||||
isSelected ? "text-text" : "text-text-subtle",
|
||||
)}
|
||||
>
|
||||
{isSelected && (
|
||||
<div className="absolute left-[-100vw] right-0 top-0 bottom-0 bg-surface-active opacity-30 -z-10" />
|
||||
)}
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
title={checked ? "Unstage change" : "Stage change"}
|
||||
hideLabel
|
||||
onChange={(checked) => onCheck(node, checked)}
|
||||
<>
|
||||
{node.model.model !== "http_request" &&
|
||||
node.model.model !== "grpc_request" &&
|
||||
node.model.model !== "websocket_request" ? (
|
||||
<Icon
|
||||
color="secondary"
|
||||
icon={
|
||||
node.model.model === "folder"
|
||||
? "folder"
|
||||
: node.model.model === "environment"
|
||||
? "variable"
|
||||
: "house"
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={classNames("flex-1 min-w-0 flex items-center gap-1 px-1 py-0.5 text-left")}
|
||||
onClick={() => node.status.status !== "current" && onSelect(node.status)}
|
||||
) : (
|
||||
<span aria-hidden className="w-4" />
|
||||
)}
|
||||
<div className="truncate flex-1">{resolvedModelName(node.model)}</div>
|
||||
{node.status.status !== "current" && (
|
||||
<InlineCode
|
||||
className={classNames(
|
||||
"py-0 bg-transparent w-24 text-center shrink-0",
|
||||
node.status.status === "modified" && "text-info",
|
||||
node.status.status === "untracked" && "text-success",
|
||||
node.status.status === "removed" && "text-danger",
|
||||
)}
|
||||
>
|
||||
{node.model.model !== "http_request" &&
|
||||
node.model.model !== "grpc_request" &&
|
||||
node.model.model !== "websocket_request" ? (
|
||||
<Icon
|
||||
color="secondary"
|
||||
icon={
|
||||
node.model.model === "folder"
|
||||
? "folder"
|
||||
: node.model.model === "environment"
|
||||
? "variable"
|
||||
: "house"
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<span aria-hidden className="w-4" />
|
||||
)}
|
||||
<div className="truncate flex-1">{resolvedModelName(node.model)}</div>
|
||||
{node.status.status !== "current" && (
|
||||
<InlineCode
|
||||
className={classNames(
|
||||
"py-0 bg-transparent w-24 text-center shrink-0",
|
||||
node.status.status === "modified" && "text-info",
|
||||
node.status.status === "untracked" && "text-success",
|
||||
node.status.status === "removed" && "text-danger",
|
||||
)}
|
||||
>
|
||||
{node.status.status}
|
||||
</InlineCode>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{node.children.map((childNode) => {
|
||||
return (
|
||||
<TreeNodeChildren
|
||||
key={childNode.status.relaPath + childNode.status.status + childNode.status.staged}
|
||||
node={childNode}
|
||||
depth={depth + 1}
|
||||
onCheck={onCheck}
|
||||
onSelect={onSelect}
|
||||
selectedPath={selectedPath}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{node.status.status}
|
||||
</InlineCode>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -495,15 +449,6 @@ function setCheckedAndChildren(
|
||||
if (toUnstage.length > 0) unstage({ relaPaths: toUnstage });
|
||||
}
|
||||
|
||||
function isNodeRelevant(node: CommitTreeNode): boolean {
|
||||
if (node.status.status !== "current") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Recursively check children
|
||||
return node.children.some((c) => isNodeRelevant(c));
|
||||
}
|
||||
|
||||
function DiffPanel({
|
||||
entry,
|
||||
onDiscardChanges,
|
||||
@@ -526,13 +471,11 @@ function DiffPanel({
|
||||
size="2xs"
|
||||
variant="border"
|
||||
onClick={() => onDiscardChanges(entry)}
|
||||
>Discard Changes</Button>
|
||||
>
|
||||
Discard Changes
|
||||
</Button>
|
||||
</div>
|
||||
<DiffViewer
|
||||
original={prevYaml ?? ""}
|
||||
modified={nextYaml ?? ""}
|
||||
className="flex-1 min-h-0"
|
||||
/>
|
||||
<DiffViewer original={prevYaml ?? ""} modified={nextYaml ?? ""} className="flex-1 min-h-0" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -86,10 +86,8 @@ export async function promptDivergedStrategy({
|
||||
showDialog({
|
||||
id: "git-diverged",
|
||||
title: "Branches Diverged",
|
||||
hideX: true,
|
||||
size: "sm",
|
||||
disableBackdropClose: true,
|
||||
onClose: () => resolve("cancel"),
|
||||
disableClose: true,
|
||||
render: ({ hide }) =>
|
||||
DivergedDialog({
|
||||
remote,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, test, vi } from "vite-plus/test";
|
||||
import { CsvViewerInner } from "./CsvViewer";
|
||||
|
||||
vi.mock("@yaakapp-internal/ui", () => ({
|
||||
Table: ({ children }: { children: ReactNode }) => <table>{children}</table>,
|
||||
TableBody: ({ children }: { children: ReactNode }) => <tbody>{children}</tbody>,
|
||||
TableCell: ({ children }: { children: ReactNode }) => <td>{children}</td>,
|
||||
TableHead: ({ children }: { children: ReactNode }) => <thead>{children}</thead>,
|
||||
TableHeaderCell: ({ children }: { children: ReactNode }) => <th>{children}</th>,
|
||||
TableRow: ({ children }: { children: ReactNode }) => <tr>{children}</tr>,
|
||||
}));
|
||||
|
||||
describe("CsvViewer", () => {
|
||||
test("renders columns that extend beyond the first row", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<CsvViewerInner
|
||||
text={[
|
||||
"startDate,2026-02-03T00:00-03:00",
|
||||
"endDate,2026-02-03T23:59:59-03:00",
|
||||
"id,Fecha de inicio,Nombre,Estado,Perfil de puesto,ID de sucursal,Sucursal,Fecha de fin,ID de usuario",
|
||||
"391118210,2026-02-03 12:58:55,atencion1,Disponible,ATD,3549,sucursal,2026-02-03 12:59:08,42041",
|
||||
].join("\n")}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain("ID de usuario");
|
||||
expect(markup).toContain("42041");
|
||||
expect(markup.match(/<td>/g)).toHaveLength(20);
|
||||
});
|
||||
});
|
||||
@@ -26,27 +26,33 @@ export function CsvViewer({ text, className }: Props) {
|
||||
export function CsvViewerInner({ text, className }: { text: string | null; className?: string }) {
|
||||
const parsed = useMemo(() => {
|
||||
if (text == null) return null;
|
||||
return Papa.parse<Record<string, string>>(text, { header: true, skipEmptyLines: true });
|
||||
return Papa.parse<string[]>(text, { skipEmptyLines: true });
|
||||
}, [text]);
|
||||
|
||||
if (parsed === null) return null;
|
||||
|
||||
const header = parsed.data[0] ?? [];
|
||||
const rows = parsed.data.slice(1);
|
||||
const columnCount = parsed.data.reduce((count, row) => Math.max(count, row.length), 0);
|
||||
const columnIndexes = Array.from({ length: columnCount }, (_, index) => index);
|
||||
|
||||
return (
|
||||
<div className="overflow-auto h-full">
|
||||
<Table className={classNames(className, "text-sm")}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{parsed.meta.fields?.map((field) => (
|
||||
<TableHeaderCell key={field}>{field}</TableHeaderCell>
|
||||
{columnIndexes.map((columnIndex) => (
|
||||
<TableHeaderCell key={columnIndex}>{header[columnIndex] ?? ""}</TableHeaderCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{parsed.data.map((row, i) => (
|
||||
{rows.map((row, i) => (
|
||||
// oxlint-disable-next-line react/no-array-index-key
|
||||
<TableRow key={i}>
|
||||
{parsed.meta.fields?.map((key) => (
|
||||
<TableCell key={key}>{row[key] ?? ""}</TableCell>
|
||||
{row.map((cell, columnIndex) => (
|
||||
// oxlint-disable-next-line react/no-array-index-key
|
||||
<TableCell key={columnIndex}>{cell}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -112,9 +112,12 @@ export const hotkeysAtom = atom((get) => {
|
||||
// Merge default hotkeys with custom hotkeys from settings
|
||||
// Custom hotkeys override defaults for the same action
|
||||
// An empty array means the hotkey is intentionally disabled
|
||||
const merged: Record<HotkeyAction, string[]> = { ...defaultHotkeys };
|
||||
const merged: Partial<Record<HotkeyAction, string[]>> = {};
|
||||
for (const action of hotkeyActions) {
|
||||
merged[action] = defaultHotkeys[action];
|
||||
}
|
||||
for (const [action, keys] of Object.entries(customHotkeys)) {
|
||||
if (action in defaultHotkeys && Array.isArray(keys)) {
|
||||
if (action in merged && Array.isArray(keys)) {
|
||||
merged[action as HotkeyAction] = keys;
|
||||
}
|
||||
}
|
||||
@@ -122,7 +125,7 @@ export const hotkeysAtom = atom((get) => {
|
||||
});
|
||||
|
||||
/** Helper function to get current hotkeys from the store */
|
||||
function getHotkeys(): Record<HotkeyAction, string[]> {
|
||||
function getHotkeys(): Partial<Record<HotkeyAction, string[]>> {
|
||||
return jotaiStore.get(hotkeysAtom);
|
||||
}
|
||||
|
||||
@@ -165,16 +168,25 @@ const layoutInsensitiveKeys = [
|
||||
"Space",
|
||||
];
|
||||
|
||||
/** Zoom is the browser's own on these keys, so the app has no such action there. */
|
||||
const ZOOM_ACTIONS: HotkeyAction[] = ["app.zoom_in", "app.zoom_out", "app.zoom_reset"];
|
||||
|
||||
/**
|
||||
* The actions this host actually has. An action left out of here has no keys in
|
||||
* `hotkeysAtom`, so it never matches and never claims the keystroke.
|
||||
*/
|
||||
export const hotkeyActions: HotkeyAction[] = (
|
||||
Object.keys(defaultHotkeys) as (keyof typeof defaultHotkeys)[]
|
||||
).sort((a, b) => {
|
||||
const scopeA = a.split(".")[0] || "";
|
||||
const scopeB = b.split(".")[0] || "";
|
||||
if (scopeA !== scopeB) {
|
||||
return scopeA.localeCompare(scopeB);
|
||||
}
|
||||
return hotkeyLabels[a].localeCompare(hotkeyLabels[b]);
|
||||
});
|
||||
)
|
||||
.filter((a) => platform.capabilities.interfaceZoom || !ZOOM_ACTIONS.includes(a))
|
||||
.sort((a, b) => {
|
||||
const scopeA = a.split(".")[0] || "";
|
||||
const scopeB = b.split(".")[0] || "";
|
||||
if (scopeA !== scopeB) {
|
||||
return scopeA.localeCompare(scopeB);
|
||||
}
|
||||
return hotkeyLabels[a].localeCompare(hotkeyLabels[b]);
|
||||
});
|
||||
|
||||
export type HotKeyOptions = {
|
||||
enable?: boolean | (() => boolean);
|
||||
|
||||
@@ -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>-->
|
||||
|
||||
|
||||
@@ -14,9 +14,8 @@ export function showAlert({ id, title, body, size = "sm" }: AlertArgs) {
|
||||
showDialog({
|
||||
id,
|
||||
title,
|
||||
hideX: true,
|
||||
size,
|
||||
disableBackdropClose: true, // Prevent accidental dismisses
|
||||
disableClose: true,
|
||||
render: ({ hide }) => Alert({ onHide: hide, body }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,9 +18,8 @@ export async function showConfirm({
|
||||
return new Promise((onResult: ConfirmProps["onResult"]) => {
|
||||
showDialog({
|
||||
...extraProps,
|
||||
hideX: true,
|
||||
size,
|
||||
disableBackdropClose: true, // Prevent accidental dismisses
|
||||
disableClose: true,
|
||||
render: ({ hide }) => Confirm({ onHide: hide, color, onResult, confirmText, requireTyping }),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,14 +1,34 @@
|
||||
import type { BatchUpsertResult } from "@yaakapp-internal/models";
|
||||
import {
|
||||
type BatchUpsertResult,
|
||||
type ImportDestination,
|
||||
type ImportPlan,
|
||||
type ImportSource,
|
||||
workspacesAtom,
|
||||
} from "@yaakapp-internal/models";
|
||||
import { FormattedError, VStack } from "@yaakapp-internal/ui";
|
||||
import { Button } from "../components/core/Button";
|
||||
import { ImportDataDialog } from "../components/ImportDataDialog";
|
||||
import { activeFolderAtom } from "../hooks/useActiveFolder";
|
||||
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
|
||||
import { createFastMutation } from "../hooks/useFastMutation";
|
||||
import { showAlert } from "./alert";
|
||||
import { showDialog } from "./dialog";
|
||||
import { jotaiStore } from "./jotai";
|
||||
import { pluralizeCount } from "./pluralize";
|
||||
import { router } from "./router";
|
||||
import { rpc } from "./rpc";
|
||||
|
||||
// Stable identities so the dialog's effects don't re-run (and cancel in-flight
|
||||
// fetches) every time the dialog container re-renders.
|
||||
const 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 listSources = (workspaceId: string) =>
|
||||
rpc<ImportSource[]>("cmd_list_import_sources", { workspaceId });
|
||||
const findSourcesForOrigin = (args: { filePath?: string; url?: string }) =>
|
||||
rpc<ImportSource[]>("cmd_import_sources_for_origin", args);
|
||||
|
||||
export const importData = createFastMutation({
|
||||
mutationKey: ["import_data"],
|
||||
onError: (err: string) => {
|
||||
@@ -21,29 +41,41 @@ export const importData = createFastMutation({
|
||||
},
|
||||
mutationFn: async () => {
|
||||
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: "sm",
|
||||
size: "lg",
|
||||
disableClose: true,
|
||||
render: ({ hide }) => {
|
||||
const importAndHide = async (runImport: () => Promise<BatchUpsertResult>) => {
|
||||
try {
|
||||
await finishImport(await runImport());
|
||||
resolve();
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
} finally {
|
||||
hide();
|
||||
}
|
||||
const cancel = () => {
|
||||
hide();
|
||||
resolve();
|
||||
};
|
||||
const fail = (err: unknown) => {
|
||||
hide();
|
||||
reject(err);
|
||||
};
|
||||
const commit = async (plan: ImportPlan) => {
|
||||
const imported = await rpc<BatchUpsertResult>("cmd_commit_import", { plan });
|
||||
hide();
|
||||
await finishImport(imported);
|
||||
resolve();
|
||||
};
|
||||
return (
|
||||
<ImportDataDialog
|
||||
importFile={(filePath) =>
|
||||
importAndHide(() => rpc<BatchUpsertResult>("cmd_import_data", { filePath }))
|
||||
}
|
||||
importUrl={(url) =>
|
||||
importAndHide(() => rpc<BatchUpsertResult>("cmd_import_url", { url }))
|
||||
}
|
||||
currentWorkspace={currentWorkspace}
|
||||
workspaces={workspaces}
|
||||
selectedFolder={selectedFolder}
|
||||
planFile={planFile}
|
||||
planUrl={planUrl}
|
||||
listSources={listSources}
|
||||
findSourcesForOrigin={findSourcesForOrigin}
|
||||
commit={commit}
|
||||
cancel={cancel}
|
||||
onError={fail}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -12,7 +12,7 @@ import type {
|
||||
UpdateResponse,
|
||||
YaakNotification,
|
||||
} from "@yaakapp-internal/tauri-client";
|
||||
import { HStack, Icon, VStack } from "@yaakapp-internal/ui";
|
||||
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import { openSettings } from "../commands/openSettings";
|
||||
import { Button } from "../components/core/Button";
|
||||
import { ButtonInfiniteLoading } from "../components/core/ButtonInfiniteLoading";
|
||||
@@ -180,9 +180,65 @@ function showUpdateInstalledToast(version: string) {
|
||||
|
||||
async function showUpdateAvailableToast(updateInfo: UpdateInfo) {
|
||||
const UPDATE_TOAST_ID = "update-info";
|
||||
const { version, replyEventId, downloaded } = updateInfo;
|
||||
const { version, replyEventId, downloaded, install } = updateInfo;
|
||||
|
||||
jotaiStore.set(updateAvailableAtom, { version, downloaded });
|
||||
jotaiStore.set(updateAvailableAtom, { version, downloaded, install });
|
||||
|
||||
const whatsNewButton = (
|
||||
<Button
|
||||
size="xs"
|
||||
color="info"
|
||||
variant="border"
|
||||
rightSlot={<Icon icon="external_link" />}
|
||||
onClick={async () => {
|
||||
await platform.openUrl(`https://yaak.app/changelog/${version}`);
|
||||
}}
|
||||
>
|
||||
What's New
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (install !== "integrated") {
|
||||
// Nothing to reply to here; the backend only told us so we can say how to update
|
||||
const flatpak = install === "flatpak";
|
||||
showToast({
|
||||
id: UPDATE_TOAST_ID,
|
||||
color: "info",
|
||||
timeout: null,
|
||||
message: (
|
||||
<VStack>
|
||||
<h2 className="font-semibold">Yaak {version} is available</h2>
|
||||
<p className="text-text-subtle text-sm">
|
||||
{flatpak ? (
|
||||
<>
|
||||
Update with <InlineCode>flatpak update</InlineCode> or your software center.
|
||||
</>
|
||||
) : (
|
||||
"Download the new version to upgrade."
|
||||
)}
|
||||
</p>
|
||||
</VStack>
|
||||
),
|
||||
action: () => (
|
||||
<HStack space={1.5}>
|
||||
{!flatpak && (
|
||||
<Button
|
||||
size="xs"
|
||||
color="info"
|
||||
rightSlot={<Icon icon="external_link" />}
|
||||
onClick={async () => {
|
||||
await platform.openUrl("https://yaak.app/download");
|
||||
}}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
)}
|
||||
{whatsNewButton}
|
||||
</HStack>
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Acknowledge the event, so we don't time out and try the fallback update logic
|
||||
await platform.emit(replyEventId, { type: "ack" } satisfies UpdateResponse);
|
||||
@@ -215,17 +271,7 @@ async function showUpdateAvailableToast(updateInfo: UpdateInfo) {
|
||||
>
|
||||
{downloaded ? "Install Now" : "Download and Install"}
|
||||
</ButtonInfiniteLoading>
|
||||
<Button
|
||||
size="xs"
|
||||
color="info"
|
||||
variant="border"
|
||||
rightSlot={<Icon icon="external_link" />}
|
||||
onClick={async () => {
|
||||
await platform.openUrl(`https://yaak.app/changelog/${version}`);
|
||||
}}
|
||||
>
|
||||
What's New
|
||||
</Button>
|
||||
{whatsNewButton}
|
||||
</HStack>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import type { JsonSchema } from "./jsonSchemaExample";
|
||||
import { buildExampleFromSchema } from "./jsonSchemaExample";
|
||||
|
||||
describe("buildExampleFromSchema", () => {
|
||||
test("fills scalar fields with placeholders", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
age: { type: "number", format: "int32" },
|
||||
active: { type: "boolean" },
|
||||
data: { type: "string", format: "byte" },
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({
|
||||
name: "",
|
||||
age: 0,
|
||||
active: false,
|
||||
data: "",
|
||||
});
|
||||
});
|
||||
|
||||
test("encodes 64-bit integers as strings", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", format: "int64" },
|
||||
count: { type: "string", format: "uint64" },
|
||||
offset: { type: "string", format: "sfixed64" },
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({ id: "0", count: "0", offset: "0" });
|
||||
});
|
||||
|
||||
test("fills date-time with a parseable timestamp", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: { createdAt: { type: "string", format: "date-time" } },
|
||||
};
|
||||
|
||||
const example = buildExampleFromSchema(schema) as { createdAt: string };
|
||||
expect(Number.isNaN(Date.parse(example.createdAt))).toBe(false);
|
||||
});
|
||||
|
||||
test("fills a duration with a value that parses", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: { timeout: { type: "string", format: "duration" } },
|
||||
};
|
||||
|
||||
// An empty string fails protobuf's Duration parsing, so the message wouldn't send
|
||||
expect(buildExampleFromSchema(schema)).toEqual({ timeout: "0s" });
|
||||
});
|
||||
|
||||
test("expands nested messages through $defs", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: { user: { $ref: "#/$defs/example.User" } },
|
||||
$defs: {
|
||||
"example.User": {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
address: { $ref: "#/$defs/example.Address" },
|
||||
},
|
||||
},
|
||||
"example.Address": {
|
||||
type: "object",
|
||||
properties: { city: { type: "string" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({
|
||||
user: { name: "", address: { city: "" } },
|
||||
});
|
||||
});
|
||||
|
||||
test("gives repeated fields a single placeholder item", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
tags: { type: "array", items: { type: "string" } },
|
||||
users: { type: "array", items: { $ref: "#/$defs/example.User" } },
|
||||
unknown: { type: "array" },
|
||||
},
|
||||
$defs: {
|
||||
"example.User": { type: "object", properties: { name: { type: "string" } } },
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({
|
||||
tags: [""],
|
||||
users: [{ name: "" }],
|
||||
unknown: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("uses the first value of an enum", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
status: { type: "string", enum: ["STATUS_UNSPECIFIED", "STATUS_ACTIVE"] },
|
||||
empty: { type: "string", enum: [] },
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({ status: "STATUS_UNSPECIFIED", empty: "" });
|
||||
});
|
||||
|
||||
test("gives maps a single placeholder entry", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
labels: { type: "object", additionalProperties: { type: "string" } },
|
||||
users: { type: "object", additionalProperties: { $ref: "#/$defs/example.User" } },
|
||||
},
|
||||
$defs: {
|
||||
"example.User": { type: "object", properties: { name: { type: "string" } } },
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({
|
||||
labels: { key: "" },
|
||||
users: { key: { name: "" } },
|
||||
});
|
||||
});
|
||||
|
||||
test("stops at the root self-reference", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
value: { type: "string" },
|
||||
children: { type: "array", items: { $ref: "#" } },
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({ value: "", children: [{}] });
|
||||
});
|
||||
|
||||
test("stops at a cycle between messages", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: { node: { $ref: "#/$defs/example.Node" } },
|
||||
$defs: {
|
||||
"example.Node": {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
parent: { $ref: "#/$defs/example.Node" },
|
||||
leaf: { $ref: "#/$defs/example.Leaf" },
|
||||
},
|
||||
},
|
||||
"example.Leaf": {
|
||||
type: "object",
|
||||
properties: { node: { $ref: "#/$defs/example.Node" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({
|
||||
node: { name: "", parent: {}, leaf: { node: {} } },
|
||||
});
|
||||
});
|
||||
|
||||
test("expands the same message twice when it is not on the same path", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
from: { $ref: "#/$defs/example.User" },
|
||||
to: { $ref: "#/$defs/example.User" },
|
||||
},
|
||||
$defs: {
|
||||
"example.User": { type: "object", properties: { name: { type: "string" } } },
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({ from: { name: "" }, to: { name: "" } });
|
||||
});
|
||||
|
||||
test("fills every branch of a flattened oneof", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
text: { type: "string" },
|
||||
image: { $ref: "#/$defs/example.Image" },
|
||||
},
|
||||
$defs: {
|
||||
"example.Image": { type: "object", properties: { url: { type: "string" } } },
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({
|
||||
id: "",
|
||||
text: "",
|
||||
image: { url: "" },
|
||||
});
|
||||
});
|
||||
|
||||
test("stops expanding once the node budget runs out", () => {
|
||||
// Every level references the next one twice, so an unbounded walk would build 2^depth
|
||||
// nodes without ever repeating a ref on the same path.
|
||||
const depth = 16;
|
||||
const $defs: Record<string, JsonSchema> = { [`d${depth}`]: { type: "string" } };
|
||||
for (let i = 0; i < depth; i++) {
|
||||
$defs[`d${i}`] = {
|
||||
type: "object",
|
||||
properties: {
|
||||
a: { $ref: `#/$defs/d${i + 1}` },
|
||||
b: { $ref: `#/$defs/d${i + 1}` },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const example = buildExampleFromSchema({
|
||||
type: "object",
|
||||
properties: { root: { $ref: "#/$defs/d0" } },
|
||||
$defs,
|
||||
});
|
||||
|
||||
// 2 ** 16 nodes unbounded; the budget holds it to a couple of thousand
|
||||
expect(countNodes(example)).toBeLessThan(10_000);
|
||||
});
|
||||
|
||||
test("handles messages without a known type", () => {
|
||||
const schema: JsonSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
empty: {},
|
||||
struct: { type: "object" },
|
||||
missing: { $ref: "#/$defs/example.Nope" },
|
||||
},
|
||||
};
|
||||
|
||||
expect(buildExampleFromSchema(schema)).toEqual({ empty: null, struct: {}, missing: {} });
|
||||
});
|
||||
});
|
||||
|
||||
function countNodes(value: unknown): number {
|
||||
if (Array.isArray(value)) {
|
||||
return 1 + value.reduce((total: number, v) => total + countNodes(v), 0);
|
||||
}
|
||||
if (value !== null && typeof value === "object") {
|
||||
return 1 + Object.values(value).reduce((total: number, v) => total + countNodes(v), 0);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Subset of JSON Schema emitted by the gRPC reflection layer for a method's
|
||||
* input message. See `message_to_json_schema` in the `yaak-grpc` crate.
|
||||
*/
|
||||
export type JsonSchema = {
|
||||
type?: string;
|
||||
format?: string;
|
||||
properties?: Record<string, JsonSchema>;
|
||||
items?: JsonSchema;
|
||||
additionalProperties?: JsonSchema;
|
||||
enum?: unknown[];
|
||||
$defs?: Record<string, JsonSchema>;
|
||||
$ref?: string;
|
||||
};
|
||||
|
||||
const DEFS_PREFIX = "#/$defs/";
|
||||
const ROOT_REF = "#";
|
||||
|
||||
// Protobuf 64-bit integers are encoded as strings in the JSON mapping
|
||||
const STRING_NUMBER_FORMATS = ["int64", "uint64", "sint64", "fixed64", "sfixed64"];
|
||||
|
||||
// Refs on sibling branches each expand their own subtree, so a schema that references the
|
||||
// same messages repeatedly can produce exponentially many nodes without ever cycling.
|
||||
const MAX_NODES = 5000;
|
||||
|
||||
type Budget = { remaining: number };
|
||||
|
||||
/** Build a sample message with placeholder values for every field in the schema */
|
||||
export function buildExampleFromSchema(schema: JsonSchema): unknown {
|
||||
// The root is already being built, so a `#` ref anywhere below it is a cycle
|
||||
return buildValue(schema, schema, new Set([ROOT_REF]), { remaining: MAX_NODES });
|
||||
}
|
||||
|
||||
function buildValue(
|
||||
schema: JsonSchema,
|
||||
root: JsonSchema,
|
||||
refPath: Set<string>,
|
||||
budget: Budget,
|
||||
): unknown {
|
||||
if (schema == null || typeof schema !== "object" || budget.remaining <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
budget.remaining -= 1;
|
||||
|
||||
if (typeof schema.$ref === "string") {
|
||||
if (refPath.has(schema.$ref)) {
|
||||
return {};
|
||||
}
|
||||
const resolved = resolveRef(schema.$ref, root);
|
||||
if (resolved == null) {
|
||||
return {};
|
||||
}
|
||||
return buildValue(resolved, root, new Set(refPath).add(schema.$ref), budget);
|
||||
}
|
||||
|
||||
if (Array.isArray(schema.enum)) {
|
||||
return schema.enum[0] ?? "";
|
||||
}
|
||||
|
||||
switch (schema.type) {
|
||||
case "object":
|
||||
return buildObject(schema, root, refPath, budget);
|
||||
case "array":
|
||||
return schema.items == null ? [] : [buildValue(schema.items, root, refPath, budget)];
|
||||
case "string":
|
||||
return buildString(schema.format);
|
||||
case "number":
|
||||
return 0;
|
||||
case "boolean":
|
||||
return false;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildObject(
|
||||
schema: JsonSchema,
|
||||
root: JsonSchema,
|
||||
refPath: Set<string>,
|
||||
budget: Budget,
|
||||
): unknown {
|
||||
if (schema.properties != null && typeof schema.properties === "object") {
|
||||
const example: Record<string, unknown> = {};
|
||||
for (const [name, propertySchema] of Object.entries(schema.properties)) {
|
||||
example[name] = buildValue(propertySchema, root, refPath, budget);
|
||||
}
|
||||
return example;
|
||||
}
|
||||
|
||||
// Maps have no properties, only a value schema
|
||||
if (schema.additionalProperties != null) {
|
||||
return { key: buildValue(schema.additionalProperties, root, refPath, budget) };
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function buildString(format: string | undefined): string {
|
||||
if (format === "date-time") {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
// Duration JSON is a decimal string with an `s` suffix, and an empty one fails to parse
|
||||
if (format === "duration") {
|
||||
return "0s";
|
||||
}
|
||||
if (format != null && STRING_NUMBER_FORMATS.includes(format)) {
|
||||
return "0";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function resolveRef(ref: string, root: JsonSchema): JsonSchema | null {
|
||||
if (ref === ROOT_REF) {
|
||||
return root;
|
||||
}
|
||||
if (!ref.startsWith(DEFS_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
return root.$defs?.[ref.slice(DEFS_PREFIX.length)] ?? null;
|
||||
}
|
||||
@@ -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 } : {},
|
||||
});
|
||||
}
|
||||
@@ -25,13 +25,8 @@ export async function showPromptForm({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
hideX: true,
|
||||
size: size ?? "sm",
|
||||
disableBackdropClose: true, // Prevent accidental dismisses
|
||||
onClose: () => {
|
||||
// Click backdrop, close, or escape
|
||||
resolve(null);
|
||||
},
|
||||
disableClose: true,
|
||||
render: ({ hide }) =>
|
||||
Prompt({
|
||||
onCancel: () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ type ModelType = AnyModel["model"];
|
||||
type WorkspaceRequestSettings = Pick<
|
||||
Workspace,
|
||||
| "settingFollowRedirects"
|
||||
| "settingHttpVersion"
|
||||
| "settingRequestMessageSize"
|
||||
| "settingRequestTimeout"
|
||||
| "settingSendCookies"
|
||||
@@ -18,9 +19,7 @@ type ModelTypeWithSetting<K extends RequestSettingKey> = {
|
||||
[M in ModelType]: K extends keyof ModelForType<M> ? M : never;
|
||||
}[ModelType];
|
||||
|
||||
export type RequestSettingDefinition<
|
||||
K extends RequestSettingKey = RequestSettingKey,
|
||||
> = {
|
||||
export type RequestSettingDefinition<K extends RequestSettingKey = RequestSettingKey> = {
|
||||
defaultValue: WorkspaceRequestSettings[K];
|
||||
description: string;
|
||||
modelKey: K;
|
||||
@@ -46,8 +45,7 @@ export const SETTING_REQUEST_TIMEOUT = defineRequestSetting({
|
||||
|
||||
export const SETTING_REQUEST_MESSAGE_SIZE = defineRequestSetting({
|
||||
defaultValue: 64 * 1024 * 1024,
|
||||
description:
|
||||
"Maximum gRPC or WebSocket message size in MB. Set to 0 to disable.",
|
||||
description: "Maximum gRPC or WebSocket message size in MB. Set to 0 to disable.",
|
||||
modelKey: "settingRequestMessageSize",
|
||||
models: ["workspace", "folder", "websocket_request", "grpc_request"],
|
||||
title: "Message Size Limit",
|
||||
@@ -57,13 +55,7 @@ export const SETTING_VALIDATE_CERTIFICATES = defineRequestSetting({
|
||||
defaultValue: true,
|
||||
description: "When disabled, skip validation of server certificates.",
|
||||
modelKey: "settingValidateCertificates",
|
||||
models: [
|
||||
"workspace",
|
||||
"folder",
|
||||
"http_request",
|
||||
"websocket_request",
|
||||
"grpc_request",
|
||||
],
|
||||
models: ["workspace", "folder", "http_request", "websocket_request", "grpc_request"],
|
||||
title: "Validate TLS certificates",
|
||||
});
|
||||
|
||||
@@ -75,10 +67,17 @@ export const SETTING_FOLLOW_REDIRECTS = defineRequestSetting({
|
||||
title: "Follow redirects",
|
||||
});
|
||||
|
||||
export const SETTING_HTTP_VERSION = defineRequestSetting({
|
||||
defaultValue: "auto",
|
||||
description: "Force HTTP/1.1 or HTTP/2 for servers that don't negotiate the version correctly.",
|
||||
modelKey: "settingHttpVersion",
|
||||
models: ["workspace", "folder", "http_request"],
|
||||
title: "HTTP version",
|
||||
});
|
||||
|
||||
export const SETTING_SEND_COOKIES = defineRequestSetting({
|
||||
defaultValue: true,
|
||||
description:
|
||||
"Attach matching cookies from the active cookie jar to outgoing requests.",
|
||||
description: "Attach matching cookies from the active cookie jar to outgoing requests.",
|
||||
modelKey: "settingSendCookies",
|
||||
models: ["workspace", "folder", "http_request", "websocket_request"],
|
||||
title: "Automatically send cookies",
|
||||
@@ -86,8 +85,7 @@ export const SETTING_SEND_COOKIES = defineRequestSetting({
|
||||
|
||||
export const SETTING_STORE_COOKIES = defineRequestSetting({
|
||||
defaultValue: true,
|
||||
description:
|
||||
"Save cookies from Set-Cookie response headers to the active cookie jar.",
|
||||
description: "Save cookies from Set-Cookie response headers to the active cookie jar.",
|
||||
modelKey: "settingStoreCookies",
|
||||
models: ["workspace", "folder", "http_request", "websocket_request"],
|
||||
title: "Automatically store cookies",
|
||||
|
||||
@@ -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",
|
||||
@@ -47,24 +47,24 @@
|
||||
"eventemitter3": "^5.0.1",
|
||||
"focus-trap-react": "^11.0.4",
|
||||
"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.45.0",
|
||||
"mime": "^4.0.4",
|
||||
"motion": "^12.4.7",
|
||||
"nanoid": "^5.1.16",
|
||||
"motion": "^13.2.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",
|
||||
@@ -93,12 +93,10 @@
|
||||
"@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",
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.3.2",
|
||||
"vite-plugin-static-copy": "^4.1.1",
|
||||
"vite-plugin-wasm": "^3.5.0",
|
||||
"vite-plus": "^0.2.9"
|
||||
"vite-plus": "^0.3.0"
|
||||
}
|
||||
}
|
||||
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 />;
|
||||
}
|
||||
@@ -14,5 +14,6 @@ export const Route = createFileRoute("/workspaces/$workspaceId/settings")({
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
return <Settings />;
|
||||
const { tab } = Route.useSearch();
|
||||
return <Settings tab={tab} />;
|
||||
}
|
||||
@@ -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 |
@@ -19,7 +19,7 @@
|
||||
"classnames": "^2.5.1",
|
||||
"jotai": "^2.18.0",
|
||||
"jotai-family": "^1.0.1",
|
||||
"motion": "^12.4.7",
|
||||
"motion": "^13.2.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.2",
|
||||
"vite-plus": "^0.3.0"
|
||||
}
|
||||
}
|
||||
@@ -22,8 +22,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,12 +42,13 @@ tokio = { workspace = true, features = [
|
||||
] }
|
||||
walkdir = "2"
|
||||
webbrowser = "1"
|
||||
zip = "4"
|
||||
zip = "7"
|
||||
yaak = { workspace = true }
|
||||
yaak-api = { workspace = true }
|
||||
yaak-core = { workspace = true }
|
||||
yaak-crypto = { workspace = true }
|
||||
yaak-http = { workspace = true }
|
||||
yaak-lifecycle = { workspace = true }
|
||||
yaak-models = { workspace = true }
|
||||
yaak-plugins = { workspace = true }
|
||||
yaak-templates = { 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 or Insomnia 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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -3,18 +3,22 @@ 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_models::export::{self, ExportDataParams};
|
||||
use yaak::import;
|
||||
use yaak_core::WorkspaceContext;
|
||||
use yaak_models::util::BatchUpsertResult;
|
||||
use yaak_models::util::{
|
||||
BatchUpsertResult, ImportDestination, ImportOrigin, ImportPlanAction, ImportPlanItem,
|
||||
};
|
||||
use yaak_plugins::events::{ImportResources, PluginContext};
|
||||
|
||||
type CommandResult<T = ()> = std::result::Result<T, String>;
|
||||
|
||||
pub async fn run_import(ctx: &CliContext, args: ImportArgs) -> i32 {
|
||||
match import(ctx, args).await {
|
||||
Ok(result) => {
|
||||
Ok((result, items)) => {
|
||||
println!("Imported {}", format_counts(&result));
|
||||
if let Some(skipped) = format_skipped(&items) {
|
||||
println!("Skipped {skipped}");
|
||||
}
|
||||
0
|
||||
}
|
||||
Err(error) => {
|
||||
@@ -37,7 +41,10 @@ pub fn run_export(ctx: &CliContext, args: ExportArgs) -> i32 {
|
||||
}
|
||||
}
|
||||
|
||||
async fn import(ctx: &CliContext, args: ImportArgs) -> CommandResult<BatchUpsertResult> {
|
||||
async fn import(
|
||||
ctx: &CliContext,
|
||||
args: ImportArgs,
|
||||
) -> CommandResult<(BatchUpsertResult, Vec<ImportPlanItem>)> {
|
||||
if let Some(workspace_id) = args.workspace_id.as_deref() {
|
||||
ctx.db()
|
||||
.get_workspace(workspace_id)
|
||||
@@ -51,6 +58,7 @@ async fn import(ctx: &CliContext, args: ImportArgs) -> CommandResult<BatchUpsert
|
||||
.import_data(&plugin_context, &file_contents)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to import data: {e}"))?;
|
||||
let importer = import_result.importer;
|
||||
let resources = import_result.resources;
|
||||
let workspace_id = args.workspace_id;
|
||||
if workspace_id.is_none() && resources_need_current_workspace(&resources) {
|
||||
@@ -59,28 +67,121 @@ async fn import(ctx: &CliContext, args: ImportArgs) -> CommandResult<BatchUpsert
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
let workspace_context = WorkspaceContext {
|
||||
workspace_id,
|
||||
environment_id: None,
|
||||
cookie_jar_id: None,
|
||||
request_id: None,
|
||||
let destination = match workspace_id {
|
||||
Some(workspace_id) => {
|
||||
ImportDestination::ExistingWorkspace { workspace_id, folder_id: None }
|
||||
}
|
||||
None => ImportDestination::NewWorkspace,
|
||||
};
|
||||
let imported = import::import_resources(ctx.query_manager(), workspace_context, resources)
|
||||
let plan = import::plan_import_resources(
|
||||
ctx.query_manager(),
|
||||
importer,
|
||||
destination,
|
||||
resources,
|
||||
import_result.source_keys,
|
||||
Some(file_origin(&args.file)),
|
||||
)
|
||||
.map_err(|e| format!("Failed to plan import: {e}"))?;
|
||||
let items = plan.items.clone();
|
||||
let imported = import::commit_import_plan(ctx.query_manager(), plan)
|
||||
.map_err(|e| format!("Failed to import data: {e}"))?;
|
||||
Ok(imported)
|
||||
Ok((imported, items))
|
||||
}
|
||||
|
||||
fn file_origin(path: &std::path::Path) -> ImportOrigin {
|
||||
let canonical = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
|
||||
let label = canonical
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| path.display().to_string());
|
||||
ImportOrigin { origin: canonical.to_string_lossy().to_string(), label }
|
||||
}
|
||||
|
||||
/// Summarize what the default selection left untouched during a merging re-import.
|
||||
fn format_skipped(items: &[ImportPlanItem]) -> Option<String> {
|
||||
let count = |action: ImportPlanAction| items.iter().filter(|i| i.action == action).count();
|
||||
let plural = |n: usize| if n == 1 { "" } else { "s" };
|
||||
|
||||
let mut parts = Vec::new();
|
||||
let deletions = count(ImportPlanAction::Delete);
|
||||
if deletions > 0 {
|
||||
parts.push(format!("{deletions} removed from source (not deleted locally)"));
|
||||
}
|
||||
let conflicts = count(ImportPlanAction::Conflict);
|
||||
if conflicts > 0 {
|
||||
parts.push(format!("{conflicts} conflict{} (kept local changes)", plural(conflicts)));
|
||||
}
|
||||
let keep_local = count(ImportPlanAction::KeepLocal);
|
||||
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"));
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -435,19 +435,16 @@ fn create(
|
||||
let workspace_id = resolve_workspace_id(ctx, workspace_id_arg.as_deref(), "request create")?;
|
||||
let name = name.unwrap_or_default();
|
||||
let url = url.unwrap_or_default();
|
||||
let method = method.unwrap_or_else(|| "GET".to_string());
|
||||
|
||||
let request = HttpRequest {
|
||||
workspace_id,
|
||||
name,
|
||||
method: method.to_uppercase(),
|
||||
url,
|
||||
..Default::default()
|
||||
};
|
||||
let mut request = HttpRequest { workspace_id, name, url, ..Default::default() };
|
||||
// Only override the method when one was given; `HttpRequest::default()` is the
|
||||
// single place the fallback ("GET") is defined.
|
||||
if let Some(method) = method {
|
||||
request.method = method.to_uppercase();
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -466,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);
|
||||
@@ -490,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(())
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -49,6 +49,12 @@ impl CliContext {
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// Guest: the desktop may have this DB open, so only what's safe beside a live session
|
||||
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));
|
||||
|
||||
Self {
|
||||
|
||||
@@ -13,7 +13,6 @@ use tokio::task::JoinHandle;
|
||||
use yaak::plugin_events::{
|
||||
GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event,
|
||||
};
|
||||
use yaak::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();
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
@@ -81,14 +82,21 @@ fn import_reads_yaak_workspace_file() {
|
||||
|
||||
let query_manager = query_manager(data_dir);
|
||||
let db = query_manager.connect();
|
||||
assert_eq!(
|
||||
db.get_workspace("wrk_import").expect("workspace imported").name,
|
||||
"Imported Workspace"
|
||||
);
|
||||
assert_eq!(
|
||||
db.get_http_request("req_import").expect("request imported").url,
|
||||
"https://example.com"
|
||||
);
|
||||
let workspaces = db.list_workspaces().expect("list imported workspaces");
|
||||
let workspace = workspaces
|
||||
.iter()
|
||||
.find(|workspace| workspace.name == "Imported Workspace")
|
||||
.expect("workspace imported");
|
||||
assert_ne!(workspace.id, "wrk_import");
|
||||
|
||||
let requests = db.list_http_requests(&workspace.id).expect("list imported requests");
|
||||
let request = requests
|
||||
.iter()
|
||||
.find(|request| request.name == "Imported Request")
|
||||
.expect("request imported");
|
||||
assert_ne!(request.id, "req_import");
|
||||
assert_eq!(request.workspace_id, workspace.id);
|
||||
assert_eq!(request.url, "https://example.com");
|
||||
}
|
||||
|
||||
fn write_postman_environment_fixture(path: &std::path::Path) {
|
||||
@@ -153,10 +161,164 @@ 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");
|
||||
assert_eq!(imported_environment.workspace_id, workspace_id);
|
||||
}
|
||||
|
||||
fn write_linked_fixture(path: &std::path::Path, requests: &[(&str, &str, &str)]) {
|
||||
let requests = requests
|
||||
.iter()
|
||||
.map(|(id, name, url)| {
|
||||
format!(
|
||||
r#"{{ "model": "http_request", "id": "{id}", "workspaceId": "wrk_link",
|
||||
"name": "{name}", "method": "GET", "url": "{url}" }}"#
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
std::fs::write(
|
||||
path,
|
||||
format!(
|
||||
r#"{{
|
||||
"yaakVersion": "test",
|
||||
"yaakSchema": 4,
|
||||
"resources": {{
|
||||
"workspaces": [{{ "model": "workspace", "id": "wrk_link", "name": "Linked Workspace" }}],
|
||||
"httpRequests": [{requests}]
|
||||
}}
|
||||
}}"#
|
||||
),
|
||||
)
|
||||
.expect("write linked fixture");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn re_import_merges_into_linked_workspace() {
|
||||
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()
|
||||
.stdout(contains("Imported 1 workspace, 2 HTTP requests"));
|
||||
|
||||
let workspace_id = {
|
||||
let query_manager = query_manager(data_dir);
|
||||
let db = query_manager.connect();
|
||||
db.list_workspaces()
|
||||
.expect("list workspaces")
|
||||
.into_iter()
|
||||
.find(|w| w.name == "Linked Workspace")
|
||||
.expect("workspace imported")
|
||||
.id
|
||||
};
|
||||
|
||||
// The source doc changes A, drops B, and adds C. The default selection applies the
|
||||
// update and the create but leaves the removal as an offer.
|
||||
write_linked_fixture(
|
||||
&import_path,
|
||||
&[
|
||||
("req_a", "Request A", "https://example.com/a-v2"),
|
||||
("req_c", "Request C", "https://example.com/c"),
|
||||
],
|
||||
);
|
||||
cli_cmd(data_dir)
|
||||
.args([
|
||||
"import",
|
||||
import_path.to_str().expect("import path is utf-8"),
|
||||
"--workspace-id",
|
||||
&workspace_id,
|
||||
])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Imported 2 HTTP requests"))
|
||||
.stdout(contains("Skipped 1 removed from source"));
|
||||
|
||||
let query_manager = query_manager(data_dir);
|
||||
let db = query_manager.connect();
|
||||
let requests = db.list_http_requests(&workspace_id).expect("list requests");
|
||||
assert_eq!(requests.len(), 3, "merge must not duplicate: {requests:?}");
|
||||
assert_eq!(
|
||||
requests.iter().find(|r| r.name == "Request A").expect("request A").url,
|
||||
"https://example.com/a-v2"
|
||||
);
|
||||
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.
|
||||
@@ -0,0 +1,185 @@
|
||||
use crate::AppState;
|
||||
use crate::data::{self, User};
|
||||
use crate::error::{ApiError, ApiResult, parse_json};
|
||||
use axum::extract::State;
|
||||
use axum::extract::rejection::BytesRejection;
|
||||
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use bytes::Bytes;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub const PASSWORD: &str = "yaak";
|
||||
const SESSION_COOKIE: &str = "session";
|
||||
const TTL: Duration = Duration::from_secs(60 * 60);
|
||||
const MAX_CREDENTIALS: usize = 10_000;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
Bearer,
|
||||
Session,
|
||||
}
|
||||
|
||||
/// Issued tokens and session IDs, kept apart so a token can't be sent as a cookie or the
|
||||
/// other way around.
|
||||
pub struct Credentials {
|
||||
entries: Mutex<HashMap<String, Entry>>,
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
user_id: u64,
|
||||
kind: Kind,
|
||||
expires: Instant,
|
||||
}
|
||||
|
||||
impl Credentials {
|
||||
pub fn new() -> Self {
|
||||
Self { entries: Mutex::new(HashMap::new()) }
|
||||
}
|
||||
|
||||
fn issue(&self, user_id: u64, kind: Kind) -> String {
|
||||
let value = uuid::Uuid::new_v4().simple().to_string();
|
||||
let now = Instant::now();
|
||||
let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if entries.len() >= MAX_CREDENTIALS {
|
||||
entries.retain(|_, e| e.expires > now);
|
||||
}
|
||||
if entries.len() >= MAX_CREDENTIALS
|
||||
&& let Some(oldest) =
|
||||
entries.iter().min_by_key(|(_, e)| e.expires).map(|(k, _)| k.clone())
|
||||
{
|
||||
entries.remove(&oldest);
|
||||
}
|
||||
entries.insert(value.clone(), Entry { user_id, kind, expires: now + TTL });
|
||||
value
|
||||
}
|
||||
|
||||
fn lookup(&self, value: &str, kind: Kind) -> Option<&'static User> {
|
||||
let entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let entry = entries.get(value)?;
|
||||
if entry.kind != kind || entry.expires <= Instant::now() {
|
||||
return None;
|
||||
}
|
||||
data::user(entry.user_id)
|
||||
}
|
||||
|
||||
fn revoke(&self, value: &str) {
|
||||
self.entries.lock().unwrap_or_else(|e| e.into_inner()).remove(value);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Login {
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
fn log_in(body: Result<Bytes, BytesRejection>) -> ApiResult<&'static User> {
|
||||
let login: Login = parse_json(body)?;
|
||||
data::user_by_username(&login.username)
|
||||
.filter(|_| login.password == PASSWORD)
|
||||
.ok_or_else(|| ApiError::unauthorized("Invalid username or password", None))
|
||||
}
|
||||
|
||||
pub async fn create_token(
|
||||
State(state): State<AppState>,
|
||||
body: Result<Bytes, BytesRejection>,
|
||||
) -> ApiResult<Json<Value>> {
|
||||
let user = log_in(body)?;
|
||||
let token = state.credentials.issue(user.id, Kind::Bearer);
|
||||
Ok(Json(json!({
|
||||
"accessToken": token,
|
||||
"tokenType": "Bearer",
|
||||
"expiresIn": TTL.as_secs(),
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn me(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> ApiResult<Json<&'static User>> {
|
||||
let Some(authorization) = headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok())
|
||||
else {
|
||||
return Err(ApiError::unauthorized("Missing Authorization header", Some("Bearer")));
|
||||
};
|
||||
let token = match authorization.split_once(' ') {
|
||||
Some((scheme, token)) if scheme.eq_ignore_ascii_case("bearer") => token.trim(),
|
||||
_ => {
|
||||
return Err(ApiError::unauthorized(
|
||||
"Authorization header must be \"Bearer <token>\"",
|
||||
Some("Bearer"),
|
||||
));
|
||||
}
|
||||
};
|
||||
state.credentials.lookup(token, Kind::Bearer).map(Json).ok_or_else(|| {
|
||||
ApiError::unauthorized("Invalid or expired token", Some("Bearer error=\"invalid_token\""))
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn create_session(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
body: Result<Bytes, BytesRejection>,
|
||||
) -> ApiResult<Response> {
|
||||
let user = log_in(body)?;
|
||||
let id = state.credentials.issue(user.id, Kind::Session);
|
||||
let cookie = format!(
|
||||
"{SESSION_COOKIE}={id}; Path=/; Max-Age={}; HttpOnly; SameSite=Lax{}",
|
||||
TTL.as_secs(),
|
||||
secure_attribute(&headers)
|
||||
);
|
||||
let mut res = Json(user).into_response();
|
||||
res.headers_mut().insert(header::SET_COOKIE, HeaderValue::from_str(&cookie).expect("ascii"));
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
pub async fn session(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> ApiResult<Json<&'static User>> {
|
||||
let Some(id) = session_cookie(&headers) else {
|
||||
return Err(ApiError::unauthorized("Missing session cookie", None));
|
||||
};
|
||||
state
|
||||
.credentials
|
||||
.lookup(id, Kind::Session)
|
||||
.map(Json)
|
||||
.ok_or_else(|| ApiError::unauthorized("Invalid or expired session", None))
|
||||
}
|
||||
|
||||
pub async fn delete_session(State(state): State<AppState>, headers: HeaderMap) -> Response {
|
||||
if let Some(id) = session_cookie(&headers) {
|
||||
state.credentials.revoke(id);
|
||||
}
|
||||
let cookie = format!(
|
||||
"{SESSION_COOKIE}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax{}",
|
||||
secure_attribute(&headers)
|
||||
);
|
||||
let mut res = StatusCode::NO_CONTENT.into_response();
|
||||
res.headers_mut().insert(header::SET_COOKIE, HeaderValue::from_str(&cookie).expect("ascii"));
|
||||
res
|
||||
}
|
||||
|
||||
fn session_cookie(headers: &HeaderMap) -> Option<&str> {
|
||||
headers
|
||||
.get_all(header::COOKIE)
|
||||
.iter()
|
||||
.filter_map(|v| v.to_str().ok())
|
||||
.flat_map(|v| v.split(';'))
|
||||
.filter_map(|pair| pair.trim().split_once('='))
|
||||
.find(|(name, _)| *name == SESSION_COOKIE)
|
||||
.map(|(_, value)| value)
|
||||
}
|
||||
|
||||
/// `Secure` only when the request reached the proxy over HTTPS, so the cookie still works
|
||||
/// against a local instance on plain HTTP.
|
||||
fn secure_attribute(headers: &HeaderMap) -> &'static str {
|
||||
let https = headers
|
||||
.get("x-forwarded-proto")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|v| v.eq_ignore_ascii_case("https"));
|
||||
if https { "; Secure" } else { "" }
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
use clap::Parser;
|
||||
use std::net::{SocketAddr, ToSocketAddrs};
|
||||
|
||||
/// A small, disposable API for trying Yaak against.
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
#[command(name = "yaak-playground", version, about, long_about = None)]
|
||||
pub struct Config {
|
||||
/// Interface to listen on. 0.0.0.0 inside a container.
|
||||
#[arg(long, env = "HOST", default_value = "127.0.0.1")]
|
||||
pub host: String,
|
||||
|
||||
#[arg(long, env = "PORT", default_value_t = 9228)]
|
||||
pub port: u16,
|
||||
|
||||
/// Requests allowed per client IP per minute. 0 disables the limit.
|
||||
#[arg(
|
||||
long,
|
||||
env = "YAAK_PLAYGROUND_RATE_LIMIT_PER_MINUTE",
|
||||
default_value_t = 300
|
||||
)]
|
||||
pub rate_limit_per_minute: u32,
|
||||
|
||||
/// Take the client IP from `X-Forwarded-For` (first hop) instead of the socket. Only behind
|
||||
/// a load balancer that sets the header.
|
||||
#[arg(
|
||||
long,
|
||||
env = "YAAK_PLAYGROUND_TRUST_FORWARDED_FOR",
|
||||
default_value_t = false
|
||||
)]
|
||||
pub trust_forwarded_for: bool,
|
||||
|
||||
/// Seconds after a client's first write before its posts go back to the sample data.
|
||||
#[arg(long, env = "YAAK_PLAYGROUND_RESET_AFTER_SECS", default_value_t = 3600)]
|
||||
pub reset_after_secs: u64,
|
||||
|
||||
/// Clients whose writes are kept at once. The oldest is reset early to make room.
|
||||
#[arg(long, env = "YAAK_PLAYGROUND_MAX_CLIENTS", default_value_t = 2000)]
|
||||
pub max_clients: usize,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
env = "YAAK_PLAYGROUND_MAX_POSTS_PER_CLIENT",
|
||||
default_value_t = 100
|
||||
)]
|
||||
pub max_posts_per_client: usize,
|
||||
|
||||
#[arg(long, env = "YAAK_PLAYGROUND_MAX_REQUEST_BYTES", default_value_t = 64 * 1024)]
|
||||
pub max_request_bytes: usize,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn listen_addr(&self) -> Result<SocketAddr, String> {
|
||||
(self.host.as_str(), self.port)
|
||||
.to_socket_addrs()
|
||||
.map_err(|e| format!("Invalid HOST {:?}: {e}", self.host))?
|
||||
.next()
|
||||
.ok_or_else(|| format!("HOST {:?} resolved to no address", self.host))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct User {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Post {
|
||||
pub id: u64,
|
||||
pub user_id: u64,
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Todo {
|
||||
pub id: u64,
|
||||
pub user_id: u64,
|
||||
pub title: String,
|
||||
pub completed: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Seed {
|
||||
users: Vec<User>,
|
||||
posts: Vec<Post>,
|
||||
todos: Vec<Todo>,
|
||||
}
|
||||
|
||||
static SEED: LazyLock<Seed> =
|
||||
LazyLock::new(|| serde_json::from_str(include_str!("seed.json")).expect("valid seed.json"));
|
||||
|
||||
pub fn users() -> &'static [User] {
|
||||
&SEED.users
|
||||
}
|
||||
|
||||
pub fn posts() -> &'static [Post] {
|
||||
&SEED.posts
|
||||
}
|
||||
|
||||
pub fn todos() -> &'static [Todo] {
|
||||
&SEED.todos
|
||||
}
|
||||
|
||||
pub fn user(id: u64) -> Option<&'static User> {
|
||||
users().iter().find(|u| u.id == id)
|
||||
}
|
||||
|
||||
pub fn user_by_username(username: &str) -> Option<&'static User> {
|
||||
users().iter().find(|u| u.username == username)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
use axum::extract::rejection::BytesRejection;
|
||||
use axum::http::{HeaderValue, StatusCode, header};
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use bytes::Bytes;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::json;
|
||||
|
||||
pub type ApiResult<T> = Result<T, ApiError>;
|
||||
|
||||
pub struct ApiError {
|
||||
status: StatusCode,
|
||||
message: String,
|
||||
challenge: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
pub fn new(status: StatusCode, message: impl Into<String>) -> Self {
|
||||
Self { status, message: message.into(), challenge: None }
|
||||
}
|
||||
|
||||
pub fn bad_request(message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::BAD_REQUEST, message)
|
||||
}
|
||||
|
||||
pub fn not_found(message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::NOT_FOUND, message)
|
||||
}
|
||||
|
||||
/// `challenge` becomes the `WWW-Authenticate` header.
|
||||
pub fn unauthorized(message: impl Into<String>, challenge: Option<&'static str>) -> Self {
|
||||
Self { status: StatusCode::UNAUTHORIZED, message: message.into(), challenge }
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let mut res = (self.status, Json(json!({ "error": self.message }))).into_response();
|
||||
if let Some(challenge) = self.challenge {
|
||||
res.headers_mut().insert(header::WWW_AUTHENTICATE, HeaderValue::from_static(challenge));
|
||||
}
|
||||
res
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_id(raw: &str, noun: &str) -> ApiResult<u64> {
|
||||
raw.parse().map_err(|_| ApiError::not_found(format!("No {noun} with ID {raw}")))
|
||||
}
|
||||
|
||||
/// Taken as a `Result` so an oversized body gets a JSON error like everything else.
|
||||
pub fn parse_json<T: DeserializeOwned>(body: Result<Bytes, BytesRejection>) -> ApiResult<T> {
|
||||
let body = body.map_err(|e| ApiError::new(e.status(), e.body_text()))?;
|
||||
if body.is_empty() {
|
||||
return Err(ApiError::bad_request("Request body must be JSON"));
|
||||
}
|
||||
serde_json::from_slice(&body)
|
||||
.map_err(|e| ApiError::bad_request(format!("Request body is not valid: {e}")))
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
//! yaak-playground: the API behind yaak.run, for trying Yaak without bringing your own.
|
||||
//!
|
||||
//! Reads come from fixed sample data. Writes are kept per client IP and reset after a while,
|
||||
//! so nothing one caller sends is ever served to another. Nothing touches disk.
|
||||
|
||||
mod auth;
|
||||
mod config;
|
||||
mod data;
|
||||
mod error;
|
||||
mod limits;
|
||||
mod resources;
|
||||
mod store;
|
||||
|
||||
use axum::Router;
|
||||
use axum::extract::{ConnectInfo, DefaultBodyLimit, Request, State};
|
||||
use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, header};
|
||||
use axum::middleware::{self, Next};
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use axum::routing::{get, post};
|
||||
pub use config::Config;
|
||||
use error::ApiError;
|
||||
use limits::RateLimiter;
|
||||
use serde_json::{Value, json};
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use store::PostStore;
|
||||
use tower_http::cors::{AllowOrigin, CorsLayer};
|
||||
|
||||
const OPENAPI: &str = include_str!("openapi.json");
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
config: Arc<Config>,
|
||||
rate_limiter: Arc<RateLimiter>,
|
||||
posts: Arc<PostStore>,
|
||||
credentials: Arc<auth::Credentials>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct ClientIp(IpAddr);
|
||||
|
||||
/// Serve with `into_make_service_with_connect_info::<SocketAddr>()`; the client's address
|
||||
/// keys both the rate limit and its private copy of the posts.
|
||||
pub fn router(config: Config) -> Router {
|
||||
let state = AppState {
|
||||
rate_limiter: Arc::new(RateLimiter::new(config.rate_limit_per_minute)),
|
||||
posts: Arc::new(PostStore::new(
|
||||
Duration::from_secs(config.reset_after_secs),
|
||||
config.max_clients,
|
||||
config.max_posts_per_client,
|
||||
)),
|
||||
credentials: Arc::new(auth::Credentials::new()),
|
||||
config: Arc::new(config),
|
||||
};
|
||||
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(AllowOrigin::any())
|
||||
.allow_methods([
|
||||
Method::GET,
|
||||
Method::POST,
|
||||
Method::PUT,
|
||||
Method::PATCH,
|
||||
Method::DELETE,
|
||||
Method::OPTIONS,
|
||||
])
|
||||
.allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION])
|
||||
.expose_headers([
|
||||
header::LOCATION,
|
||||
header::RETRY_AFTER,
|
||||
header::WWW_AUTHENTICATE,
|
||||
]);
|
||||
|
||||
Router::new()
|
||||
.route("/", get(index))
|
||||
.route("/health", get(health))
|
||||
.route("/openapi.json", get(openapi))
|
||||
.route("/users", get(resources::list_users))
|
||||
.route("/users/:id", get(resources::get_user))
|
||||
.route("/users/:id/todos", get(resources::list_user_todos))
|
||||
.route("/todos", get(resources::list_todos))
|
||||
.route("/posts", get(resources::list_posts).post(resources::create_post))
|
||||
.route(
|
||||
"/posts/:id",
|
||||
get(resources::get_post)
|
||||
.put(resources::replace_post)
|
||||
.patch(resources::update_post)
|
||||
.delete(resources::delete_post),
|
||||
)
|
||||
.route("/auth/token", post(auth::create_token))
|
||||
.route("/auth/me", get(auth::me))
|
||||
.route(
|
||||
"/auth/session",
|
||||
post(auth::create_session).get(auth::session).delete(auth::delete_session),
|
||||
)
|
||||
.fallback(not_found)
|
||||
.layer(middleware::from_fn_with_state(state.clone(), identify_client))
|
||||
.layer(DefaultBodyLimit::max(state.config.max_request_bytes))
|
||||
.layer(cors)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn identify_client(
|
||||
State(state): State<AppState>,
|
||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||
mut req: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let ip = client_ip(&state.config, req.headers(), peer);
|
||||
if let Err(wait) = state.rate_limiter.check(ip) {
|
||||
let secs = wait.as_secs().max(1);
|
||||
let mut res = ApiError::new(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
format!("Rate limit reached. Try again in {secs}s"),
|
||||
)
|
||||
.into_response();
|
||||
res.headers_mut().insert(header::RETRY_AFTER, HeaderValue::from(secs));
|
||||
return res;
|
||||
}
|
||||
req.extensions_mut().insert(ClientIp(ip));
|
||||
next.run(req).await
|
||||
}
|
||||
|
||||
fn client_ip(config: &Config, headers: &HeaderMap, peer: SocketAddr) -> IpAddr {
|
||||
if config.trust_forwarded_for
|
||||
&& let Some(forwarded) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok())
|
||||
&& let Some(first) = forwarded.split(',').next()
|
||||
&& let Ok(ip) = first.trim().parse::<IpAddr>()
|
||||
{
|
||||
return ip;
|
||||
}
|
||||
peer.ip()
|
||||
}
|
||||
|
||||
async fn index(State(state): State<AppState>) -> Json<Value> {
|
||||
let spec: Value = serde_json::from_str(OPENAPI).expect("valid openapi.json");
|
||||
Json(json!({
|
||||
"name": "Yaak Playground",
|
||||
"description": "A small API for trying out Yaak. Posts you create, change, or delete are only visible to you, and go back to the sample data after a while.",
|
||||
"resetAfterSeconds": state.config.reset_after_secs,
|
||||
"login": { "username": "ada", "password": auth::PASSWORD },
|
||||
"openapi": "/openapi.json",
|
||||
"endpoints": operations(&spec),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Every `METHOD /path` the spec documents.
|
||||
fn operations(spec: &Value) -> Vec<String> {
|
||||
let mut ops = Vec::new();
|
||||
for (path, item) in spec["paths"].as_object().into_iter().flatten() {
|
||||
for method in ["get", "post", "put", "patch", "delete"] {
|
||||
if item.get(method).is_some() {
|
||||
ops.push(format!("{} {path}", method.to_uppercase()));
|
||||
}
|
||||
}
|
||||
}
|
||||
ops
|
||||
}
|
||||
|
||||
async fn health() -> Json<Value> {
|
||||
Json(json!({ "ok": true, "version": env!("CARGO_PKG_VERSION") }))
|
||||
}
|
||||
|
||||
async fn openapi() -> Response {
|
||||
([(header::CONTENT_TYPE, "application/json")], OPENAPI).into_response()
|
||||
}
|
||||
|
||||
async fn not_found() -> ApiError {
|
||||
ApiError::not_found("Not found. GET / lists every endpoint")
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// One token bucket per client IP, refilled continuously.
|
||||
pub struct RateLimiter {
|
||||
per_minute: u32,
|
||||
buckets: Mutex<HashMap<IpAddr, Bucket>>,
|
||||
}
|
||||
|
||||
struct Bucket {
|
||||
tokens: f64,
|
||||
last: Instant,
|
||||
}
|
||||
|
||||
impl RateLimiter {
|
||||
/// `per_minute == 0` disables limiting.
|
||||
pub fn new(per_minute: u32) -> Self {
|
||||
Self { per_minute, buckets: Mutex::new(HashMap::new()) }
|
||||
}
|
||||
|
||||
/// Take one token for `client`, or say how long until one is available.
|
||||
pub fn check(&self, client: IpAddr) -> Result<(), Duration> {
|
||||
if self.per_minute == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let capacity = self.per_minute as f64;
|
||||
let per_second = capacity / 60.0;
|
||||
let now = Instant::now();
|
||||
|
||||
let mut buckets = self.buckets.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if buckets.len() > 1024 {
|
||||
buckets.retain(|_, b| now.duration_since(b.last).as_secs_f64() * per_second < capacity);
|
||||
}
|
||||
|
||||
let bucket = buckets.entry(client).or_insert(Bucket { tokens: capacity, last: now });
|
||||
let elapsed = now.duration_since(bucket.last).as_secs_f64();
|
||||
bucket.tokens = (bucket.tokens + elapsed * per_second).min(capacity);
|
||||
bucket.last = now;
|
||||
|
||||
if bucket.tokens >= 1.0 {
|
||||
bucket.tokens -= 1.0;
|
||||
Ok(())
|
||||
} else {
|
||||
let wait = (1.0 - bucket.tokens) / per_second;
|
||||
Err(Duration::from_secs_f64(wait.max(0.001)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
use clap::Parser;
|
||||
use log::info;
|
||||
use std::net::SocketAddr;
|
||||
use yaak_playground::{Config, router};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||
let config = Config::parse();
|
||||
let bind = config.listen_addr().unwrap_or_else(|e| {
|
||||
eprintln!("{e}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
let app = router(config);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(bind).await.unwrap_or_else(|e| {
|
||||
eprintln!("Failed to bind {bind}: {e}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
info!("yaak-playground listening on http://{bind}");
|
||||
|
||||
axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>())
|
||||
.with_graceful_shutdown(async {
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
info!("Shutting down");
|
||||
})
|
||||
.await
|
||||
.expect("server error");
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "Yaak Playground",
|
||||
"version": "1.0.0",
|
||||
"description": "A small API for trying out Yaak. Users and todos are fixed sample data. Posts you create, change, or delete are only visible to you, and go back to the sample data after a while.\n\nLog in with any username (like `ada`) and the password `yaak`."
|
||||
},
|
||||
"servers": [{ "url": "https://yaak.run" }],
|
||||
"tags": [
|
||||
{ "name": "Users" },
|
||||
{ "name": "Posts" },
|
||||
{ "name": "Todos" },
|
||||
{ "name": "Auth", "description": "Two ways to stay logged in: a bearer token you send yourself, or a session cookie the cookie jar sends for you." }
|
||||
],
|
||||
"paths": {
|
||||
"/users": {
|
||||
"get": {
|
||||
"tags": ["Users"],
|
||||
"operationId": "listUsers",
|
||||
"summary": "List users",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Every user",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "type": "array", "items": { "$ref": "#/components/schemas/User" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/{id}": {
|
||||
"get": {
|
||||
"tags": ["Users"],
|
||||
"operationId": "getUser",
|
||||
"summary": "Get a user",
|
||||
"parameters": [{ "$ref": "#/components/parameters/Id" }],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The user",
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/User" } } }
|
||||
},
|
||||
"404": { "$ref": "#/components/responses/NotFound" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/{id}/todos": {
|
||||
"get": {
|
||||
"tags": ["Users", "Todos"],
|
||||
"operationId": "listUserTodos",
|
||||
"summary": "List a user's todos",
|
||||
"parameters": [
|
||||
{ "$ref": "#/components/parameters/Id" },
|
||||
{ "$ref": "#/components/parameters/Completed" },
|
||||
{ "$ref": "#/components/parameters/Limit" }
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The user's todos",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "type": "array", "items": { "$ref": "#/components/schemas/Todo" } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "$ref": "#/components/responses/BadRequest" },
|
||||
"404": { "$ref": "#/components/responses/NotFound" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/todos": {
|
||||
"get": {
|
||||
"tags": ["Todos"],
|
||||
"operationId": "listTodos",
|
||||
"summary": "List todos",
|
||||
"parameters": [
|
||||
{ "$ref": "#/components/parameters/UserId" },
|
||||
{ "$ref": "#/components/parameters/Completed" },
|
||||
{ "$ref": "#/components/parameters/Limit" }
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Matching todos",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "type": "array", "items": { "$ref": "#/components/schemas/Todo" } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "$ref": "#/components/responses/BadRequest" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/posts": {
|
||||
"get": {
|
||||
"tags": ["Posts"],
|
||||
"operationId": "listPosts",
|
||||
"summary": "List posts",
|
||||
"parameters": [
|
||||
{ "$ref": "#/components/parameters/UserId" },
|
||||
{ "$ref": "#/components/parameters/Limit" }
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Matching posts",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "type": "array", "items": { "$ref": "#/components/schemas/Post" } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "$ref": "#/components/responses/BadRequest" }
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": ["Posts"],
|
||||
"operationId": "createPost",
|
||||
"summary": "Create a post",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/PostInput" } } }
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "The new post",
|
||||
"headers": {
|
||||
"Location": { "description": "Where to fetch the new post", "schema": { "type": "string" } }
|
||||
},
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Post" } } }
|
||||
},
|
||||
"400": { "$ref": "#/components/responses/BadRequest" },
|
||||
"409": {
|
||||
"description": "You already have the most posts allowed",
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/posts/{id}": {
|
||||
"get": {
|
||||
"tags": ["Posts"],
|
||||
"operationId": "getPost",
|
||||
"summary": "Get a post",
|
||||
"parameters": [{ "$ref": "#/components/parameters/Id" }],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The post",
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Post" } } }
|
||||
},
|
||||
"404": { "$ref": "#/components/responses/NotFound" }
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"tags": ["Posts"],
|
||||
"operationId": "replacePost",
|
||||
"summary": "Replace a post",
|
||||
"parameters": [{ "$ref": "#/components/parameters/Id" }],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/PostInput" } } }
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The updated post",
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Post" } } }
|
||||
},
|
||||
"400": { "$ref": "#/components/responses/BadRequest" },
|
||||
"404": { "$ref": "#/components/responses/NotFound" }
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"tags": ["Posts"],
|
||||
"operationId": "updatePost",
|
||||
"summary": "Update part of a post",
|
||||
"parameters": [{ "$ref": "#/components/parameters/Id" }],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/PostPatch" } } }
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The updated post",
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Post" } } }
|
||||
},
|
||||
"400": { "$ref": "#/components/responses/BadRequest" },
|
||||
"404": { "$ref": "#/components/responses/NotFound" }
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": ["Posts"],
|
||||
"operationId": "deletePost",
|
||||
"summary": "Delete a post",
|
||||
"parameters": [{ "$ref": "#/components/parameters/Id" }],
|
||||
"responses": {
|
||||
"204": { "description": "Deleted" },
|
||||
"404": { "$ref": "#/components/responses/NotFound" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/token": {
|
||||
"post": {
|
||||
"tags": ["Auth"],
|
||||
"operationId": "createToken",
|
||||
"summary": "Log in for a bearer token",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Login" } } }
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "A token to send as `Authorization: Bearer <token>`",
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Token" } } }
|
||||
},
|
||||
"400": { "$ref": "#/components/responses/BadRequest" },
|
||||
"401": { "$ref": "#/components/responses/Unauthorized" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/me": {
|
||||
"get": {
|
||||
"tags": ["Auth"],
|
||||
"operationId": "getMe",
|
||||
"summary": "Get the user a bearer token belongs to",
|
||||
"security": [{ "bearerAuth": [] }],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The logged-in user",
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/User" } } }
|
||||
},
|
||||
"401": { "$ref": "#/components/responses/Unauthorized" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/session": {
|
||||
"post": {
|
||||
"tags": ["Auth"],
|
||||
"operationId": "createSession",
|
||||
"summary": "Log in with a session cookie",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Login" } } }
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The logged-in user. The session cookie comes back in `Set-Cookie`.",
|
||||
"headers": {
|
||||
"Set-Cookie": { "description": "The `session` cookie", "schema": { "type": "string" } }
|
||||
},
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/User" } } }
|
||||
},
|
||||
"400": { "$ref": "#/components/responses/BadRequest" },
|
||||
"401": { "$ref": "#/components/responses/Unauthorized" }
|
||||
}
|
||||
},
|
||||
"get": {
|
||||
"tags": ["Auth"],
|
||||
"operationId": "getSession",
|
||||
"summary": "Get the user a session cookie belongs to",
|
||||
"security": [{ "cookieAuth": [] }],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The logged-in user",
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/User" } } }
|
||||
},
|
||||
"401": { "$ref": "#/components/responses/Unauthorized" }
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": ["Auth"],
|
||||
"operationId": "deleteSession",
|
||||
"summary": "Log out",
|
||||
"responses": {
|
||||
"204": { "description": "Logged out, and the cookie is cleared" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"parameters": {
|
||||
"Id": {
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "integer", "minimum": 1 },
|
||||
"example": 1
|
||||
},
|
||||
"UserId": {
|
||||
"name": "userId",
|
||||
"in": "query",
|
||||
"description": "Only return items belonging to this user",
|
||||
"schema": { "type": "integer", "minimum": 1 }
|
||||
},
|
||||
"Completed": {
|
||||
"name": "completed",
|
||||
"in": "query",
|
||||
"schema": { "type": "boolean" }
|
||||
},
|
||||
"Limit": {
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"description": "Return at most this many items",
|
||||
"schema": { "type": "integer", "minimum": 0 }
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"BadRequest": {
|
||||
"description": "Something in the request is invalid",
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
|
||||
},
|
||||
"NotFound": {
|
||||
"description": "Nothing has that ID",
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
|
||||
},
|
||||
"Unauthorized": {
|
||||
"description": "Missing or invalid credentials",
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
"User": {
|
||||
"type": "object",
|
||||
"required": ["id", "name", "username", "email"],
|
||||
"properties": {
|
||||
"id": { "type": "integer", "example": 1 },
|
||||
"name": { "type": "string", "example": "Ada Lovelace" },
|
||||
"username": { "type": "string", "example": "ada" },
|
||||
"email": { "type": "string", "format": "email", "example": "ada@example.com" }
|
||||
}
|
||||
},
|
||||
"Post": {
|
||||
"type": "object",
|
||||
"required": ["id", "userId", "title", "body"],
|
||||
"properties": {
|
||||
"id": { "type": "integer", "example": 1 },
|
||||
"userId": { "type": "integer", "example": 1 },
|
||||
"title": { "type": "string" },
|
||||
"body": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"PostInput": {
|
||||
"type": "object",
|
||||
"required": ["userId", "title", "body"],
|
||||
"properties": {
|
||||
"userId": { "type": "integer", "example": 1 },
|
||||
"title": { "type": "string", "maxLength": 200, "example": "Hello from Yaak" },
|
||||
"body": { "type": "string", "maxLength": 2000, "example": "My first post" }
|
||||
}
|
||||
},
|
||||
"PostPatch": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"userId": { "type": "integer" },
|
||||
"title": { "type": "string", "maxLength": 200 },
|
||||
"body": { "type": "string", "maxLength": 2000 }
|
||||
}
|
||||
},
|
||||
"Todo": {
|
||||
"type": "object",
|
||||
"required": ["id", "userId", "title", "completed"],
|
||||
"properties": {
|
||||
"id": { "type": "integer", "example": 1 },
|
||||
"userId": { "type": "integer", "example": 1 },
|
||||
"title": { "type": "string" },
|
||||
"completed": { "type": "boolean" }
|
||||
}
|
||||
},
|
||||
"Login": {
|
||||
"type": "object",
|
||||
"required": ["username", "password"],
|
||||
"properties": {
|
||||
"username": { "type": "string", "example": "ada" },
|
||||
"password": { "type": "string", "example": "yaak" }
|
||||
}
|
||||
},
|
||||
"Token": {
|
||||
"type": "object",
|
||||
"required": ["accessToken", "tokenType", "expiresIn"],
|
||||
"properties": {
|
||||
"accessToken": { "type": "string" },
|
||||
"tokenType": { "type": "string", "const": "Bearer" },
|
||||
"expiresIn": { "type": "integer", "description": "Seconds until the token expires", "example": 3600 }
|
||||
}
|
||||
},
|
||||
"Error": {
|
||||
"type": "object",
|
||||
"required": ["error"],
|
||||
"properties": { "error": { "type": "string" } }
|
||||
}
|
||||
},
|
||||
"securitySchemes": {
|
||||
"bearerAuth": { "type": "http", "scheme": "bearer" },
|
||||
"cookieAuth": { "type": "apiKey", "in": "cookie", "name": "session" }
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 358 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user