mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 20:23:40 +01:00
Compare commits
57
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5520fb3a8 | ||
|
|
c71256d800 | ||
|
|
6e7f23a778 | ||
|
|
b03c41b767 | ||
|
|
a0d71eb486 | ||
|
|
38c562f775 | ||
|
|
1249d15f2c | ||
|
|
1707c387ea | ||
|
|
a497d9f018 | ||
|
|
c13e26656b | ||
|
|
dfc974db95 | ||
|
|
0b86d3ca3f | ||
|
|
9e51e95f9e | ||
|
|
aa10c44a45 | ||
|
|
46464cfacf | ||
|
|
263a215f7a | ||
|
|
646d969649 | ||
|
|
4913711968 | ||
|
|
35d4c9d79d | ||
|
|
bbe8d1eef5 | ||
|
|
1e3dfff01e | ||
|
|
fccff5b7db | ||
|
|
6ed25b9f42 | ||
|
|
4a4d3b4301 | ||
|
|
25a464546a | ||
|
|
2c51fc92d6 | ||
|
|
f827aae46d | ||
|
|
caeebebc75 | ||
|
|
621f9c3b8f | ||
|
|
77b18d3477 | ||
|
|
a8c845b091 | ||
|
|
bcbf6d32a7 | ||
|
|
e30b289ed7 | ||
|
|
85904566e7 | ||
|
|
0b6803c6c0 | ||
|
|
edeb433c79 | ||
|
|
12e6e8a977 | ||
|
|
ab616fd915 | ||
|
|
4859aba142 | ||
|
|
cef569ee15 | ||
|
|
ae518dfb4c | ||
|
|
c9990d8492 | ||
|
|
309818200a | ||
|
|
06142d007a | ||
|
|
8a6e4810fd | ||
|
|
5a793e1d42 | ||
|
|
3d6c31440c | ||
|
|
31d55440c1 | ||
|
|
a822cc93d4 | ||
|
|
48889b6550 | ||
|
|
881cff472d | ||
|
|
d465c5679a | ||
|
|
0a68dea133 | ||
|
|
17a44f34a3 | ||
|
|
8c8be31aa2 | ||
|
|
ff5db718ce | ||
|
|
fd95b4cba3 |
No files matched your search
@@ -7,6 +7,23 @@ updates:
|
||||
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
|
||||
@@ -25,6 +42,10 @@ updates:
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// Retain only distributable bundle files, never the target tree or signing material.
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
copyFileSync,
|
||||
createReadStream,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
|
||||
const env = process.env;
|
||||
const bundle = join("target", env.RELEASE_TARGET || "", "release", "bundle");
|
||||
const destination = join(env.RUNNER_TEMP, "yaak-release-artifacts");
|
||||
const extensions =
|
||||
/\.(dmg|deb|rpm|AppImage|exe|msi|app\.tar\.gz|AppImage\.tar\.gz|nsis\.zip|msi\.zip)(\.sig)?$/;
|
||||
const files = [];
|
||||
|
||||
function snapshot(directory) {
|
||||
if (!existsSync(directory)) return;
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
const source = join(directory, entry.name);
|
||||
// Only descend through the bundler's immediate output folders. This excludes
|
||||
// .app trees and the extracted CEF deb contents (which contain executables).
|
||||
if (entry.isDirectory() && directory === bundle) {
|
||||
snapshot(source);
|
||||
} else if (
|
||||
entry.isFile() &&
|
||||
(extensions.test(entry.name) || /^yaak-cef_.*_linux_.*\.tar\.gz$/.test(entry.name))
|
||||
) {
|
||||
// Match tauri-action's default macOS updater asset naming exactly.
|
||||
const arch = env.RELEASE_ARCH === "arm64" ? "aarch64" : "x64";
|
||||
const assetName = entry.name.replace(/\.app\.tar\.gz(\.sig)?$/, `_${arch}.app.tar.gz$1`);
|
||||
mkdirSync(destination, { recursive: true });
|
||||
const retained = join(destination, assetName);
|
||||
// The second snapshot must not overwrite the original user installer/signature.
|
||||
if (!existsSync(retained)) copyFileSync(source, retained);
|
||||
files.push({ source: relative(".", source), assetName });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
snapshot(bundle);
|
||||
if (existsSync(destination)) {
|
||||
const assets = readdirSync(destination)
|
||||
.filter((name) => name !== "provenance.json")
|
||||
.sort();
|
||||
const checksums = [];
|
||||
for (const assetName of assets) {
|
||||
const hash = createHash("sha256");
|
||||
for await (const chunk of createReadStream(join(destination, assetName))) hash.update(chunk);
|
||||
checksums.push({ assetName, sha256: hash.digest("hex") });
|
||||
}
|
||||
writeFileSync(
|
||||
join(destination, "provenance.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
repository: env.GITHUB_REPOSITORY,
|
||||
tag: env.GITHUB_REF_NAME,
|
||||
sha: env.GITHUB_SHA,
|
||||
runId: env.GITHUB_RUN_ID,
|
||||
runAttempt: env.GITHUB_RUN_ATTEMPT,
|
||||
workflowRef: env.GITHUB_WORKFLOW_REF,
|
||||
workflowSha: env.GITHUB_WORKFLOW_SHA,
|
||||
tauriAction: "tauri-apps/tauri-action@v0",
|
||||
platform: env.RELEASE_PLATFORM,
|
||||
runtime: env.RELEASE_RUNTIME,
|
||||
arch: env.RELEASE_ARCH,
|
||||
target: env.RELEASE_TARGET,
|
||||
args: env.RELEASE_ARGS,
|
||||
assets: checksums,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
);
|
||||
}
|
||||
console.log(`Found ${files.length} distributable bundle files in ${bundle}`);
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
runtime: "wry"
|
||||
targets: "aarch64-pc-windows-msvc"
|
||||
runs-on: ${{ matrix.platform }}
|
||||
timeout-minutes: 60
|
||||
timeout-minutes: 360
|
||||
steps:
|
||||
- name: Checkout yaakapp/app
|
||||
uses: actions/checkout@v4
|
||||
@@ -172,7 +172,8 @@ jobs:
|
||||
codesign --force --options runtime --entitlements crates-tauri/yaak-app-client/macos/entitlements.yaakprotoc.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app-client/vendored/protoc/yaakprotoc || true
|
||||
codesign --force --options runtime --entitlements crates-tauri/yaak-app-client/macos/entitlements.yaaknode.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app-client/vendored/node/yaaknode || true
|
||||
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
- name: Build and upload Tauri bundles
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
|
||||
|
||||
@@ -194,6 +195,8 @@ jobs:
|
||||
AZURE_CLIENT_SECRET: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_TENANT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_TENANT_ID }}
|
||||
with:
|
||||
# Also retries builds; upload retries alone cannot clean up starter assets.
|
||||
retryAttempts: 3
|
||||
tauriScript: "node ../../node_modules/@tauri-apps/cli/tauri.js"
|
||||
tagName: "v__VERSION__"
|
||||
releaseName: "Release __VERSION__"
|
||||
@@ -203,6 +206,17 @@ jobs:
|
||||
projectPath: ./crates-tauri/yaak-app-client
|
||||
args: "${{ matrix.args }}"
|
||||
|
||||
# Snapshot before the per-machine build overwrites the regular NSIS bundle/signature.
|
||||
- name: Snapshot Tauri bundles
|
||||
if: ${{ !cancelled() }}
|
||||
run: node .github/scripts/preserve-release-artifacts.mjs
|
||||
env:
|
||||
RELEASE_TARGET: ${{ matrix.targets }}
|
||||
RELEASE_PLATFORM: ${{ matrix.platform }}
|
||||
RELEASE_RUNTIME: ${{ matrix.runtime }}
|
||||
RELEASE_ARCH: ${{ matrix.yaak_arch }}
|
||||
RELEASE_ARGS: ${{ matrix.args }}
|
||||
|
||||
- name: Build and upload CEF tarball from deb (Linux only)
|
||||
if: matrix.os == 'ubuntu' && matrix.runtime == 'cef'
|
||||
env:
|
||||
@@ -217,7 +231,13 @@ jobs:
|
||||
mkdir -p "$extract_dir"
|
||||
dpkg-deb -x "$deb" "$extract_dir"
|
||||
tar -C "$extract_dir" -czf "$tarball" .
|
||||
gh release upload "${{ github.ref_name }}" "$tarball" --clobber
|
||||
for attempt in 1 2 3 4; do
|
||||
if gh release upload "$GITHUB_REF_NAME" "$tarball" --clobber; then
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -eq 4 ]; then exit 1; fi
|
||||
sleep 10
|
||||
done
|
||||
|
||||
# Build a per-machine NSIS installer for enterprise deployment (PDQ, SCCM, Intune)
|
||||
- name: Build and upload machine-wide installer (Windows only)
|
||||
@@ -235,6 +255,7 @@ jobs:
|
||||
Get-ChildItem -Recurse -Path target -File -Filter "*.exe.sig" | Remove-Item -Force
|
||||
Push-Location crates-tauri/yaak-app-client
|
||||
npx tauri bundle ${{ matrix.args }} --bundles nsis --config '{"bundle":{"createUpdaterArtifacts":true,"windows":{"nsis":{"installMode":"perMachine"}}}}'
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
Pop-Location
|
||||
$setup = Get-ChildItem -Recurse -Path target -Filter "*setup*.exe" | Select-Object -First 1
|
||||
$setupSig = "$($setup.FullName).sig"
|
||||
@@ -242,5 +263,30 @@ jobs:
|
||||
$destSig = "$dest.sig"
|
||||
Copy-Item $setup.FullName $dest
|
||||
Copy-Item $setupSig $destSig
|
||||
gh release upload "${{ github.ref_name }}" "$dest" --clobber
|
||||
gh release upload "${{ github.ref_name }}" "$destSig" --clobber
|
||||
foreach ($asset in @($dest, $destSig)) {
|
||||
for ($attempt = 1; $attempt -le 4; $attempt++) {
|
||||
gh release upload "$env:GITHUB_REF_NAME" "$asset" --clobber
|
||||
if ($LASTEXITCODE -eq 0) { break }
|
||||
if ($attempt -eq 4) { exit $LASTEXITCODE }
|
||||
Start-Sleep -Seconds 10
|
||||
}
|
||||
}
|
||||
|
||||
- name: Snapshot additional release bundles
|
||||
if: ${{ !cancelled() }}
|
||||
run: node .github/scripts/preserve-release-artifacts.mjs
|
||||
env:
|
||||
RELEASE_TARGET: ${{ matrix.targets }}
|
||||
RELEASE_PLATFORM: ${{ matrix.platform }}
|
||||
RELEASE_RUNTIME: ${{ matrix.runtime }}
|
||||
RELEASE_ARCH: ${{ matrix.yaak_arch }}
|
||||
RELEASE_ARGS: ${{ matrix.args }}
|
||||
|
||||
- name: Retain release bundles for recovery
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-${{ github.ref_name }}-${{ matrix.platform }}-${{ matrix.runtime }}-${{ matrix.yaak_arch }}-${{ github.run_id }}-attempt-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/yaak-release-artifacts/
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
+2
-1
@@ -3,6 +3,7 @@
|
||||
"ignorePatterns": [
|
||||
"**/bindings/**",
|
||||
"crates/yaak-templates/pkg/**",
|
||||
"apps/yaak-client/routeTree.gen.ts"
|
||||
"apps/yaak-client/routeTree.gen.ts",
|
||||
"plugins/importer-bruno/tests/fixtures/upstream/**"
|
||||
]
|
||||
}
|
||||
Generated
+1063
-1018
File diff suppressed because it is too large.
Load diff
+13
-1
@@ -2,6 +2,8 @@
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/yaak",
|
||||
"crates/yaak-assertions",
|
||||
"crates/yaak-jsonpath",
|
||||
"crates/yaak-commands",
|
||||
# Common/foundation crates
|
||||
"crates/common/yaak-database",
|
||||
@@ -29,6 +31,7 @@ members = [
|
||||
"crates-proxy/yaak-proxy-lib",
|
||||
# Server crates (the browser tier's hosted send executor)
|
||||
"crates-server/yaak-web",
|
||||
"crates-server/yaak-playground",
|
||||
# CLI crates
|
||||
"crates-cli/yaak-cli",
|
||||
# Tauri-specific crates
|
||||
@@ -47,7 +50,14 @@ chrono = "0.4.42"
|
||||
hex = "0.4.3"
|
||||
keyring = "3.6.3"
|
||||
log = "0.4.29"
|
||||
reqwest = "0.12.20"
|
||||
reqwest = { version = "0.13.5", default-features = false, features = [
|
||||
# reqwest 0.13 switched `default-tls` from native-tls to rustls; keep the
|
||||
# rest of the old default set and stay on native-tls.
|
||||
"charset",
|
||||
"http2",
|
||||
"system-proxy",
|
||||
"native-tls",
|
||||
] }
|
||||
rustls = { version = "0.23.34", default-features = false }
|
||||
rustls-platform-verifier = "0.6.2"
|
||||
schemars = { version = "0.8.22", features = ["chrono"] }
|
||||
@@ -74,6 +84,8 @@ yaak-rpc-schema = { path = "crates/common/yaak-rpc-schema" }
|
||||
# Internal crates - shared
|
||||
yaak-core = { path = "crates/yaak-core" }
|
||||
yaak = { path = "crates/yaak" }
|
||||
yaak-assertions = { path = "crates/yaak-assertions" }
|
||||
yaak-jsonpath = { path = "crates/yaak-jsonpath" }
|
||||
yaak-commands = { path = "crates/yaak-commands" }
|
||||
yaak-common = { path = "crates/yaak-common" }
|
||||
yaak-crypto = { path = "crates/yaak-crypto" }
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# The playground API behind yaak.run. See crates-server/yaak-playground/README.md.
|
||||
|
||||
FROM rust:1-bookworm AS build
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN cargo build --release -p yaak-playground
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
COPY --from=build /app/target/release/yaak-playground /usr/local/bin/yaak-playground
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=8080
|
||||
EXPOSE 8080
|
||||
USER nobody
|
||||
CMD ["yaak-playground"]
|
||||
+4
-1
@@ -38,7 +38,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates libssl3 && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=server /app/target/release/yaak-web /usr/local/bin/yaak-web
|
||||
COPY --from=web /app/dist/apps/yaak-client /srv
|
||||
ENV YAAK_WEB_BIND=0.0.0.0:8080
|
||||
# 0.0.0.0 so the port is reachable from outside the container. PORT is the variable a
|
||||
# platform-as-a-service assigns, so a deploy that sets it needs nothing else changed.
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=8080
|
||||
EXPOSE 8080
|
||||
USER nobody
|
||||
# Overriding the command (dropping --serve) leaves the stateless send executor:
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { gitClone } from "@yaakapp-internal/git";
|
||||
import { Banner, VStack } from "@yaakapp-internal/ui";
|
||||
import { useState } from "react";
|
||||
import { useId, useState } from "react";
|
||||
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
|
||||
import { appInfo } from "../lib/appInfo";
|
||||
import { CommercialUseBanner } from "./CommercialUseBanner";
|
||||
import { showErrorToast } from "../lib/toast";
|
||||
import { Button } from "./core/Button";
|
||||
import { Checkbox } from "./core/Checkbox";
|
||||
import { DialogFooter } from "./core/Dialog";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import { promptCredentials } from "./git/credentials";
|
||||
@@ -29,6 +29,7 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
|
||||
const [subdirectory, setSubdirectory] = useState<string>("");
|
||||
const [isCloning, setIsCloning] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const formId = useId();
|
||||
|
||||
const repoName = extractRepoName(url);
|
||||
const sep = getPathSeparator(baseDirectory);
|
||||
@@ -83,7 +84,7 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack as="form" space={3} alignItems="start" className="pb-3" onSubmit={handleClone}>
|
||||
<VStack as="form" id={formId} space={3} alignItems="start" onSubmit={handleClone}>
|
||||
{error && (
|
||||
<Banner color="danger" className="w-full">
|
||||
{error}
|
||||
@@ -132,15 +133,17 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
color="primary"
|
||||
className="w-full mt-3"
|
||||
disabled={!url || !directory || isCloning}
|
||||
isLoading={isCloning}
|
||||
>
|
||||
{isCloning ? "Cloning..." : "Clone Repository"}
|
||||
</Button>
|
||||
<DialogFooter
|
||||
actions={[
|
||||
{
|
||||
label: isCloning ? "Cloning..." : "Clone Repository",
|
||||
color: "primary",
|
||||
form: formId,
|
||||
disabled: !url || !directory || isCloning,
|
||||
isLoading: isCloning,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createWorkspaceModel } from "@yaakapp-internal/models";
|
||||
import { useState } from "react";
|
||||
import { useId, useState } from "react";
|
||||
import { useToggle } from "../hooks/useToggle";
|
||||
import { ColorIndicator } from "./ColorIndicator";
|
||||
import { Button } from "./core/Button";
|
||||
import { Checkbox } from "./core/Checkbox";
|
||||
import { ColorPickerWithThemeColors } from "./core/ColorPicker";
|
||||
import { DialogFooter } from "./core/Dialog";
|
||||
import { Label } from "./core/Label";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
|
||||
@@ -18,9 +18,11 @@ export function CreateEnvironmentDialog({ workspaceId, hide, onCreate }: Props)
|
||||
const [name, setName] = useState<string>("");
|
||||
const [color, setColor] = useState<string | null>(null);
|
||||
const [sharable, toggleSharable] = useToggle(false);
|
||||
const formId = useId();
|
||||
return (
|
||||
<form
|
||||
className="pb-3 flex flex-col gap-3"
|
||||
id={formId}
|
||||
className="flex flex-col gap-3"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
const id = await createWorkspaceModel({
|
||||
@@ -59,10 +61,17 @@ export function CreateEnvironmentDialog({ workspaceId, hide, onCreate }: Props)
|
||||
</Label>
|
||||
<ColorPickerWithThemeColors onChange={setColor} color={color} />
|
||||
</div>
|
||||
<Button type="submit" color="secondary" className="mt-3">
|
||||
{color != null && <ColorIndicator color={color} />}
|
||||
Create Environment
|
||||
</Button>
|
||||
<DialogFooter
|
||||
inline
|
||||
actions={[
|
||||
{
|
||||
label: "Create Environment",
|
||||
color: "secondary",
|
||||
form: formId,
|
||||
leftSlot: color != null ? <ColorIndicator color={color} /> : undefined,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -2,13 +2,13 @@ import { gitMutations } from "@yaakapp-internal/git";
|
||||
import type { WorkspaceMeta } from "@yaakapp-internal/models";
|
||||
import { createGlobalModel, updateModel } from "@yaakapp-internal/models";
|
||||
import { VStack } from "@yaakapp-internal/ui";
|
||||
import { useState } from "react";
|
||||
import { useId, useState } from "react";
|
||||
import { router } from "../lib/router";
|
||||
import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
|
||||
import { rpc } from "../lib/rpc";
|
||||
import { showErrorToast } from "../lib/toast";
|
||||
import { Button } from "./core/Button";
|
||||
import { Checkbox } from "./core/Checkbox";
|
||||
import { DialogFooter } from "./core/Dialog";
|
||||
import { Label } from "./core/Label";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import { EncryptionHelp } from "./EncryptionHelp";
|
||||
@@ -26,12 +26,13 @@ export function CreateWorkspaceDialog({ hide }: Props) {
|
||||
initGit?: boolean;
|
||||
}>({ filePath: null, initGit: false });
|
||||
const [setupEncryption, setSetupEncryption] = useState<boolean>(false);
|
||||
const formId = useId();
|
||||
return (
|
||||
<VStack
|
||||
as="form"
|
||||
id={formId}
|
||||
space={3}
|
||||
alignItems="start"
|
||||
className="pb-3"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
const workspaceId = await createGlobalModel({ model: "workspace", name });
|
||||
@@ -89,9 +90,7 @@ export function CreateWorkspaceDialog({ hide }: Props) {
|
||||
title="Enable Encryption"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" color="primary" className="w-full mt-3">
|
||||
Create Workspace
|
||||
</Button>
|
||||
<DialogFooter actions={[{ label: "Create Workspace", color: "primary", form: formId }]} />
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Workspace } from "@yaakapp-internal/models";
|
||||
import { workspacesAtom } from "@yaakapp-internal/models";
|
||||
import { HStack, VStack } from "@yaakapp-internal/ui";
|
||||
import { VStack } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import slugify from "slugify";
|
||||
@@ -8,9 +8,9 @@ import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
|
||||
import { pluralizeCount } from "../lib/pluralize";
|
||||
import { rpc } from "../lib/rpc";
|
||||
import { CommercialUseBanner } from "./CommercialUseBanner";
|
||||
import { Button } from "./core/Button";
|
||||
import { Checkbox } from "./core/Checkbox";
|
||||
import { DetailsBanner } from "./core/DetailsBanner";
|
||||
import { DialogFooter } from "./core/Dialog";
|
||||
import { Link } from "./core/Link";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
|
||||
@@ -65,29 +65,30 @@ function ExportDataDialogContent({
|
||||
const ids = Object.keys(selectedWorkspaces).filter((k) => selectedWorkspaces[k]);
|
||||
const workspace = ids.length === 1 ? workspaces.find((w) => w.id === ids[0]) : undefined;
|
||||
const slug = workspace ? slugify(workspace.name, { lower: true }) : "workspaces";
|
||||
const exportPath = await platform.dialog.save({
|
||||
title: "Export Data",
|
||||
defaultPath: `yaak.${slug}.json`,
|
||||
});
|
||||
if (exportPath == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await rpc("cmd_export_data", {
|
||||
const document = await rpc<string>("cmd_export_data", {
|
||||
workspaceIds: ids,
|
||||
exportPath,
|
||||
includePrivateEnvironments: includePrivateEnvironments,
|
||||
});
|
||||
|
||||
const savedTo = await platform.files.save(
|
||||
`yaak.${slug}.json`,
|
||||
new TextEncoder().encode(document),
|
||||
[{ name: "JSON", extensions: ["json"] }],
|
||||
);
|
||||
if (savedTo == null) {
|
||||
return; // Cancelled
|
||||
}
|
||||
|
||||
onHide();
|
||||
onSuccess(exportPath);
|
||||
onSuccess(savedTo);
|
||||
}, [includePrivateEnvironments, onHide, onSuccess, selectedWorkspaces, workspaces]);
|
||||
|
||||
const allSelected = workspaces.every((w) => selectedWorkspaces[w.id]);
|
||||
const numSelected = Object.values(selectedWorkspaces).filter(Boolean).length;
|
||||
const noneSelected = numSelected === 0;
|
||||
return (
|
||||
<div className="h-full w-full grid grid-rows-[minmax(0,1fr)_auto] overflow-hidden rounded-b-lg">
|
||||
<VStack space={3} className="overflow-auto px-5 pb-6">
|
||||
<>
|
||||
<VStack space={3} className="pb-4">
|
||||
<CommercialUseBanner source="data-export" title="Exporting work data?" />
|
||||
|
||||
<table className="w-full mb-auto min-w-full max-w-full divide-y divide-surface-highlight">
|
||||
@@ -140,29 +141,22 @@ function ExportDataDialogContent({
|
||||
/>
|
||||
</DetailsBanner>
|
||||
</VStack>
|
||||
<footer className="px-5 grid grid-cols-[1fr_auto] items-center bg-surface py-3 border-t border-border-subtle">
|
||||
<div>
|
||||
<DialogFooter
|
||||
leftSlot={
|
||||
<Link href="https://yaak.app/button/new" noUnderline className="text-text-subtlest">
|
||||
Create Run Button
|
||||
</Link>
|
||||
</div>
|
||||
<HStack space={2} justifyContent="end">
|
||||
<Button size="sm" className="focus" variant="border" onClick={onHide}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
type="submit"
|
||||
className="focus"
|
||||
color="primary"
|
||||
disabled={noneSelected}
|
||||
onClick={() => handleExport()}
|
||||
>
|
||||
Export{" "}
|
||||
{pluralizeCount("Workspace", numSelected, { omitSingle: true, noneWord: "Nothing" })}
|
||||
</Button>
|
||||
</HStack>
|
||||
</footer>
|
||||
</div>
|
||||
}
|
||||
actions={[
|
||||
{ label: "Cancel", onClick: onHide },
|
||||
{
|
||||
label: `Export ${pluralizeCount("Workspace", numSelected, { omitSingle: true, noneWord: "Nothing" })}`,
|
||||
color: "primary",
|
||||
disabled: noneSelected,
|
||||
onClick: () => handleExport(),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { NavigateWorkspace } from "@yaakapp-internal/tauri-client";
|
||||
import { activeRequestAtom } from "../hooks/useActiveRequest";
|
||||
import { useSubscribeActiveWorkspaceId } from "../hooks/useActiveWorkspace";
|
||||
import { useActiveWorkspaceChangedToast } from "../hooks/useActiveWorkspaceChangedToast";
|
||||
@@ -5,10 +6,13 @@ import { useHotKey, useSubscribeHotKeys } from "../hooks/useHotKey";
|
||||
import { useSubscribeHttpAuthentication } from "../hooks/useHttpAuthentication";
|
||||
import { useSyncFontSizeSetting } from "../hooks/useSyncFontSizeSetting";
|
||||
import { useSyncWorkspaceChildModels } from "../hooks/useSyncWorkspaceChildModels";
|
||||
import { usePlatformEvent } from "../hooks/usePlatformEvent";
|
||||
import { useSyncZoomSetting } from "../hooks/useSyncZoomSetting";
|
||||
import { useSubscribeTemplateFunctions } from "../hooks/useTemplateFunctions";
|
||||
import { fireAndForget } from "../lib/fireAndForget";
|
||||
import { jotaiStore } from "../lib/jotai";
|
||||
import { renameModelWithPrompt } from "../lib/renameModelWithPrompt";
|
||||
import { router } from "../lib/router";
|
||||
|
||||
export function GlobalHooks() {
|
||||
useSyncZoomSetting();
|
||||
@@ -24,6 +28,20 @@ export function GlobalHooks() {
|
||||
useActiveWorkspaceChangedToast();
|
||||
useSubscribeHotKeys();
|
||||
|
||||
usePlatformEvent("show_home", () => {
|
||||
fireAndForget(router.navigate({ to: "/", search: { home: true } }));
|
||||
});
|
||||
|
||||
usePlatformEvent<NavigateWorkspace>("navigate_workspace", ({ workspaceId, environmentId }) => {
|
||||
fireAndForget(
|
||||
router.navigate({
|
||||
to: "/workspaces/$workspaceId",
|
||||
params: { workspaceId },
|
||||
search: { environment_id: environmentId },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
useHotKey(
|
||||
"request.rename",
|
||||
async () => {
|
||||
|
||||
@@ -239,6 +239,7 @@ export function GrpcEditor({
|
||||
showDialog({
|
||||
title: "Configure Schema",
|
||||
size: "md",
|
||||
className: "h-[30rem]",
|
||||
id: "grpc-configure-schema",
|
||||
render: ({ hide }) => <GrpcProtoSelectionDialog onDone={hide} />,
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { GrpcRequest } from "@yaakapp-internal/models";
|
||||
import { Banner, HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import { Banner, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import { useActiveRequest } from "../hooks/useActiveRequest";
|
||||
import { useGrpc } from "../hooks/useGrpc";
|
||||
import { useGrpcProtoFiles } from "../hooks/useGrpcProtoFiles";
|
||||
import { pluralizeCount } from "../lib/pluralize";
|
||||
import { Button } from "./core/Button";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { DialogFooter } from "./core/Dialog";
|
||||
import { PathList } from "./core/PathList";
|
||||
import { Link } from "./core/Link";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
|
||||
@@ -20,7 +20,10 @@ export function GrpcProtoSelectionDialog(props: Props) {
|
||||
return <GrpcProtoSelectionDialogWithRequest request={request} {...props} />;
|
||||
}
|
||||
|
||||
function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: GrpcRequest }) {
|
||||
function GrpcProtoSelectionDialogWithRequest({
|
||||
request,
|
||||
onDone,
|
||||
}: Props & { request: GrpcRequest }) {
|
||||
const protoFilesKv = useGrpcProtoFiles(request.id);
|
||||
const protoFiles = protoFilesKv.value ?? [];
|
||||
const grpc = useGrpc(request, null, protoFiles);
|
||||
@@ -38,54 +41,8 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack className="flex-col-reverse mb-3" space={3}>
|
||||
{/* Buttons on top so they get focus first */}
|
||||
<HStack space={2} justifyContent="start" className="flex-row-reverse mt-3">
|
||||
<Button
|
||||
color="primary"
|
||||
variant="border"
|
||||
onClick={async () => {
|
||||
const selected = await platform.dialog.open({
|
||||
title: "Select Proto Files",
|
||||
multiple: true,
|
||||
filters: [{ name: "Proto Files", extensions: ["proto"] }],
|
||||
});
|
||||
if (selected == null) return;
|
||||
|
||||
const newFiles = selected.filter((p) => !protoFiles.includes(p));
|
||||
await protoFilesKv.set([...protoFiles, ...newFiles]);
|
||||
await grpc.reflect.refetch();
|
||||
}}
|
||||
>
|
||||
Add Proto Files
|
||||
</Button>
|
||||
<Button
|
||||
variant="border"
|
||||
color="primary"
|
||||
onClick={async () => {
|
||||
const selected = await platform.dialog.open({
|
||||
title: "Select Proto Directory",
|
||||
directory: true,
|
||||
});
|
||||
if (selected == null) return;
|
||||
|
||||
await protoFilesKv.set([...protoFiles.filter((f) => f !== selected), selected]);
|
||||
await grpc.reflect.refetch();
|
||||
}}
|
||||
>
|
||||
Add Import Folders
|
||||
</Button>
|
||||
<Button
|
||||
isLoading={grpc.reflect.isFetching}
|
||||
disabled={grpc.reflect.isFetching}
|
||||
variant="border"
|
||||
color="secondary"
|
||||
onClick={() => grpc.reflect.refetch()}
|
||||
>
|
||||
Refresh Schema
|
||||
</Button>
|
||||
</HStack>
|
||||
<VStack space={5}>
|
||||
<>
|
||||
<VStack space={5} className="pb-4">
|
||||
{reflectError && (
|
||||
<Banner color="warning">
|
||||
<h1 className="font-bold">
|
||||
@@ -128,46 +85,47 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
{protoFiles.length > 0 && (
|
||||
<table className="w-full divide-y divide-surface-highlight">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-text-subtlest" colSpan={3}>
|
||||
Added File Paths
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-surface-highlight">
|
||||
{protoFiles.map((f, i) => {
|
||||
const parts = f.split("/");
|
||||
// oxlint-disable-next-line no-array-index-key -- none
|
||||
return (
|
||||
<tr key={f + i} className="group">
|
||||
<td>
|
||||
<Icon icon={f.endsWith(".proto") ? "file_code" : "folder_code"} />
|
||||
</td>
|
||||
<td className="pl-1 font-mono text-sm" title={f}>
|
||||
{parts.length > 3 && ".../"}
|
||||
{parts.slice(-3).join("/")}
|
||||
</td>
|
||||
<td className="w-0 py-0.5">
|
||||
<IconButton
|
||||
title="Remove file"
|
||||
variant="border"
|
||||
size="xs"
|
||||
icon="trash"
|
||||
className="my-0.5 ml-auto opacity-50 transition-opacity group-hover:opacity-100"
|
||||
onClick={async () => {
|
||||
await protoFilesKv.set(protoFiles.filter((p) => p !== f));
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<PathList
|
||||
items={protoFiles.map((path) => ({
|
||||
path,
|
||||
icon: path.endsWith(".proto") ? "file_code" : "folder_code",
|
||||
}))}
|
||||
onRemove={async (path) => {
|
||||
await protoFilesKv.set(protoFiles.filter((p) => p !== path));
|
||||
await grpc.reflect.refetch();
|
||||
}}
|
||||
addLabel="Add proto files or an import folder"
|
||||
addItems={[
|
||||
{
|
||||
label: "Proto files",
|
||||
icon: "file_code",
|
||||
onSelect: async () => {
|
||||
const selected = await platform.dialog.open({
|
||||
title: "Select Proto Files",
|
||||
multiple: true,
|
||||
filters: [{ name: "Proto Files", extensions: ["proto"] }],
|
||||
});
|
||||
if (selected == null) return;
|
||||
const newFiles = selected.filter((p) => !protoFiles.includes(p));
|
||||
await protoFilesKv.set([...protoFiles, ...newFiles]);
|
||||
await grpc.reflect.refetch();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Import folder",
|
||||
icon: "folder_code",
|
||||
onSelect: async () => {
|
||||
const selected = await platform.dialog.open({
|
||||
title: "Select Proto Directory",
|
||||
directory: true,
|
||||
});
|
||||
if (selected == null) return;
|
||||
await protoFilesKv.set([...protoFiles.filter((f) => f !== selected), selected]);
|
||||
await grpc.reflect.refetch();
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{reflectionUnimplemented && protoFiles.length === 0 && (
|
||||
<Banner>
|
||||
<InlineCode>{request.url}</InlineCode> doesn't implement{" "}
|
||||
@@ -178,6 +136,17 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
|
||||
</Banner>
|
||||
)}
|
||||
</VStack>
|
||||
</VStack>
|
||||
<DialogFooter
|
||||
actions={[
|
||||
{
|
||||
label: "Refresh Schema",
|
||||
isLoading: grpc.reflect.isFetching,
|
||||
disabled: grpc.reflect.isFetching,
|
||||
onClick: () => grpc.reflect.refetch(),
|
||||
},
|
||||
{ label: "Done", color: "primary", onClick: onDone },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import deepEqual from "@gilbarbara/deep-equal";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import type { AssertionReport, HttpAssertions, HttpResponse } from "@yaakapp-internal/models";
|
||||
import { format } from "date-fns";
|
||||
import { rpc } from "../lib/rpc";
|
||||
import { Button } from "./core/Button";
|
||||
import { HttpAssertionReport } from "./HttpAssertionResults";
|
||||
|
||||
interface Props {
|
||||
assertions: HttpAssertions;
|
||||
response: HttpResponse | null;
|
||||
}
|
||||
|
||||
export function HttpAssertionPreview(props: Props) {
|
||||
// A response switch (including a new send) discards results and in-flight UI
|
||||
// state. The old evaluation may finish, but it cannot label the new response.
|
||||
return <ResponsePreview key={`${props.response?.id}:${props.response?.updatedAt}`} {...props} />;
|
||||
}
|
||||
|
||||
function ResponsePreview({ assertions, response }: Props) {
|
||||
const preview = useMutation({
|
||||
mutationFn: (definition: HttpAssertions) =>
|
||||
rpc<AssertionReport>("cmd_preview_http_assertions", {
|
||||
responseId: response?.id,
|
||||
assertions: definition,
|
||||
}),
|
||||
retry: false,
|
||||
gcTime: 0,
|
||||
});
|
||||
const unavailable = !response
|
||||
? "Send a request first to get a response to try."
|
||||
: response.state !== "closed"
|
||||
? "Wait for the response to finish."
|
||||
: !assertions.checks.some((check) => check.enabled)
|
||||
? "Enable an assertion to try it."
|
||||
: null;
|
||||
const changed = preview.data != null && !deepEqual(preview.data.definition, assertions);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="border"
|
||||
disabled={unavailable != null || preview.isPending}
|
||||
onClick={() => preview.mutate(assertions)}
|
||||
>
|
||||
{preview.isPending ? "Trying assertions…" : "Try assertions"}
|
||||
</Button>
|
||||
<p className="text-xs text-text-subtle">
|
||||
{unavailable ?? "Preview against the selected response without sending again."}
|
||||
</p>
|
||||
{preview.isError && (
|
||||
<p className="text-sm text-danger" role="alert">
|
||||
Could not preview assertions:{" "}
|
||||
{preview.error instanceof Error ? preview.error.message : String(preview.error)}
|
||||
</p>
|
||||
)}
|
||||
{preview.data && response && (
|
||||
<section
|
||||
aria-label="Assertion preview"
|
||||
aria-live="polite"
|
||||
className="border border-border-subtle rounded-md p-3 space-y-2"
|
||||
>
|
||||
<p className="text-sm font-medium">Preview</p>
|
||||
<p className="text-xs text-text-subtle">
|
||||
Response from {format(`${response.createdAt}Z`, "MMM d, h:mm:ss a")}. Recorded results
|
||||
are unchanged.
|
||||
</p>
|
||||
{changed && (
|
||||
<p className="text-xs text-warning" role="status">
|
||||
Assertions changed. Try again to update this preview.
|
||||
</p>
|
||||
)}
|
||||
<HttpAssertionReport report={preview.data} />
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { AssertionReport, HttpResponse } from "@yaakapp-internal/models";
|
||||
import classNames from "classnames";
|
||||
import { EmptyStateText } from "./EmptyStateText";
|
||||
|
||||
export function HttpAssertionResults({ response }: { response: HttpResponse }) {
|
||||
const report = response.assertionResults;
|
||||
if (!report)
|
||||
return <EmptyStateText>Assertions were not evaluated for this response.</EmptyStateText>;
|
||||
if (!report.error && report.results.length === 0)
|
||||
return <EmptyStateText>Waiting for the response to finish.</EmptyStateText>;
|
||||
return (
|
||||
<div className="h-full overflow-auto space-y-2 pb-3">
|
||||
<p className="text-xs text-text-subtle">Checks saved with this response.</p>
|
||||
<HttpAssertionReport report={report} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HttpAssertionReport({ report }: { report: AssertionReport }) {
|
||||
const passed = report.results.filter((r) => r.outcome === "passed").length;
|
||||
const failed = report.results.filter((r) => r.outcome === "failed").length;
|
||||
const errors = report.results.filter(
|
||||
(r) => r.outcome === "error" || r.outcome === "invalid",
|
||||
).length;
|
||||
const skipped = report.results.filter((r) => r.outcome === "skipped").length;
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm">
|
||||
{passed} passed · {failed} failed · {errors} errors · {skipped} skipped
|
||||
</p>
|
||||
{report.error && <p className="text-sm text-danger">{report.error}</p>}
|
||||
{report.results.map((result, index) => {
|
||||
const check = report.definition.checks[index];
|
||||
return (
|
||||
<div
|
||||
key={`${result.assertionId}:${index}`}
|
||||
className="border border-border-subtle rounded-md p-2 text-sm space-y-1"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="font-mono break-all">
|
||||
{check?.target === "status" ? "Status" : check?.selector}
|
||||
</span>
|
||||
<span
|
||||
className={classNames(
|
||||
"capitalize shrink-0",
|
||||
result.outcome === "passed"
|
||||
? "text-success"
|
||||
: result.outcome === "skipped"
|
||||
? "text-text-subtle"
|
||||
: "text-danger",
|
||||
)}
|
||||
>
|
||||
{result.outcome}
|
||||
</span>
|
||||
</div>
|
||||
{check && (
|
||||
<p className="text-xs break-all">
|
||||
{check.operator.replaceAll("_", " ")}
|
||||
{check.operator !== "exists" && (
|
||||
<>
|
||||
{" "}
|
||||
{check.expectedType === "null"
|
||||
? "null"
|
||||
: check.expectedType === "text"
|
||||
? JSON.stringify(check.expected)
|
||||
: check.expected}{" "}
|
||||
({check.expectedType})
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-text-subtle">{result.reason}</p>
|
||||
{result.actual != null && (
|
||||
<p className="font-mono text-xs break-all">Actual: {JSON.stringify(result.actual)}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { HttpAssertion, HttpAssertions, HttpRequest } from "@yaakapp-internal/models";
|
||||
import { patchModelDebounced } from "@yaakapp-internal/models";
|
||||
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
|
||||
import { JsonPathInput } from "./JsonPathInput";
|
||||
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
|
||||
import { useStateWithDeps } from "../hooks/useStateWithDeps";
|
||||
import { generateId } from "../lib/generateId";
|
||||
import { rpc } from "../lib/rpc";
|
||||
import { Button } from "./core/Button";
|
||||
import { Checkbox } from "./core/Checkbox";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import { Select } from "./core/Select";
|
||||
import { HttpAssertionPreview } from "./HttpAssertionPreview";
|
||||
|
||||
const EMPTY: HttpAssertions = { version: 1, checks: [] };
|
||||
const operators = [
|
||||
{ value: "equals", label: "Equals" },
|
||||
{ value: "not_equals", label: "Does not equal" },
|
||||
{ value: "greater_than", label: "Greater than" },
|
||||
{ value: "less_than", label: "Less than" },
|
||||
{ value: "contains", label: "Contains" },
|
||||
{ value: "exists", label: "Exists" },
|
||||
];
|
||||
|
||||
export function HttpAssertionsEditor({ request }: { request: HttpRequest }) {
|
||||
const updateKey = useRequestUpdateKey(request.id);
|
||||
const { activeResponse } = usePinnedHttpResponse(request.id);
|
||||
const [definition, setDefinition] = useStateWithDeps(request.assertions ?? EMPTY, [updateKey]);
|
||||
const validation = useQuery({
|
||||
queryKey: ["validate_assertions", definition],
|
||||
queryFn: () =>
|
||||
rpc<Record<string, string>>("cmd_validate_http_assertions", { assertions: definition }),
|
||||
staleTime: Infinity,
|
||||
gcTime: 0,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const save = (checks: HttpAssertion[]) => {
|
||||
const next = { ...definition, checks };
|
||||
setDefinition(next);
|
||||
patchModelDebounced(request, { assertions: next });
|
||||
};
|
||||
const update = (id: string, patch: Partial<HttpAssertion>) =>
|
||||
save(definition.checks.map((c) => (c.id === id ? { ...c, ...patch } : c)));
|
||||
|
||||
return (
|
||||
<div className="@container h-full overflow-auto pb-3 space-y-3">
|
||||
<p className="text-sm text-text-subtle">
|
||||
Check the response each time you send. Results appear in the response’s Assertions tab.
|
||||
</p>
|
||||
{definition.checks.map((check, index) => (
|
||||
<div key={check.id} className="border border-border-subtle rounded-md p-2 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
title={`Assertion ${index + 1}`}
|
||||
checked={check.enabled}
|
||||
onChange={(enabled) => update(check.id, { enabled })}
|
||||
/>
|
||||
<IconButton
|
||||
icon="trash"
|
||||
size="xs"
|
||||
title={`Delete assertion ${index + 1}`}
|
||||
onClick={() => save(definition.checks.filter((c) => c.id !== check.id))}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-[minmax(0,1fr)] @[28rem]:grid-cols-[8rem_minmax(0,1fr)] gap-2">
|
||||
<Select
|
||||
name={`target-${check.id}`}
|
||||
label="Response"
|
||||
size="sm"
|
||||
value={check.target}
|
||||
options={[
|
||||
{ value: "json", label: "JSON body" },
|
||||
{ value: "status", label: "Status" },
|
||||
{ value: "header", label: "Header" },
|
||||
]}
|
||||
onChange={(target) =>
|
||||
update(check.id, {
|
||||
target,
|
||||
selector: "",
|
||||
operator: "equals",
|
||||
expected: target === "status" ? "200" : "",
|
||||
expectedType: target === "status" ? "number" : "text",
|
||||
})
|
||||
}
|
||||
/>
|
||||
{check.target === "json" && (
|
||||
<JsonPathInput
|
||||
response={activeResponse}
|
||||
stateKey={`assertion-path:${request.id}:${check.id}`}
|
||||
defaultValue={check.selector}
|
||||
forceUpdateKey={updateKey}
|
||||
onChange={(selector) => update(check.id, { selector })}
|
||||
/>
|
||||
)}
|
||||
{check.target === "header" && (
|
||||
<PlainInput
|
||||
label="Header name"
|
||||
size="sm"
|
||||
defaultValue={check.selector}
|
||||
forceUpdateKey={`${updateKey}:${check.target}`}
|
||||
placeholder="Content-Type"
|
||||
onChange={(selector) => update(check.id, { selector })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-[minmax(0,1fr)] @[28rem]:grid-cols-[8rem_minmax(0,1fr)] gap-2">
|
||||
<Select
|
||||
name={`operator-${check.id}`}
|
||||
label="Comparison"
|
||||
size="sm"
|
||||
value={check.operator}
|
||||
options={operators.filter(
|
||||
(o) =>
|
||||
check.target === "json" ||
|
||||
(check.target === "status"
|
||||
? o.value !== "contains" && o.value !== "exists"
|
||||
: !["greater_than", "less_than"].includes(o.value)),
|
||||
)}
|
||||
onChange={(operator) =>
|
||||
update(check.id, {
|
||||
operator,
|
||||
...(["greater_than", "less_than"].includes(operator)
|
||||
? { expectedType: "number" }
|
||||
: operator === "contains"
|
||||
? { expectedType: "text" }
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
/>
|
||||
{check.operator !== "exists" && (
|
||||
<div className="flex items-start gap-2 min-w-0">
|
||||
{check.expectedType !== "null" && (
|
||||
<div className="flex-1 min-w-0">
|
||||
<PlainInput
|
||||
label="Expected value"
|
||||
size="sm"
|
||||
defaultValue={check.expected}
|
||||
forceUpdateKey={`${updateKey}:${check.target}`}
|
||||
placeholder={check.expectedType === "boolean" ? "true or false" : "Value"}
|
||||
onChange={(expected) => update(check.id, { expected })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{check.target === "json" &&
|
||||
!["greater_than", "less_than", "contains"].includes(check.operator) && (
|
||||
<div className="w-24 shrink-0">
|
||||
<Select
|
||||
name={`type-${check.id}`}
|
||||
label="Type"
|
||||
size="sm"
|
||||
value={check.expectedType}
|
||||
options={[
|
||||
{ value: "text", label: "Text" },
|
||||
{ value: "number", label: "Number" },
|
||||
{ value: "boolean", label: "Boolean" },
|
||||
{ value: "null", label: "Null" },
|
||||
]}
|
||||
onChange={(expectedType) => update(check.id, { expectedType })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{validation.data?.[check.id] && (
|
||||
<p className="text-xs text-danger" role="status">
|
||||
{validation.data[check.id]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{validation.data?.[""] && (
|
||||
<p className="text-sm text-danger" role="status">
|
||||
{validation.data[""]}
|
||||
</p>
|
||||
)}
|
||||
{validation.isError && (
|
||||
<p className="text-sm text-danger" role="status">
|
||||
Unable to validate assertions.
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="border"
|
||||
disabled={definition.version !== 1 || definition.checks.length >= 100}
|
||||
onClick={() =>
|
||||
save([
|
||||
...definition.checks,
|
||||
{
|
||||
id: generateId(),
|
||||
enabled: true,
|
||||
target: "json",
|
||||
selector: "",
|
||||
operator: "equals",
|
||||
expected: "",
|
||||
expectedType: "text",
|
||||
},
|
||||
])
|
||||
}
|
||||
>
|
||||
Add assertion
|
||||
</Button>
|
||||
{definition.checks.some((c) => c.target === "json") && (
|
||||
<p className="text-xs text-text-subtle">
|
||||
Use a JSONPath such as $.user.id or user.id. Comparisons select one value; Exists also
|
||||
accepts multiple matches. Suggestions use the selected response, one level at a time.
|
||||
Values are literal and types must match.
|
||||
</p>
|
||||
)}
|
||||
<HttpAssertionPreview assertions={definition} response={activeResponse} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -42,11 +42,12 @@ import { Editor } from "./core/Editor/LazyEditor";
|
||||
import { InlineCode } from "@yaakapp-internal/ui";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
|
||||
import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs";
|
||||
import { TabContent, Tabs } from "./core/Tabs/Tabs";
|
||||
import { EmptyStateText } from "./EmptyStateText";
|
||||
import { FormMultipartEditor } from "./FormMultipartEditor";
|
||||
import { FormUrlencodedEditor } from "./FormUrlencodedEditor";
|
||||
import { HeadersEditor } from "./HeadersEditor";
|
||||
import { HttpAssertionsEditor } from "./HttpAssertionsEditor";
|
||||
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
|
||||
import { JsonBodyEditor } from "./JsonBodyEditor";
|
||||
import { MarkdownEditor } from "./MarkdownEditor";
|
||||
@@ -72,6 +73,7 @@ const TAB_HEADERS = "headers";
|
||||
const TAB_AUTH = "auth";
|
||||
const TAB_SETTINGS = "settings";
|
||||
const TAB_DESCRIPTION = "description";
|
||||
const TAB_ASSERTIONS = "assertions";
|
||||
const TABS_STORAGE_KEY = "http_request_tabs";
|
||||
|
||||
// Derived from the identity-stable URL list so this only recomputes when a URL
|
||||
@@ -171,6 +173,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
() => [
|
||||
{
|
||||
value: TAB_BODY,
|
||||
menuLabel: "Body",
|
||||
rightSlot: numParams > 0 ? <CountBadge count={numParams} /> : null,
|
||||
options: {
|
||||
value: activeRequest.bodyType,
|
||||
@@ -264,6 +267,16 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
label: "Info",
|
||||
rightSlot: hasDescription && <CountBadge count={true} />,
|
||||
},
|
||||
{
|
||||
value: TAB_ASSERTIONS,
|
||||
label: "Assertions",
|
||||
rightSlot: (
|
||||
<CountBadge
|
||||
count={activeRequest.assertions?.checks.filter((c) => c.enabled).length ?? 0}
|
||||
/>
|
||||
),
|
||||
hiddenByDefault: !activeRequest.assertions?.checks.length,
|
||||
},
|
||||
],
|
||||
[
|
||||
activeRequest,
|
||||
@@ -310,11 +323,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
e.preventDefault(); // Prevent input onChange
|
||||
|
||||
await patchModel(activeRequest, patch);
|
||||
await setActiveTab({
|
||||
storageKey: TABS_STORAGE_KEY,
|
||||
activeTabKey: activeRequestId,
|
||||
value: TAB_PARAMS,
|
||||
});
|
||||
tabsRef.current?.setActiveTab(TAB_PARAMS);
|
||||
|
||||
// Wait for request to update, then refresh the UI
|
||||
// TODO: Somehow make this deterministic
|
||||
@@ -368,8 +377,12 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
tabs={tabs}
|
||||
tabListClassName="mt-1 -mb-1.5"
|
||||
storageKey={TABS_STORAGE_KEY}
|
||||
visibilityStorageKey={[TABS_STORAGE_KEY, "visibility", activeRequest.workspaceId]}
|
||||
activeTabKey={activeRequestId}
|
||||
>
|
||||
<TabContent value={TAB_ASSERTIONS}>
|
||||
<HttpAssertionsEditor key={activeRequest.id} request={activeRequest} />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_AUTH}>
|
||||
<HttpAuthenticationEditor model={activeRequest} />
|
||||
</TabContent>
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { HttpResponse, HttpResponseEvent } from "@yaakapp-internal/models";
|
||||
import { Banner, HStack, Icon, LoadingIcon, VStack } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import type { ComponentType, CSSProperties } from "react";
|
||||
import { lazy, Suspense, useMemo } from "react";
|
||||
import { lazy, Suspense, useMemo, useRef } from "react";
|
||||
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
|
||||
import { useCopyHttpResponse } from "../hooks/useCopyHttpResponse";
|
||||
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
|
||||
@@ -23,11 +23,12 @@ import { HttpResponseDurationTag } from "./core/HttpResponseDurationTag";
|
||||
import { HttpStatusTag } from "./core/HttpStatusTag";
|
||||
import { PillButton } from "./core/PillButton";
|
||||
import { SizeTag } from "./core/SizeTag";
|
||||
import type { TabItem } from "./core/Tabs/Tabs";
|
||||
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
|
||||
import { TabContent, Tabs } from "./core/Tabs/Tabs";
|
||||
import { Tooltip } from "./core/Tooltip";
|
||||
import { EmptyStateText } from "./EmptyStateText";
|
||||
import { ErrorBoundary } from "./ErrorBoundary";
|
||||
import { HttpAssertionResults } from "./HttpAssertionResults";
|
||||
import { HttpResponseTimeline } from "./HttpResponseTimeline";
|
||||
import { RecentHttpResponsesDropdown } from "./RecentHttpResponsesDropdown";
|
||||
import { RequestBodyViewer } from "./RequestBodyViewer";
|
||||
@@ -56,6 +57,7 @@ const TAB_BODY = "body";
|
||||
const TAB_REQUEST = "request";
|
||||
const TAB_HEADERS = "headers";
|
||||
const TAB_COOKIES = "cookies";
|
||||
const TAB_ASSERTIONS = "assertions";
|
||||
const TAB_TIMELINE = "timeline";
|
||||
|
||||
export type TimelineViewMode = "timeline" | "text";
|
||||
@@ -67,6 +69,7 @@ interface RedirectDropWarning {
|
||||
|
||||
export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||
const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId);
|
||||
const tabsRef = useRef<TabsRef>(null);
|
||||
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
|
||||
const [timelineViewMode, setTimelineViewMode] = useTimelineViewMode();
|
||||
const contentType = getContentTypeFromHeaders(activeResponse?.headers ?? null);
|
||||
@@ -83,9 +86,53 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||
const cookieCounts = useMemo(() => getCookieCounts(responseEvents.data), [responseEvents.data]);
|
||||
const saveResponse = useSaveResponse(activeResponse ?? null);
|
||||
const copyResponse = useCopyHttpResponse(activeResponse ?? null);
|
||||
const assertionReport = activeResponse?.assertionResults;
|
||||
// Native sends save the definition before evaluation. Wait for actual outcomes,
|
||||
// and leave the tab hidden when every check was disabled.
|
||||
const hasAssertionResults =
|
||||
activeResponse?.state === "closed" &&
|
||||
assertionReport != null &&
|
||||
(assertionReport.error != null ||
|
||||
assertionReport.results.some((result) => result.outcome !== "skipped"));
|
||||
const failedAssertions =
|
||||
assertionReport?.results.filter((result) => result.outcome === "failed").length ?? 0;
|
||||
const assertionErrors =
|
||||
assertionReport?.results.filter(
|
||||
(result) => result.outcome === "error" || result.outcome === "invalid",
|
||||
).length ?? 0;
|
||||
const assertionFailures = failedAssertions + assertionErrors;
|
||||
const assertionFailureLabel = [
|
||||
failedAssertions > 0
|
||||
? `${failedAssertions} assertion${failedAssertions === 1 ? "" : "s"} failed`
|
||||
: null,
|
||||
assertionErrors > 0
|
||||
? `${assertionErrors} assertion error${assertionErrors === 1 ? "" : "s"}`
|
||||
: assertionReport?.error != null
|
||||
? "Assertion error"
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
const assertionPasses =
|
||||
assertionReport?.results.filter((result) => result.outcome === "passed").length ?? 0;
|
||||
const assertionsHaveErrors = assertionFailures > 0 || assertionReport?.error != null;
|
||||
|
||||
const tabs = useMemo<TabItem[]>(
|
||||
() => [
|
||||
...(hasAssertionResults
|
||||
? [
|
||||
{
|
||||
value: TAB_ASSERTIONS,
|
||||
label: "Assertions",
|
||||
rightSlot: (
|
||||
<CountBadge
|
||||
count={assertionsHaveErrors ? assertionFailures || true : assertionPasses}
|
||||
color={assertionsHaveErrors ? "danger" : "success"}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
value: TAB_BODY,
|
||||
label: "Response",
|
||||
@@ -162,6 +209,10 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||
activeResponse?.requestHeaders.length,
|
||||
activeResponse?.state,
|
||||
activeResponse?.status,
|
||||
hasAssertionResults,
|
||||
assertionFailures,
|
||||
assertionPasses,
|
||||
assertionsHaveErrors,
|
||||
cookieCounts.sent,
|
||||
cookieCounts.received,
|
||||
copyResponse.mutate,
|
||||
@@ -210,6 +261,17 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||
<HStack space={2} className="w-full shrink-0">
|
||||
{activeResponse.state !== "closed" && <LoadingIcon size="sm" />}
|
||||
<HttpStatusTag showReason response={activeResponse} />
|
||||
{hasAssertionResults && assertionsHaveErrors && (
|
||||
<PillButton
|
||||
color="danger"
|
||||
className="font-sans"
|
||||
title="View assertion results"
|
||||
leftSlot={<Icon icon="alert_triangle" size="xs" />}
|
||||
onClick={() => tabsRef.current?.setActiveTab(TAB_ASSERTIONS)}
|
||||
>
|
||||
{assertionFailureLabel}
|
||||
</PillButton>
|
||||
)}
|
||||
<span>•</span>
|
||||
<HttpResponseDurationTag response={activeResponse} />
|
||||
<span>•</span>
|
||||
@@ -282,13 +344,18 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||
)}
|
||||
{/* Show tabs if we have any data (headers, body, etc.) even if there's an error */}
|
||||
<Tabs
|
||||
ref={tabsRef}
|
||||
tabs={tabs}
|
||||
defaultValue={TAB_BODY}
|
||||
label="Response"
|
||||
className="ml-3 mr-3 mb-3 min-h-0 flex-1"
|
||||
tabListClassName="mt-0.5 -mb-1.5"
|
||||
storageKey="http_response_tabs"
|
||||
activeTabKey={activeRequestId}
|
||||
>
|
||||
<TabContent value={TAB_ASSERTIONS}>
|
||||
<HttpAssertionResults response={activeResponse} />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_BODY}>
|
||||
<ErrorBoundary name="Http Response Viewer">
|
||||
<Suspense>
|
||||
|
||||
@@ -1,33 +1,36 @@
|
||||
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 { Banner, FormattedError, HStack, Icon, VStack } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { formatDistanceToNowStrict } from "date-fns";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { type ComponentProps, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { errorMessage } from "../lib/errorMessage";
|
||||
import { pluralize } from "../lib/pluralize";
|
||||
import { CommercialUseBanner } from "./CommercialUseBanner";
|
||||
import { Button } from "./core/Button";
|
||||
import { Checkbox } from "./core/Checkbox";
|
||||
import {
|
||||
ImportSourceList,
|
||||
type ImportSourceDetection,
|
||||
type ImportSourcePath,
|
||||
InlineButton,
|
||||
toSourcePath,
|
||||
} from "./ImportSourceList";
|
||||
import type { CheckboxTreeNode } from "./core/CheckboxTree";
|
||||
import { CheckboxTree } from "./core/CheckboxTree";
|
||||
import { Chip } from "./core/Chip";
|
||||
import { DialogFooter } from "./core/Dialog";
|
||||
import { IconTooltip } from "./core/IconTooltip";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import { Select } from "./core/Select";
|
||||
import { SegmentedControl } from "./core/SegmentedControl";
|
||||
|
||||
interface Props {
|
||||
currentWorkspace: Workspace | null;
|
||||
workspaces: Workspace[];
|
||||
selectedFolder: Folder | null;
|
||||
planFile: (filePath: string, destination: ImportDestination) => Promise<ImportPlan>;
|
||||
planUrl: (url: string, destination: ImportDestination) => Promise<ImportPlan>;
|
||||
planSources: (sources: ImportSourcePath[], destination: ImportDestination) => Promise<ImportPlan>;
|
||||
detectSource: (source: ImportSourcePath) => Promise<string>;
|
||||
listSources: (workspaceId: string) => Promise<ImportSource[]>;
|
||||
findSourcesForOrigin: (args: { filePath?: string; url?: string }) => Promise<ImportSource[]>;
|
||||
commit: (plan: ImportPlan) => Promise<void>;
|
||||
@@ -35,25 +38,6 @@ interface Props {
|
||||
onError: (err: unknown) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* An absolute or relative path is unambiguously a file. Everything else is treated as a URL, so a
|
||||
* bare host like `example.com/openapi.json` still works (the backend defaults it to https).
|
||||
*/
|
||||
function isFilePath(value: string): boolean {
|
||||
return (
|
||||
value.startsWith("/") ||
|
||||
value.startsWith("./") ||
|
||||
value.startsWith("../") ||
|
||||
value.startsWith("~/") ||
|
||||
value.startsWith("\\\\") ||
|
||||
/^[a-zA-Z]:[\\/]/.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
function fileName(path: string): string {
|
||||
return path.split(/[/\\]/).at(-1) || path;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -82,30 +66,19 @@ export function ImportDataDialog(props: Props) {
|
||||
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,
|
||||
planSources,
|
||||
detectSource,
|
||||
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 [sourceError, setSourceError] = useState<unknown>(null);
|
||||
const [plan, setPlan] = useState<ImportPlan | null>(null);
|
||||
const [items, setItems] = useState<ImportPlanItem[]>([]);
|
||||
// null means no explicit choice yet, so the default below applies
|
||||
@@ -113,65 +86,82 @@ function LoadedImportDataDialog({
|
||||
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[]>(initialSources);
|
||||
const initialPaths = useMemo(
|
||||
() => [...new Set(initialSources.map((source) => source.origin))].map(toSourcePath),
|
||||
[initialSources],
|
||||
);
|
||||
const [originSources, setOriginSources] = useState<ImportSource[]>(
|
||||
prefill != null ? [prefill] : [],
|
||||
const [sources, setSources] = useState<ImportSourcePath[]>(initialPaths);
|
||||
const knownPaths = useRef(new Set(initialPaths.map((source) => source.path)));
|
||||
const [detections, setDetections] = useState<Record<string, ImportSourceDetection>>({});
|
||||
const detect = useCallback(
|
||||
(added: ImportSourcePath[]) => {
|
||||
setDetections((prev) => ({
|
||||
...prev,
|
||||
...Object.fromEntries(added.map((s) => [s.path, { status: "loading" as const }])),
|
||||
}));
|
||||
for (const source of added) {
|
||||
const settle = (detection: ImportSourceDetection) =>
|
||||
setDetections((prev) =>
|
||||
source.path in prev ? { ...prev, [source.path]: detection } : prev,
|
||||
);
|
||||
detectSource(source).then(
|
||||
(importer) => settle({ status: "ok", importer }),
|
||||
(err) => settle({ status: "error", message: errorMessage(err) }),
|
||||
);
|
||||
}
|
||||
},
|
||||
[detectSource],
|
||||
);
|
||||
// A file path or a URL. Both inputs write here, so there is only ever one thing to import
|
||||
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);
|
||||
const trimmedSource = source?.trim() ?? "";
|
||||
const filePath = isFilePath(trimmedSource) ? trimmedSource : null;
|
||||
const allDetected = sources.every((source) => detections[source.path]?.status === "ok");
|
||||
useEffect(() => {
|
||||
detect(initialPaths);
|
||||
}, [detect, initialPaths]);
|
||||
|
||||
const selectSource = (value: string) => {
|
||||
setSource(value);
|
||||
// Remount the input so it shows the path of the newly-picked file
|
||||
setForceUpdateKey((k) => k + 1);
|
||||
const addSources = useCallback(
|
||||
(added: ImportSourcePath[]) => {
|
||||
setSourceError(null);
|
||||
const fresh = added.filter(({ path }) => {
|
||||
if (knownPaths.current.has(path)) return false;
|
||||
knownPaths.current.add(path);
|
||||
return true;
|
||||
});
|
||||
if (fresh.length === 0) return;
|
||||
setSources((existing) => [...existing, ...fresh]);
|
||||
detect(fresh);
|
||||
},
|
||||
[detect],
|
||||
);
|
||||
const removeSource = (path: string) => {
|
||||
setSourceError(null);
|
||||
knownPaths.current.delete(path);
|
||||
setSources((current) => current.filter((source) => source.path !== path));
|
||||
setDetections(({ [path]: _removed, ...rest }) => rest);
|
||||
};
|
||||
|
||||
// Accept a file dropped anywhere on the dialog, the way SelectFile does for its button
|
||||
useEffect(() => {
|
||||
return platform.window.onDragDrop((event) => {
|
||||
if (event.type === "over") {
|
||||
const p = event.position;
|
||||
const r = ref.current?.getBoundingClientRect();
|
||||
if (r == null) return;
|
||||
setIsHovering(p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom);
|
||||
} else if (event.type === "drop" && isHovering) {
|
||||
const p = event.paths[0];
|
||||
if (p) selectSource(p);
|
||||
setIsHovering(false);
|
||||
} else {
|
||||
setIsHovering(false);
|
||||
}
|
||||
});
|
||||
}, [isHovering, setSource]);
|
||||
|
||||
useEffect(() => {
|
||||
if (trimmedSource === "") {
|
||||
let cancelled = false;
|
||||
if (sources.length === 0) {
|
||||
setOriginSources([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const timeout = setTimeout(() => {
|
||||
findSourcesForOrigin(filePath != null ? { filePath } : { url: trimmedSource })
|
||||
.then((sources) => {
|
||||
if (!cancelled) setOriginSources(sources);
|
||||
})
|
||||
.catch(() => setOriginSources([]));
|
||||
}, 300);
|
||||
Promise.all(
|
||||
sources.map(({ path, kind }) =>
|
||||
findSourcesForOrigin(kind === "url" ? { url: path } : { filePath: path }),
|
||||
),
|
||||
)
|
||||
.then((found) => {
|
||||
if (!cancelled) setOriginSources(found.flat());
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setOriginSources([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [trimmedSource, filePath, findSourcesForOrigin]);
|
||||
}, [sources, findSourcesForOrigin]);
|
||||
|
||||
// The one workspace this file is linked to, if there is exactly one.
|
||||
// The one workspace these sources are linked to, if there is exactly one.
|
||||
const linkedWorkspace = useMemo(() => {
|
||||
const ids = [...new Set(originSources.map((s) => s.workspaceId))];
|
||||
if (ids.length !== 1) return null;
|
||||
@@ -193,32 +183,6 @@ function LoadedImportDataDialog({
|
||||
? 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);
|
||||
};
|
||||
|
||||
// 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" };
|
||||
@@ -226,21 +190,18 @@ function LoadedImportDataDialog({
|
||||
return {
|
||||
type: "existing_workspace",
|
||||
workspaceId: destinationWorkspaceId,
|
||||
folderId: canTargetSelectedFolder && targetSelectedFolder ? selectedFolder.id : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const handlePreview = async () => {
|
||||
setIsLoading(true);
|
||||
setSourceError(null);
|
||||
try {
|
||||
const nextPlan =
|
||||
filePath != null
|
||||
? await planFile(filePath, destination())
|
||||
: await planUrl(trimmedSource, destination());
|
||||
const nextPlan = await planSources(sources, destination());
|
||||
setPlan(nextPlan);
|
||||
setItems(nextPlan.items);
|
||||
} catch (err) {
|
||||
onError(err);
|
||||
setSourceError(err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@@ -258,8 +219,6 @@ function LoadedImportDataDialog({
|
||||
}
|
||||
};
|
||||
|
||||
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.
|
||||
@@ -313,196 +272,215 @@ function LoadedImportDataDialog({
|
||||
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(", ")];
|
||||
return [
|
||||
names.length === 1 ? "New workspace" : `${names.length} new workspaces`,
|
||||
names.join(", "),
|
||||
];
|
||||
}
|
||||
const { workspaceId, folderId } = plan.destination;
|
||||
const { workspaceId } = plan.destination;
|
||||
const name = workspaces.find((w) => w.id === workspaceId)?.name ?? "Unknown workspace";
|
||||
return [
|
||||
"Destination",
|
||||
folderId != null && folderId === selectedFolder?.id
|
||||
? `${name} / ${selectedFolder.name}`
|
||||
: name,
|
||||
];
|
||||
return ["Destination", name];
|
||||
})();
|
||||
|
||||
// The destination workspace roots the tree. It is not a plan item — commit always applies
|
||||
// it — so its checkbox only aggregates the subtree.
|
||||
const workspaceRoot: CheckboxTreeNode<TreeRow> = (() => {
|
||||
const planned = plan.resources.workspaces[0];
|
||||
const workspaceRoots: CheckboxTreeNode<TreeRow>[] = (() => {
|
||||
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",
|
||||
const destinations = existing ? [existing] : plan.resources.workspaces;
|
||||
if (destinations.length === 1) {
|
||||
const workspace = destinations[0]!;
|
||||
return [
|
||||
{
|
||||
key: workspace.id,
|
||||
data: {
|
||||
kind: "destination",
|
||||
label: workspace.name,
|
||||
isNew: planDestination.type === "new_workspace",
|
||||
},
|
||||
children: buildItemTree(items),
|
||||
},
|
||||
];
|
||||
}
|
||||
const workspaceById = new Map(
|
||||
[
|
||||
...plan.resources.environments,
|
||||
...plan.resources.folders,
|
||||
...plan.resources.httpRequests,
|
||||
...plan.resources.grpcRequests,
|
||||
...plan.resources.websocketRequests,
|
||||
].map((resource) => [resource.id, resource.workspaceId]),
|
||||
);
|
||||
return destinations.map((workspace) => ({
|
||||
key: workspace.id,
|
||||
data: {
|
||||
kind: "destination",
|
||||
label: existing?.name ?? planned?.name ?? "New workspace",
|
||||
label: workspace.name,
|
||||
isNew: planDestination.type === "new_workspace",
|
||||
},
|
||||
children: itemTree,
|
||||
};
|
||||
children: buildItemTree(
|
||||
items.filter((item) => workspaceById.get(item.modelId) === workspace.id),
|
||||
),
|
||||
}));
|
||||
})();
|
||||
|
||||
return (
|
||||
<VStack space={4} className="pb-4">
|
||||
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
|
||||
<PreviewRow label="Detected format" value={plan.importer} />
|
||||
<PreviewRow label={destinationLabel} value={destinationValue} />
|
||||
</div>
|
||||
<>
|
||||
<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>
|
||||
{plan.warnings
|
||||
.filter(
|
||||
(warning, index, warnings) =>
|
||||
warnings.findIndex(
|
||||
(other) =>
|
||||
other.title === warning.title &&
|
||||
other.detail === warning.detail &&
|
||||
other.level === warning.level,
|
||||
) === index,
|
||||
)
|
||||
.map((warning) => (
|
||||
<Banner
|
||||
key={`${warning.level}:${warning.title}:${warning.detail}`}
|
||||
color={warning.level === "warning" ? "warning" : "info"}
|
||||
className="flex items-start gap-2.5"
|
||||
>
|
||||
<Icon
|
||||
icon={warning.level === "warning" ? "alert_triangle" : "info"}
|
||||
size="sm"
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">{warning.title}</div>
|
||||
<div className="text-xs text-text-subtle mt-0.5">{warning.detail}</div>
|
||||
</div>
|
||||
</Banner>
|
||||
))}
|
||||
|
||||
{workspaceRoots.map((root) => (
|
||||
<div key={root.key} className="rounded-lg border border-border-subtle px-3 py-2">
|
||||
<CheckboxTree
|
||||
node={root}
|
||||
checked={nodeCheckedStatus}
|
||||
onCheck={toggleNode}
|
||||
isCheckboxDisabled={(n) => disabledIds.has(n.key)}
|
||||
isCollapsedByDefault={(n) =>
|
||||
(n.data.kind === "destination" && workspaceRoots.length > 1) ||
|
||||
(n.data.kind === "item" && n.data.item.action === "ignored")
|
||||
}
|
||||
isRelevant={(n) =>
|
||||
n.data.kind === "destination" ||
|
||||
(n.data.kind === "item" && n.data.item.action !== "unchanged")
|
||||
}
|
||||
renderRow={(n) => (
|
||||
<ImportTreeRow row={n.data} onResolveConflict={resolveConflict} />
|
||||
)}
|
||||
/>
|
||||
</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>
|
||||
))}
|
||||
</VStack>
|
||||
<DialogFooter
|
||||
leftSlot={
|
||||
footerNote !== "" ? (
|
||||
<div className="text-xs text-text-subtle">{footerNote}</div>
|
||||
) : undefined
|
||||
}
|
||||
actions={[
|
||||
{
|
||||
label: "Back",
|
||||
disabled: isLoading,
|
||||
onClick: () => {
|
||||
setPlan(null);
|
||||
setItems([]);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: isLoading
|
||||
? "Importing"
|
||||
: changeCount > 0
|
||||
? `Apply ${changeCount} ${changeCount === 1 ? "Change" : "Changes"}`
|
||||
: "Done",
|
||||
color: "primary",
|
||||
isLoading,
|
||||
onClick: handleCommit,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const 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?" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSelectFile}
|
||||
className={classNames(
|
||||
"w-full rounded-lg border border-dashed px-4 py-6",
|
||||
"flex flex-col items-center gap-1 text-center",
|
||||
isHovering
|
||||
? "border-notice bg-surface-highlight"
|
||||
: "border-border hover:border-text-subtle",
|
||||
)}
|
||||
>
|
||||
<Icon icon="folder_input" className="text-text-subtlest w-8! h-8! mb-2" />
|
||||
{/* Fixed height so the region doesn't resize between the empty and selected states */}
|
||||
<div className="h-6 w-full flex items-center justify-center">
|
||||
{filePath == null ? (
|
||||
<div className="text-text">
|
||||
<strong className="font-semibold">Choose a file</strong> or drag it here
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-text font-mono text-xs max-w-full truncate" title={filePath}>
|
||||
{fileName(filePath)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-text-subtlest">
|
||||
Supports OpenAPI, Swagger, Postman, Insomnia, and curl
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<PlainInput
|
||||
label="Or enter a file path or URL"
|
||||
const destinationPicker = (
|
||||
<HStack
|
||||
space={2}
|
||||
alignItems="center"
|
||||
className={classNames(workspaces.length === 0 && "hidden")}
|
||||
>
|
||||
<Select
|
||||
name="import-destination-kind"
|
||||
label="Import into"
|
||||
labelPosition="left"
|
||||
size="sm"
|
||||
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" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<VStack space={2}>
|
||||
{destinationKind === "other" && (
|
||||
<Select
|
||||
name="import-destination-kind"
|
||||
label="Import location"
|
||||
name="import-destination-workspace"
|
||||
label="Workspace"
|
||||
hideLabel
|
||||
size="sm"
|
||||
value={destinationKind}
|
||||
onChange={setDestinationChoice}
|
||||
value={otherWorkspaceId ?? ""}
|
||||
onChange={(id) => setOtherWorkspaceId(id === "" ? null : id)}
|
||||
filterable
|
||||
options={[
|
||||
{ value: "new", label: "New Workspace" },
|
||||
...(currentWorkspace != null
|
||||
? [{ value: "current" as const, label: "Current Workspace" }]
|
||||
: []),
|
||||
{ value: "other", label: "Other Workspace" },
|
||||
{ value: "", label: "Select a workspace" },
|
||||
...workspaces
|
||||
.filter((w) => w.id !== currentWorkspace?.id)
|
||||
.map((w) => ({ value: w.id, label: w.name })),
|
||||
]}
|
||||
/>
|
||||
{destinationKind === "other" && (
|
||||
<Select
|
||||
name="import-destination-workspace"
|
||||
label="Workspace"
|
||||
hideLabel
|
||||
size="sm"
|
||||
value={otherWorkspaceId ?? ""}
|
||||
onChange={(id) => setOtherWorkspaceId(id === "" ? null : id)}
|
||||
filterable
|
||||
options={[
|
||||
{ value: "", label: "Select a workspace" },
|
||||
...workspaces
|
||||
.filter((w) => w.id !== currentWorkspace?.id)
|
||||
.map((w) => ({ value: w.id, label: w.name })),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<VStack space={4} className="h-full pb-4">
|
||||
<CommercialUseBanner source="data-import" title="Importing work data?" />
|
||||
|
||||
<ImportSourceList
|
||||
sources={sources}
|
||||
detections={detections}
|
||||
disabled={isLoading}
|
||||
onAdd={addSources}
|
||||
onRemove={removeSource}
|
||||
onError={setSourceError}
|
||||
/>
|
||||
|
||||
{sourceError != null && (
|
||||
<Banner color="warning">
|
||||
<FormattedError>{errorMessage(sourceError)}</FormattedError>
|
||||
</Banner>
|
||||
)}
|
||||
{lastImported != null && destinationWorkspaceId != null ? (
|
||||
|
||||
{linkedWorkspace != null && linkedWorkspace.id !== destinationWorkspaceId && (
|
||||
<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"
|
||||
{sources.length === 1 ? "This source was" : "These sources were"} last imported into{" "}
|
||||
<InlineButton
|
||||
onClick={() => {
|
||||
if (linkedWorkspace.id === currentWorkspace?.id) {
|
||||
setDestinationChoice("current");
|
||||
@@ -513,36 +491,28 @@ function LoadedImportDataDialog({
|
||||
}}
|
||||
>
|
||||
{linkedWorkspace.name}
|
||||
</button>
|
||||
</InlineButton>
|
||||
</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 ||
|
||||
(destinationKind === "other" && otherWorkspaceId == null)
|
||||
}
|
||||
isLoading={isLoading}
|
||||
onClick={handlePreview}
|
||||
>
|
||||
{isLoading ? "Analyzing" : "Preview Import"}
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
<DialogFooter
|
||||
leftSlot={destinationPicker}
|
||||
actions={[
|
||||
{ label: "Cancel", disabled: isLoading, onClick: cancel },
|
||||
{
|
||||
label: isLoading ? "Analyzing" : "Preview Import",
|
||||
color: "primary",
|
||||
disabled:
|
||||
sources.length === 0 ||
|
||||
!allDetected ||
|
||||
isLoading ||
|
||||
(destinationKind === "other" && otherWorkspaceId == null),
|
||||
isLoading,
|
||||
onClick: handlePreview,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -556,10 +526,12 @@ function ImportTreeRow({
|
||||
if (row.kind !== "item") {
|
||||
return (
|
||||
<>
|
||||
<Icon color="secondary" icon={row.kind === "destination" ? "house" : row.icon} />
|
||||
{row.kind === "destination" && (
|
||||
<Icon icon="box" size="sm" className="shrink-0 text-text-subtle" />
|
||||
)}
|
||||
<div className="truncate flex-1">{row.label}</div>
|
||||
{row.kind === "destination" && row.isNew && (
|
||||
<ActionChip label="new" help="Created by this import" className="text-success" />
|
||||
<ActionChip label="new" help="Created by this import" color="success" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
@@ -569,11 +541,6 @@ function ImportTreeRow({
|
||||
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">
|
||||
@@ -593,17 +560,7 @@ function ImportTreeRow({
|
||||
</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",
|
||||
)}
|
||||
/>
|
||||
<ActionChip label={label} help={actionHelp(item)} color={actionColor(item)} />
|
||||
)
|
||||
)}
|
||||
</>
|
||||
@@ -613,26 +570,37 @@ function ImportTreeRow({
|
||||
function ActionChip({
|
||||
label,
|
||||
help,
|
||||
className,
|
||||
color,
|
||||
}: {
|
||||
label: string;
|
||||
help: string | null;
|
||||
className?: string;
|
||||
color: ComponentProps<typeof Chip>["color"];
|
||||
}) {
|
||||
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,
|
||||
)}
|
||||
>
|
||||
<Chip color={color}>
|
||||
{label}
|
||||
{help != null && <IconTooltip content={help} iconSize="xs" />}
|
||||
</InlineCode>
|
||||
</Chip>
|
||||
);
|
||||
}
|
||||
|
||||
function actionColor(item: ImportPlanItem): ComponentProps<typeof Chip>["color"] {
|
||||
switch (item.action) {
|
||||
case "create":
|
||||
return "success";
|
||||
case "update":
|
||||
return "info";
|
||||
case "delete":
|
||||
return "danger";
|
||||
case "keep_local":
|
||||
return item.selected ? "warning" : "default";
|
||||
case "unchanged":
|
||||
case "conflict":
|
||||
case "ignored":
|
||||
return "default";
|
||||
}
|
||||
}
|
||||
|
||||
function actionLabel(item: ImportPlanItem): string | null {
|
||||
switch (item.action) {
|
||||
case "create":
|
||||
@@ -701,7 +669,7 @@ function ancestorsOf(item: ImportPlanItem, byId: Map<string, ImportPlanItem>): I
|
||||
*/
|
||||
type TreeRow =
|
||||
| { kind: "destination"; label: string; isNew: boolean }
|
||||
| { kind: "group"; label: string; icon: IconProps["icon"] }
|
||||
| { kind: "group"; label: string }
|
||||
| { kind: "item"; item: ImportPlanItem };
|
||||
|
||||
function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<TreeRow>[] {
|
||||
@@ -743,7 +711,7 @@ function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<TreeRow>[] {
|
||||
return [
|
||||
{
|
||||
key: "group:environments",
|
||||
data: { kind: "group", label: "Variables", icon: "variable" },
|
||||
data: { kind: "group", label: "Variables" },
|
||||
children: environments.map((e) => toNode(e, new Set())),
|
||||
},
|
||||
...nodes,
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { Icon, LoadingIcon } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { type KeyboardEvent, type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { Button } from "./core/Button";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { PathList, splitPath } from "./core/PathList";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
|
||||
export interface ImportSourcePath {
|
||||
path: string;
|
||||
kind?: "file" | "folder" | "url";
|
||||
}
|
||||
|
||||
export type ImportSourceDetection =
|
||||
| { status: "loading" }
|
||||
| { status: "ok"; importer: string }
|
||||
| { status: "error"; message: string };
|
||||
|
||||
interface Props {
|
||||
sources: ImportSourcePath[];
|
||||
detections: Record<string, ImportSourceDetection>;
|
||||
disabled?: boolean;
|
||||
onAdd: (sources: ImportSourcePath[]) => void;
|
||||
onRemove: (path: string) => void;
|
||||
onError: (err: unknown) => void;
|
||||
}
|
||||
|
||||
/** A bare hostname is a URL; local paths need an absolute or explicit relative prefix. */
|
||||
export function isImportFilePath(value: string): boolean {
|
||||
return (
|
||||
value.startsWith("/") ||
|
||||
value.startsWith("./") ||
|
||||
value.startsWith("../") ||
|
||||
value.startsWith("~/") ||
|
||||
value.startsWith("\\\\") ||
|
||||
/^[a-zA-Z]:[\\/]/.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
/** A typed or prefilled path is a URL unless it looks like a local path; kind is left for the host */
|
||||
export function toSourcePath(path: string): ImportSourcePath {
|
||||
return { path, kind: isImportFilePath(path) ? undefined : "url" };
|
||||
}
|
||||
|
||||
export function ImportSourceList({
|
||||
sources,
|
||||
detections,
|
||||
disabled,
|
||||
onAdd,
|
||||
onRemove,
|
||||
onError,
|
||||
}: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const [pathInput, setPathInput] = useState("");
|
||||
const [showPathInput, setShowPathInput] = useState(false);
|
||||
const pathInputRef = useRef<{ focus: () => void }>(null);
|
||||
const trimmedPath = pathInput.trim();
|
||||
|
||||
const openPathInput = () => {
|
||||
setShowPathInput(true);
|
||||
// The add-row menu hands focus back to its trigger as it closes, on a timer, so focus after it
|
||||
setTimeout(() => pathInputRef.current?.focus(), 50);
|
||||
};
|
||||
|
||||
const closePathInput = () => {
|
||||
setPathInput("");
|
||||
setShowPathInput(false);
|
||||
};
|
||||
|
||||
const addPath = () => {
|
||||
if (!trimmedPath || disabled) return;
|
||||
onAdd([toSourcePath(trimmedPath)]);
|
||||
closePathInput();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled) return;
|
||||
return platform.window.onDragDrop((event) => {
|
||||
if (event.type === "leave") {
|
||||
setIsHovering(false);
|
||||
return;
|
||||
}
|
||||
const rect = ref.current?.getBoundingClientRect();
|
||||
const { x, y } = event.position;
|
||||
const isOver =
|
||||
rect != null && x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
|
||||
if (event.type === "drop") {
|
||||
if (isOver) onAdd(event.paths.map((path) => ({ path })));
|
||||
setIsHovering(false);
|
||||
} else {
|
||||
setIsHovering(isOver);
|
||||
}
|
||||
});
|
||||
}, [disabled, onAdd]);
|
||||
|
||||
const pick = async (directory: boolean) => {
|
||||
try {
|
||||
const selected = await platform.dialog.open({
|
||||
title: directory ? "Select Import Folder" : "Select Import Files",
|
||||
multiple: true,
|
||||
directory,
|
||||
});
|
||||
if (selected == null) return;
|
||||
onAdd(selected.map((path) => ({ path, kind: directory ? "folder" : "file" })));
|
||||
} catch (err) {
|
||||
onError(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePathKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
addPath();
|
||||
} else if (event.key === "Escape") {
|
||||
event.stopPropagation();
|
||||
closePathInput();
|
||||
}
|
||||
};
|
||||
|
||||
const pathInputEl = (
|
||||
<PlainInput
|
||||
ref={pathInputRef}
|
||||
label="URL or file path"
|
||||
hideLabel
|
||||
size="sm"
|
||||
autoFocus
|
||||
disabled={disabled}
|
||||
placeholder="Paste a URL or a file path"
|
||||
onChange={setPathInput}
|
||||
onKeyDownCapture={handlePathKeyDown}
|
||||
rightSlot={
|
||||
<div className="flex items-center gap-1 my-1 mr-1">
|
||||
{trimmedPath !== "" && (
|
||||
<Button size="xs" variant="border" onClick={addPath}>
|
||||
Add
|
||||
</Button>
|
||||
)}
|
||||
<IconButton size="xs" icon="x" title="Cancel" onClick={closePathInput} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
if (sources.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
<div
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
"flex-1 rounded-lg border min-h-28 flex flex-col items-center justify-center gap-2 px-4 py-5 text-center text-text-subtle",
|
||||
isHovering ? "border-notice bg-surface-highlight" : "border-dashed border-border",
|
||||
)}
|
||||
>
|
||||
<Icon icon="folder_input" className="text-text-subtlest w-10! h-10! mb-1" />
|
||||
{isHovering ? (
|
||||
<span>Drop to add</span>
|
||||
) : (
|
||||
<span>
|
||||
Choose{" "}
|
||||
<InlineButton disabled={disabled} onClick={() => pick(false)}>
|
||||
files
|
||||
</InlineButton>
|
||||
, a{" "}
|
||||
<InlineButton disabled={disabled} onClick={() => pick(true)}>
|
||||
folder
|
||||
</InlineButton>
|
||||
, or a{" "}
|
||||
<InlineButton disabled={disabled} onClick={openPathInput}>
|
||||
URL
|
||||
</InlineButton>
|
||||
, or drag them here
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-text-subtlest">
|
||||
Supports OpenAPI, Swagger, Postman, Insomnia, Bruno, curl, and Yaak exports
|
||||
</span>
|
||||
</div>
|
||||
{showPathInput && pathInputEl}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PathList
|
||||
ref={ref}
|
||||
hovering={isHovering}
|
||||
disabled={disabled}
|
||||
onRemove={onRemove}
|
||||
addLabel="Add files, a folder, or a URL"
|
||||
addHint="or drop them here"
|
||||
addItems={[
|
||||
{ label: "Files", icon: "file", onSelect: () => pick(false) },
|
||||
{ label: "Folder", icon: "folder", onSelect: () => pick(true) },
|
||||
{ label: "URL or file path", icon: "globe", onSelect: openPathInput },
|
||||
]}
|
||||
items={sources.map((source) => {
|
||||
const detection = detections[source.path];
|
||||
return {
|
||||
path: source.path,
|
||||
icon: sourceIcon(source),
|
||||
failed: detection?.status === "error",
|
||||
subtitle: <DetectionSubtitle path={source.path} detection={detection} />,
|
||||
};
|
||||
})}
|
||||
>
|
||||
{showPathInput && pathInputEl}
|
||||
</PathList>
|
||||
);
|
||||
}
|
||||
|
||||
function DetectionSubtitle({
|
||||
path,
|
||||
detection,
|
||||
}: {
|
||||
path: string;
|
||||
detection: ImportSourceDetection | undefined;
|
||||
}) {
|
||||
const { parent } = splitPath(path);
|
||||
if (detection?.status === "loading") {
|
||||
return (
|
||||
<>
|
||||
<LoadingIcon size="xs" />
|
||||
Detecting format
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (detection?.status === "error") return detection.message;
|
||||
return (
|
||||
<>
|
||||
{detection?.status === "ok" && <span>{detection.importer}</span>}
|
||||
{detection?.status === "ok" && parent !== "" && <span>·</span>}
|
||||
{parent !== "" && <span className="truncate font-mono">{parent}</span>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function sourceIcon({ kind }: ImportSourcePath) {
|
||||
switch (kind) {
|
||||
case "url":
|
||||
return "globe";
|
||||
case "folder":
|
||||
return "folder";
|
||||
case "file":
|
||||
return "file";
|
||||
case undefined:
|
||||
return "import";
|
||||
}
|
||||
}
|
||||
|
||||
export function InlineButton({
|
||||
children,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className="underline decoration-current/40 underline-offset-3 hocus:decoration-current disabled:opacity-disabled"
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { autocompletion, startCompletion } from "@codemirror/autocomplete";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { rpc } from "../lib/rpc";
|
||||
import { Input } from "./core/Input";
|
||||
import type { InputProps } from "./core/Input";
|
||||
import { jsonPathCompletion } from "./core/Editor/json/pathCompletion";
|
||||
import type { JsonPathChildren, LoadJsonPathChildren } from "./core/Editor/json/pathCompletion";
|
||||
|
||||
type Props = Pick<InputProps, "defaultValue" | "onChange" | "forceUpdateKey" | "stateKey"> & {
|
||||
response: HttpResponse | null;
|
||||
};
|
||||
|
||||
export function JsonPathInput({ response, ...props }: Props) {
|
||||
const client = useQueryClient();
|
||||
const [sampled, setSampled] = useState(false);
|
||||
const responseKey = response?.state === "closed" ? `${response.id}:${response.updatedAt}` : null;
|
||||
const currentResponseKey = useRef(responseKey);
|
||||
currentResponseKey.current = responseKey;
|
||||
const load = useRef<LoadJsonPathChildren>(async () => null);
|
||||
load.current = async (parent) => {
|
||||
if (!response || !responseKey) return null;
|
||||
try {
|
||||
const result = await client.fetchQuery({
|
||||
queryKey: ["json_path_children", response.id, response.updatedAt, parent],
|
||||
queryFn: () =>
|
||||
rpc<JsonPathChildren>("cmd_http_response_json_children", {
|
||||
responseId: response.id,
|
||||
parent,
|
||||
}),
|
||||
staleTime: Infinity,
|
||||
gcTime: 60_000,
|
||||
retry: false,
|
||||
});
|
||||
if (responseKey !== currentResponseKey.current) return null;
|
||||
setSampled(result.truncated);
|
||||
return result;
|
||||
} catch {
|
||||
return null;
|
||||
} // Non-JSON bodies and incomplete expressions still allow normal typing.
|
||||
};
|
||||
// The editor installs extraExtensions once. A ref keeps the response current without rebuilding
|
||||
// the input and losing focus/selection when a new response arrives.
|
||||
const extensions = useMemo(
|
||||
() => [
|
||||
autocompletion({ override: [jsonPathCompletion((parent) => load.current(parent))] }),
|
||||
EditorView.domEventHandlers({
|
||||
focus: (_event, view) => {
|
||||
startCompletion(view);
|
||||
},
|
||||
}),
|
||||
],
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<Input
|
||||
{...props}
|
||||
label="JSONPath"
|
||||
size="sm"
|
||||
placeholder="$.user.id"
|
||||
language="text"
|
||||
autocompleteFunctions={false}
|
||||
autocompleteVariables={false}
|
||||
extraExtensions={extensions}
|
||||
/>
|
||||
{sampled && (
|
||||
<p className="text-xs text-text-subtle mt-1">
|
||||
Suggestions are sampled. You can enter any path.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import type { Color } from "@yaakapp-internal/plugins";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { settingsAtom } from "@yaakapp-internal/models";
|
||||
import { HeaderSize, Heading, Icon, type IconProps, LoadingIcon } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useState } from "react";
|
||||
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import { importData } from "../lib/importData";
|
||||
import {
|
||||
createExampleWorkspace,
|
||||
createFreshWorkspace,
|
||||
type OnboardingChoice,
|
||||
recordOnboardingChoice,
|
||||
} from "../lib/onboarding";
|
||||
import { showErrorToast } from "../lib/toast";
|
||||
import { CloneGitRepositoryDialog } from "./CloneGitRepositoryDialog";
|
||||
import { Button } from "./core/Button";
|
||||
|
||||
/** Shown instead of a workspace when there are none. */
|
||||
export function Onboarding() {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const { capabilities } = platform;
|
||||
const canImport = capabilities.plugins;
|
||||
const showAlreadyUsing = capabilities.localFiles || capabilities.git || canImport;
|
||||
const [busy, setBusy] = useState<OnboardingChoice | null>(null);
|
||||
|
||||
const choose = (choice: OnboardingChoice, run: () => Promise<void> | void) => async () => {
|
||||
recordOnboardingChoice(choice);
|
||||
setBusy(choice);
|
||||
try {
|
||||
await run();
|
||||
} catch (err) {
|
||||
showErrorToast({
|
||||
id: "onboarding-failed",
|
||||
title: "Something went wrong",
|
||||
message: String(err),
|
||||
});
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full w-full">
|
||||
<HeaderSize
|
||||
data-tauri-drag-region
|
||||
size="lg"
|
||||
className="x-theme-appHeader bg-surface"
|
||||
osType={platform.osType()}
|
||||
hideWindowControls={settings.hideWindowControls}
|
||||
useNativeTitlebar={settings.useNativeTitlebar}
|
||||
interfaceScale={settings.interfaceScale}
|
||||
/>
|
||||
<div className="overflow-auto px-6 py-10 grid">
|
||||
<div className="m-auto w-full max-w-lg flex flex-col gap-7">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Heading>How would you like to get started?</Heading>
|
||||
<p className="text-text-subtle">
|
||||
{canImport
|
||||
? "Bring over existing work, try a real API, or start fresh."
|
||||
: "Try a real API, or start fresh."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{canImport && (
|
||||
<StartOption
|
||||
color="primary"
|
||||
icon="folder_input"
|
||||
title="Migrate from another tool"
|
||||
description="Postman, Insomnia, Bruno, OpenAPI, or curl"
|
||||
busy={busy === "import"}
|
||||
disabled={busy != null}
|
||||
onClick={choose("import", () => importData.mutateAsync())}
|
||||
/>
|
||||
)}
|
||||
<StartOption
|
||||
color="info"
|
||||
icon="flask"
|
||||
title="Try Yaak with a real API"
|
||||
description="Ready-made requests you can send right away"
|
||||
busy={busy === "example"}
|
||||
disabled={busy != null}
|
||||
onClick={choose("example", createExampleWorkspace)}
|
||||
/>
|
||||
<StartOption
|
||||
color="success"
|
||||
icon="plus"
|
||||
title="Start fresh"
|
||||
description="An empty workspace for your first request"
|
||||
busy={busy === "fresh"}
|
||||
disabled={busy != null}
|
||||
onClick={choose("fresh", createFreshWorkspace)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showAlreadyUsing && (
|
||||
<div className="pt-5 border-t border-dashed border-border-subtle flex flex-col items-start gap-2.5">
|
||||
<span className="text-sm text-text-subtle">Already using Yaak?</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{capabilities.localFiles && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="border"
|
||||
color="secondary"
|
||||
leftSlot={<Icon icon="folder_open" size="sm" />}
|
||||
disabled={busy != null}
|
||||
onClick={choose("open_folder", async () => {
|
||||
const dir = await platform.dialog.open({
|
||||
title: "Select Workspace Directory",
|
||||
directory: true,
|
||||
multiple: false,
|
||||
});
|
||||
if (dir == null) return;
|
||||
await openWorkspaceFromSyncDir.mutateAsync(dir);
|
||||
})}
|
||||
>
|
||||
Open folder
|
||||
</Button>
|
||||
)}
|
||||
{capabilities.git && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="border"
|
||||
color="secondary"
|
||||
leftSlot={<Icon icon="git_branch" size="sm" />}
|
||||
disabled={busy != null}
|
||||
onClick={choose("clone_git", () => {
|
||||
showDialog({
|
||||
id: "clone-git-repository",
|
||||
size: "md",
|
||||
title: "Clone Git Repository",
|
||||
render: ({ hide }) => <CloneGitRepositoryDialog hide={hide} />,
|
||||
});
|
||||
})}
|
||||
>
|
||||
Clone repository
|
||||
</Button>
|
||||
)}
|
||||
{canImport && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="border"
|
||||
color="secondary"
|
||||
leftSlot={<Icon icon="import" size="sm" />}
|
||||
disabled={busy != null}
|
||||
onClick={choose("import_yaak", () => importData.mutateAsync())}
|
||||
>
|
||||
Import
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StartOption({
|
||||
color,
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
onClick,
|
||||
busy,
|
||||
disabled,
|
||||
}: {
|
||||
color: Color;
|
||||
icon: IconProps["icon"];
|
||||
title: string;
|
||||
description: string;
|
||||
onClick: () => void;
|
||||
busy: boolean;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={classNames(
|
||||
"group w-full text-left rounded-lg px-3 py-2.5 border border-border-subtle",
|
||||
"grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3.5",
|
||||
"enabled:hocus:bg-surface-highlight/50 enabled:hocus:border-border",
|
||||
"outline-border-focus focus-visible:outline-2",
|
||||
disabled && !busy && "opacity-disabled",
|
||||
)}
|
||||
>
|
||||
{/* The banner theme tints this tile from the theme's own color for each option */}
|
||||
<div
|
||||
className={classNames(
|
||||
`x-theme-banner--${color}`,
|
||||
"size-10 rounded-md bg-surface-highlight grid place-items-center",
|
||||
)}
|
||||
>
|
||||
{busy ? <LoadingIcon size="sm" /> : <Icon icon={icon} color={color} size="md" />}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-text">{title}</div>
|
||||
<div className="text-sm text-text-subtle">{description}</div>
|
||||
</div>
|
||||
<Icon
|
||||
icon="chevron_right"
|
||||
className="text-text-subtlest group-enabled:group-hover:text-text-subtle group-enabled:group-focus-visible:text-text-subtle"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { getRecentRequests } from "../hooks/useRecentRequests";
|
||||
import { useRecentWorkspaces } from "../hooks/useRecentWorkspaces";
|
||||
import { fireAndForget } from "../lib/fireAndForget";
|
||||
import { router } from "../lib/router";
|
||||
import { Onboarding } from "./Onboarding";
|
||||
|
||||
export function RedirectToLatestWorkspace() {
|
||||
const workspaces = useAtomValue(workspacesAtom);
|
||||
@@ -14,10 +15,6 @@ export function RedirectToLatestWorkspace() {
|
||||
|
||||
useEffect(() => {
|
||||
if (workspaces.length === 0 || recentWorkspaces == null) {
|
||||
console.log("No workspaces found to redirect to. Skipping.", {
|
||||
workspaces,
|
||||
recentWorkspaces,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -34,11 +31,21 @@ export function RedirectToLatestWorkspace() {
|
||||
request_id: requestId,
|
||||
};
|
||||
|
||||
// Whatever created the first workspace may have already navigated into it
|
||||
const currentId = router.history.location.pathname.match(/^\/workspaces\/([^/]+)/)?.[1];
|
||||
if (workspaces.some((w) => w.id === currentId)) return;
|
||||
|
||||
console.log("Redirecting to workspace", params, search);
|
||||
await router.navigate({ to: "/workspaces/$workspaceId", params, search });
|
||||
})(),
|
||||
);
|
||||
}, [recentWorkspaces, workspaces, workspaces.length]);
|
||||
|
||||
// The global models are loaded before the router mounts, so an empty list is a real
|
||||
// first launch (or the last workspace was just deleted), not a store still loading
|
||||
if (workspaces.length === 0) {
|
||||
return <Onboarding />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -941,10 +941,14 @@ const sidebarTreeAtom = atom<
|
||||
|
||||
if (node.children != null) {
|
||||
childItems.sort((a, b) => {
|
||||
if (a.sortPriority === b.sortPriority) {
|
||||
return a.updatedAt > b.updatedAt ? 1 : -1;
|
||||
if (a.sortPriority !== b.sortPriority) {
|
||||
return a.sortPriority - b.sortPriority;
|
||||
}
|
||||
return a.sortPriority - b.sortPriority;
|
||||
// Keep equal-priority items in a stable order when they are edited.
|
||||
if (a.createdAt !== b.createdAt) {
|
||||
return a.createdAt > b.createdAt ? 1 : -1;
|
||||
}
|
||||
return a.id === b.id ? 0 : a.id > b.id ? 1 : -1;
|
||||
});
|
||||
|
||||
for (const item of childItems) {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { Workspace } from "@yaakapp-internal/models";
|
||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
||||
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import { Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useState } from "react";
|
||||
import { switchWorkspace } from "../commands/switchWorkspace";
|
||||
import { Button } from "./core/Button";
|
||||
import { Checkbox } from "./core/Checkbox";
|
||||
import { DialogFooter } from "./core/Dialog";
|
||||
|
||||
interface Props {
|
||||
hide: () => void;
|
||||
@@ -16,45 +16,33 @@ export function SwitchWorkspaceDialog({ hide, workspace }: Props) {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const [remember, setRemember] = useState<boolean>(false);
|
||||
|
||||
const open = async (inNewWindow: boolean) => {
|
||||
hide();
|
||||
switchWorkspace.mutate({ workspaceId: workspace.id, inNewWindow });
|
||||
if (remember) {
|
||||
await patchModel(settings, { openWorkspaceNewWindow: inNewWindow });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack space={3}>
|
||||
<p>
|
||||
Where would you like to open <InlineCode>{workspace.name}</InlineCode>?
|
||||
</p>
|
||||
<HStack space={2} justifyContent="start" className="flex-row-reverse">
|
||||
<Button
|
||||
className="focus"
|
||||
color="primary"
|
||||
onClick={async () => {
|
||||
hide();
|
||||
switchWorkspace.mutate({ workspaceId: workspace.id, inNewWindow: false });
|
||||
if (remember) {
|
||||
await patchModel(settings, { openWorkspaceNewWindow: false });
|
||||
}
|
||||
}}
|
||||
>
|
||||
This Window
|
||||
</Button>
|
||||
<Button
|
||||
className="focus"
|
||||
color="secondary"
|
||||
rightSlot={<Icon icon="external_link" />}
|
||||
onClick={async () => {
|
||||
hide();
|
||||
switchWorkspace.mutate({ workspaceId: workspace.id, inNewWindow: true });
|
||||
if (remember) {
|
||||
await patchModel(settings, { openWorkspaceNewWindow: true });
|
||||
}
|
||||
}}
|
||||
>
|
||||
New Window
|
||||
</Button>
|
||||
</HStack>
|
||||
{settings && (
|
||||
<HStack justifyContent="end">
|
||||
<Checkbox checked={remember} title="Remember my choice" onChange={setRemember} />
|
||||
</HStack>
|
||||
<Checkbox checked={remember} title="Remember my choice" onChange={setRemember} />
|
||||
)}
|
||||
<DialogFooter
|
||||
inline
|
||||
actions={[
|
||||
{
|
||||
label: "New Window",
|
||||
rightSlot: <Icon icon="external_link" />,
|
||||
onClick: () => open(true),
|
||||
},
|
||||
{ label: "This Window", color: "primary", autoFocus: true, onClick: () => open(false) },
|
||||
]}
|
||||
/>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import type { FnArg, Tokens } from "@yaakapp-internal/templates";
|
||||
import { parseTemplate } from "@yaakapp-internal/templates";
|
||||
import { HStack, InlineCode, LoadingIcon, useDebouncedValue } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useId, useMemo, useState } from "react";
|
||||
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
|
||||
import { useRenderTemplate } from "../hooks/useRenderTemplate";
|
||||
import { useTemplateFunctionConfig } from "../hooks/useTemplateFunctionConfig";
|
||||
@@ -24,7 +24,7 @@ import { showDialog } from "../lib/dialog";
|
||||
import { convertTemplateToInsecure } from "../lib/encryption";
|
||||
import { jotaiStore } from "../lib/jotai";
|
||||
import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
|
||||
import { Button } from "./core/Button";
|
||||
import { DialogFooter } from "./core/Dialog";
|
||||
import { collectArgumentValues } from "./core/Editor/twig/util";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
@@ -91,6 +91,7 @@ function InitializedTemplateFunctionDialog({
|
||||
const previewType = ogPreviewType == null ? "live" : ogPreviewType;
|
||||
const [showSecretsInPreview, toggleShowSecretsInPreview] = useToggle(false);
|
||||
const [argValues, setArgValues] = useState<Record<string, string | boolean>>(initialArgValues);
|
||||
const formId = useId();
|
||||
|
||||
const tokens: Tokens = useMemo(() => {
|
||||
const argTokens: FnArg[] = Object.keys(argValues).map((name) => ({
|
||||
@@ -160,13 +161,14 @@ function InitializedTemplateFunctionDialog({
|
||||
|
||||
return (
|
||||
<form
|
||||
className="grid grid-rows-[minmax(0,1fr)_auto_auto] h-full max-h-[90vh]"
|
||||
id={formId}
|
||||
className="grid grid-rows-[minmax(0,1fr)_auto] h-full"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleDone();
|
||||
}}
|
||||
>
|
||||
<div className="overflow-y-auto h-full px-6">
|
||||
<div className="overflow-y-auto h-full">
|
||||
{name === "secure" ? (
|
||||
<PlainInput
|
||||
required
|
||||
@@ -188,75 +190,71 @@ function InitializedTemplateFunctionDialog({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-6 border-t border-t-border pt-3 pb-6 bg-surface-highlight w-full flex flex-col gap-4">
|
||||
{previewType !== "none" ? (
|
||||
<div className="w-full grid grid-cols-1 grid-rows-[auto_auto]">
|
||||
<HStack space={0.5}>
|
||||
<HStack className="text-sm text-text-subtle" space={1.5}>
|
||||
Rendered Preview
|
||||
{rendered.isLoading && <LoadingIcon size="xs" />}
|
||||
</HStack>
|
||||
{previewType !== "none" ? (
|
||||
<div className="w-full grid grid-cols-1 grid-rows-[auto_auto] border-t border-t-border pt-3">
|
||||
<HStack space={0.5}>
|
||||
<HStack className="text-sm text-text-subtle" space={1.5}>
|
||||
Rendered Preview
|
||||
{rendered.isLoading && <LoadingIcon size="xs" />}
|
||||
</HStack>
|
||||
<IconButton
|
||||
size="xs"
|
||||
iconSize="sm"
|
||||
icon={showSecretsInPreview ? "lock" : "lock_open"}
|
||||
title={showSecretsInPreview ? "Show preview" : "Hide preview"}
|
||||
onClick={toggleShowSecretsInPreview}
|
||||
className={classNames(
|
||||
"ml-auto text-text-subtlest",
|
||||
!dataContainsSecrets && "invisible",
|
||||
)}
|
||||
/>
|
||||
</HStack>
|
||||
<div className="relative w-full max-h-40">
|
||||
<InlineCode
|
||||
className={classNames(
|
||||
"block whitespace-pre-wrap select-text! cursor-text max-h-40 overflow-auto hide-scrollbars border-text-subtlest!",
|
||||
tooLarge && "italic text-danger",
|
||||
)}
|
||||
>
|
||||
{rendered.error || tagText.error ? (
|
||||
<em className="text-danger">
|
||||
{`${rendered.error || tagText.error}`.replace(/^Render Error: /, "")}
|
||||
</em>
|
||||
) : dataContainsSecrets && !showSecretsInPreview ? (
|
||||
<span className="italic text-text-subtle">
|
||||
------ sensitive values hidden ------
|
||||
</span>
|
||||
) : tooLarge ? (
|
||||
"too large to preview"
|
||||
) : (
|
||||
rendered.data || <> </>
|
||||
)}
|
||||
</InlineCode>
|
||||
<div className="absolute right-0.5 top-0 bottom-0 flex items-center">
|
||||
<IconButton
|
||||
size="xs"
|
||||
iconSize="sm"
|
||||
icon={showSecretsInPreview ? "lock" : "lock_open"}
|
||||
title={showSecretsInPreview ? "Show preview" : "Hide preview"}
|
||||
onClick={toggleShowSecretsInPreview}
|
||||
className={classNames(
|
||||
"ml-auto text-text-subtlest",
|
||||
!dataContainsSecrets && "invisible",
|
||||
)}
|
||||
icon="refresh"
|
||||
className="text-text-subtle"
|
||||
title="Refresh preview"
|
||||
spin={rendered.isPending}
|
||||
onClick={() => {
|
||||
setRenderKey(new Date().toISOString());
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
<div className="relative w-full max-h-40">
|
||||
<InlineCode
|
||||
className={classNames(
|
||||
"block whitespace-pre-wrap select-text! cursor-text max-h-40 overflow-auto hide-scrollbars border-text-subtlest!",
|
||||
tooLarge && "italic text-danger",
|
||||
)}
|
||||
>
|
||||
{rendered.error || tagText.error ? (
|
||||
<em className="text-danger">
|
||||
{`${rendered.error || tagText.error}`.replace(/^Render Error: /, "")}
|
||||
</em>
|
||||
) : dataContainsSecrets && !showSecretsInPreview ? (
|
||||
<span className="italic text-text-subtle">
|
||||
------ sensitive values hidden ------
|
||||
</span>
|
||||
) : tooLarge ? (
|
||||
"too large to preview"
|
||||
) : (
|
||||
rendered.data || <> </>
|
||||
)}
|
||||
</InlineCode>
|
||||
<div className="absolute right-0.5 top-0 bottom-0 flex items-center">
|
||||
<IconButton
|
||||
size="xs"
|
||||
icon="refresh"
|
||||
className="text-text-subtle"
|
||||
title="Refresh preview"
|
||||
spin={rendered.isPending}
|
||||
onClick={() => {
|
||||
setRenderKey(new Date().toISOString());
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<div className="flex justify-stretch w-full grow gap-2 *:flex-1">
|
||||
{templateFunction.data.name === "secure" && (
|
||||
<Button variant="border" color="secondary" onClick={setupOrConfigureEncryption}>
|
||||
Reveal Encryption Key
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" color="primary">
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<DialogFooter
|
||||
actions={[
|
||||
...(templateFunction.data.name === "secure"
|
||||
? [{ label: "Reveal Encryption Key", onClick: setupOrConfigureEncryption }]
|
||||
: []),
|
||||
{ label: "Save", color: "primary", form: formId },
|
||||
]}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -272,7 +270,6 @@ TemplateFunctionDialog.show = (
|
||||
id: `template-function-${Math.random()}`, // Allow multiple at once
|
||||
size: "md",
|
||||
className: "h-240",
|
||||
noPadding: true,
|
||||
title: <InlineCode>{fn.name}(…)</InlineCode>,
|
||||
description: fn.description,
|
||||
render: ({ hide }) => {
|
||||
|
||||
@@ -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,32 @@
|
||||
import type { Color } from "@yaakapp-internal/plugins";
|
||||
import classNames from "classnames";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface Props {
|
||||
color?: Color | "default";
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** A small tinted label, like a Gmail or Trello label */
|
||||
export function Chip({ color = "default", className, children }: Props) {
|
||||
return (
|
||||
<span
|
||||
className={classNames(
|
||||
className,
|
||||
"text-xs font-medium rounded-md px-2 py-0.5 bg-current/10 border border-current/10 shrink-0 whitespace-nowrap",
|
||||
"inline-flex items-center gap-1",
|
||||
color === "default" && "text-text-subtle",
|
||||
color === "primary" && "text-primary",
|
||||
color === "secondary" && "text-secondary",
|
||||
color === "info" && "text-info",
|
||||
color === "success" && "text-success",
|
||||
color === "notice" && "text-notice",
|
||||
color === "warning" && "text-warning",
|
||||
color === "danger" && "text-danger",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { Color } from "@yaakapp-internal/plugins";
|
||||
import { HStack } from "@yaakapp-internal/ui";
|
||||
import type { FormEvent } from "react";
|
||||
import { useState } from "react";
|
||||
import { useId, useState } from "react";
|
||||
import { CopyIconButton } from "../CopyIconButton";
|
||||
import { Button } from "./Button";
|
||||
import { DialogFooter } from "./Dialog";
|
||||
import { PlainInput } from "./PlainInput";
|
||||
|
||||
export interface ConfirmProps {
|
||||
@@ -22,6 +21,7 @@ export function Confirm({
|
||||
color = "primary",
|
||||
}: ConfirmProps) {
|
||||
const [confirm, setConfirm] = useState<string>("");
|
||||
const formId = useId();
|
||||
const handleHide = () => {
|
||||
onResult(false);
|
||||
onHide();
|
||||
@@ -38,7 +38,7 @@ export function Confirm({
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="flex flex-col" onSubmit={handleSuccess}>
|
||||
<form id={formId} className="flex flex-col" onSubmit={handleSuccess}>
|
||||
{requireTyping && (
|
||||
<PlainInput
|
||||
autoFocus
|
||||
@@ -61,14 +61,19 @@ export function Confirm({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<HStack space={2} justifyContent="start" className="mt-2 mb-4 flex-row-reverse">
|
||||
<Button type="submit" color={color} disabled={!didConfirm}>
|
||||
{confirmText ?? "Confirm"}
|
||||
</Button>
|
||||
<Button onClick={handleHide} variant="border">
|
||||
Cancel
|
||||
</Button>
|
||||
</HStack>
|
||||
<DialogFooter
|
||||
inline
|
||||
actions={[
|
||||
{ label: "Cancel", onClick: handleHide },
|
||||
{
|
||||
label: confirmText ?? "Confirm",
|
||||
color,
|
||||
form: formId,
|
||||
disabled: !didConfirm,
|
||||
autoFocus: !requireTyping,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { DialogSize } from "@yaakapp-internal/plugins";
|
||||
import { Heading, Overlay } from "@yaakapp-internal/ui";
|
||||
import { Heading, HStack, Overlay } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import * as m from "motion/react-m";
|
||||
import type { ReactNode } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { createContext, useContext, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Button, type ButtonProps } from "./Button";
|
||||
import { IconButton } from "./IconButton";
|
||||
|
||||
export interface DialogProps {
|
||||
@@ -38,6 +40,7 @@ export function Dialog({
|
||||
vAlign = "center",
|
||||
}: DialogProps) {
|
||||
const titleId = useMemo(() => Math.random().toString(36).slice(2), []);
|
||||
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
|
||||
const descriptionId = useMemo(
|
||||
() => (description ? Math.random().toString(36).slice(2) : undefined),
|
||||
[description],
|
||||
@@ -71,7 +74,7 @@ export function Dialog({
|
||||
animate={{ top: 0, scale: 1 }}
|
||||
className={classNames(
|
||||
className,
|
||||
"grid grid-rows-[auto_auto_minmax(0,1fr)]",
|
||||
"grid grid-rows-[auto_auto_minmax(0,1fr)_auto]",
|
||||
"grid-cols-1", // must be here for inline code blocks to correctly break words
|
||||
"relative bg-surface pointer-events-auto",
|
||||
"rounded-lg",
|
||||
@@ -108,9 +111,11 @@ export function Dialog({
|
||||
!noScroll && "overflow-y-auto overflow-x-hidden",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<DialogFooterSlot.Provider value={footerEl}>{children}</DialogFooterSlot.Provider>
|
||||
</div>
|
||||
|
||||
<div ref={setFooterEl} className="contents" />
|
||||
|
||||
{/*Put close at the end so that it's the last thing to be tabbed to*/}
|
||||
{!disableClose && !hideX && (
|
||||
<div className="ml-auto absolute right-1 top-1">
|
||||
@@ -129,3 +134,67 @@ export function Dialog({
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
|
||||
const DialogFooterSlot = createContext<HTMLDivElement | null>(null);
|
||||
|
||||
export interface DialogFooterAction {
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
/** Submit the form with this id, which can be anywhere in the dialog body */
|
||||
form?: string;
|
||||
color?: ButtonProps["color"];
|
||||
variant?: ButtonProps["variant"];
|
||||
disabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
autoFocus?: boolean;
|
||||
leftSlot?: ReactNode;
|
||||
rightSlot?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The action row at the bottom of a dialog. Render it anywhere in the dialog's content and it is
|
||||
* placed below the scrolling body, outside its padding. `inline` drops the divider for small
|
||||
* dialogs whose body never scrolls.
|
||||
*/
|
||||
export function DialogFooter({
|
||||
actions,
|
||||
leftSlot,
|
||||
inline,
|
||||
}: {
|
||||
actions: DialogFooterAction[];
|
||||
leftSlot?: ReactNode;
|
||||
inline?: boolean;
|
||||
}) {
|
||||
const slot = useContext(DialogFooterSlot);
|
||||
const footer = (
|
||||
<footer
|
||||
className={classNames(
|
||||
"px-6 flex items-center gap-3",
|
||||
inline ? "pt-1 pb-4" : "py-3 border-t border-border-subtle",
|
||||
)}
|
||||
>
|
||||
{leftSlot != null && <div className="mr-auto min-w-0">{leftSlot}</div>}
|
||||
<HStack space={2} justifyContent="end" className="ml-auto shrink-0">
|
||||
{actions.map((action, i) => (
|
||||
<Button
|
||||
// Labels change with state ("Cloning...", "Apply 3 Changes"), so they can't be keys
|
||||
key={i}
|
||||
type={action.form != null ? "submit" : "button"}
|
||||
form={action.form}
|
||||
color={action.color ?? "secondary"}
|
||||
variant={action.variant ?? (action.color == null ? "border" : "solid")}
|
||||
disabled={action.disabled}
|
||||
isLoading={action.isLoading}
|
||||
autoFocus={action.autoFocus}
|
||||
leftSlot={action.leftSlot}
|
||||
rightSlot={action.rightSlot}
|
||||
onClick={action.onClick}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</HStack>
|
||||
</footer>
|
||||
);
|
||||
return slot == null ? footer : createPortal(footer, slot);
|
||||
}
|
||||
@@ -32,6 +32,8 @@ interface JsonLintOptions {
|
||||
export function jsonParseLinter(options?: JsonLintOptions) {
|
||||
return (view: EditorView): Diagnostic[] => {
|
||||
const doc = view.state.doc.toString();
|
||||
if (doc.trim() === "") return [];
|
||||
|
||||
// We need lint to not break on stuff like {"foo:" ${[ ... ]}} so we'll replace all template
|
||||
// syntax with repeating `1` characters, so it's valid JSON and the position is still correct.
|
||||
const escapedDoc = doc.replace(TEMPLATE_SYNTAX_REGEX, (m) => "1".repeat(m.length));
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { syntaxTree } from "@codemirror/language";
|
||||
import type { Extension } from "@codemirror/state";
|
||||
import type { EditorView, ViewUpdate } from "@codemirror/view";
|
||||
import { ViewPlugin } from "@codemirror/view";
|
||||
import type { JsonPathSegment } from "./jsonPath";
|
||||
import { jsonPathSegmentsAt } from "./jsonPath";
|
||||
|
||||
export interface BreadcrumbUpdate {
|
||||
/** Path to the cursor, or `null` when the document isn't JSON. */
|
||||
segments: JsonPathSegment[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports the JSON path under the cursor when the editor is created (it may restore a
|
||||
* cached selection), and whenever the selection, document, or parsed tree changes. The
|
||||
* tree matters because large documents parse in the background, so a path read early
|
||||
* can be wrong until the parser reaches the cursor.
|
||||
*
|
||||
* The callback is not fired on blur: moving focus to the filter box (by clicking
|
||||
* a crumb) must not erase the breadcrumb that was just acted on.
|
||||
*/
|
||||
export function jsonBreadcrumbExtension(onUpdate: (update: BreadcrumbUpdate) => void): Extension {
|
||||
const report = (view: EditorView) => {
|
||||
onUpdate({ segments: jsonPathSegmentsAt(view.state, view.state.selection.main.head) });
|
||||
};
|
||||
|
||||
return ViewPlugin.define((view) => {
|
||||
report(view);
|
||||
return {
|
||||
update(update: ViewUpdate) {
|
||||
if (
|
||||
update.selectionSet ||
|
||||
update.docChanged ||
|
||||
syntaxTree(update.startState) !== syntaxTree(update.state)
|
||||
) {
|
||||
report(update.view);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { jsonc } from "@shopify/lang-jsonc";
|
||||
import { forceParsing } from "@codemirror/language";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { jsonPathSegmentsAt, jsonPathToSegments, segmentsToJsonPath } from "./jsonPath";
|
||||
|
||||
function stateFor(doc: string): EditorState {
|
||||
const state = EditorState.create({ doc, extensions: [jsonc()] });
|
||||
// Ensure the whole doc is parsed so resolveInner sees a complete tree.
|
||||
forceParsing({ state } as never, doc.length, 5000);
|
||||
return state;
|
||||
}
|
||||
|
||||
/** JSONPath produced when the cursor sits just after the first occurrence of `needle`. */
|
||||
function pathAt(doc: string, needle: string): string {
|
||||
const pos = doc.indexOf(needle) + needle.length;
|
||||
const segs = jsonPathSegmentsAt(stateFor(doc), pos);
|
||||
if (segs == null) throw new Error("no JSON tree");
|
||||
return segmentsToJsonPath(segs);
|
||||
}
|
||||
|
||||
/** JSONPath produced when the cursor sits just before the first occurrence of `needle`. */
|
||||
function pathBefore(doc: string, needle: string): string {
|
||||
const pos = doc.indexOf(needle);
|
||||
const segs = jsonPathSegmentsAt(stateFor(doc), pos);
|
||||
if (segs == null) throw new Error("no JSON tree");
|
||||
return segmentsToJsonPath(segs);
|
||||
}
|
||||
|
||||
describe("jsonPathSegmentsAt", () => {
|
||||
test("nested object keys", () => {
|
||||
const doc = `{ "address": { "geo": { "lat": "-37" } } }`;
|
||||
expect(pathAt(doc, `-37`)).toBe(`$.address.geo.lat`);
|
||||
});
|
||||
|
||||
test("array element index", () => {
|
||||
const doc = `{ "featured": [ { "id": 1 }, { "id": 2 } ] }`;
|
||||
expect(pathAt(doc, `2`)).toBe(`$.featured[1].id`);
|
||||
});
|
||||
|
||||
test("root array element", () => {
|
||||
const doc = `[ { "id": 1 }, { "id": 2 } ]`;
|
||||
expect(pathAt(doc, `"id": 2`)).toBe(`$[1].id`);
|
||||
});
|
||||
|
||||
test("key containing a dot uses bracket-quote notation", () => {
|
||||
const doc = `{ "sort": { "link": { "filter": { "category.id": [ "100" ] } } } }`;
|
||||
expect(pathAt(doc, `"100"`)).toBe(`$.sort.link.filter["category.id"][0]`);
|
||||
});
|
||||
|
||||
test("cursor on a property name resolves that key", () => {
|
||||
const doc = `{ "outer": { "inner": 5 } }`;
|
||||
expect(pathAt(doc, `"inner"`)).toBe(`$.outer.inner`);
|
||||
});
|
||||
|
||||
test("deeply nested arrays and objects", () => {
|
||||
const doc = `{ "a": [ { "b": [ 10, 20, 30 ] } ] }`;
|
||||
expect(pathAt(doc, `30`)).toBe(`$.a[0].b[2]`);
|
||||
});
|
||||
|
||||
test("root scalar yields the root path", () => {
|
||||
expect(pathAt(`"hello"`, `hello`)).toBe(`$`);
|
||||
});
|
||||
|
||||
test("caret just before a key still resolves that key", () => {
|
||||
// The character to the left is the object's whitespace, not the property.
|
||||
const doc = `[\n {\n "userId": 1,\n "body": "x"\n }\n]`;
|
||||
expect(pathBefore(doc, `"body"`)).toBe(`$[0].body`);
|
||||
});
|
||||
|
||||
test("caret in a line's indentation resolves that line's member", () => {
|
||||
// Caret in the blank indentation of the `title` line (structurally the
|
||||
// object's whitespace) reads as being on `title`, not a neighbour.
|
||||
const doc = `[\n {\n "userId": 1,\n "id": 1,\n "title": "x"\n }\n]`;
|
||||
const pos = doc.indexOf(`"title"`) - 2; // two spaces into the title line
|
||||
const segs = jsonPathSegmentsAt(stateFor(doc), pos);
|
||||
expect(segs == null ? null : segmentsToJsonPath(segs)).toBe(`$[0].title`);
|
||||
});
|
||||
|
||||
test("caret at the end of an array element keeps the element index", () => {
|
||||
// The caret sits after `}` where the left token is the object's close brace.
|
||||
const doc = `[\n { "id": 1 },\n { "id": 2 }\n]`;
|
||||
const pos = doc.indexOf(`},`) + 1;
|
||||
const segs = jsonPathSegmentsAt(stateFor(doc), pos);
|
||||
expect(segs == null ? null : segmentsToJsonPath(segs)).toBe(`$[0]`);
|
||||
});
|
||||
|
||||
test("non-JSON documents return null", () => {
|
||||
const state = EditorState.create({ doc: `<xml/>` });
|
||||
expect(jsonPathSegmentsAt(state, 3)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("segmentsToJsonPath", () => {
|
||||
test("empty segment list is the root", () => {
|
||||
expect(segmentsToJsonPath([])).toBe(`$`);
|
||||
});
|
||||
|
||||
test("partial path via count", () => {
|
||||
const segs = [
|
||||
{ kind: "key", key: "a" },
|
||||
{ kind: "index", index: 2 },
|
||||
{ kind: "key", key: "b" },
|
||||
] as const;
|
||||
expect(segmentsToJsonPath([...segs], 2)).toBe(`$.a[2]`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("jsonPathToSegments", () => {
|
||||
test("round-trips paths built by segmentsToJsonPath", () => {
|
||||
for (const path of ["$", "$[0]", "$[0].id", '$.data["user.name"][12]', '$["say \\"hi\\""]']) {
|
||||
const segments = jsonPathToSegments(path);
|
||||
expect(segments).not.toBeNull();
|
||||
expect(segmentsToJsonPath(segments ?? [])).toBe(path);
|
||||
}
|
||||
});
|
||||
|
||||
test("returns null for paths that don't name a single location", () => {
|
||||
for (const path of [
|
||||
"$..id",
|
||||
"$[*]",
|
||||
"$.items[?(@.id)]",
|
||||
"$[0:2]",
|
||||
"items[0]",
|
||||
"$.a b",
|
||||
'$["\\q"]',
|
||||
'$["unterminated]',
|
||||
'$["line\nbreak"]',
|
||||
"$[01]",
|
||||
"$[9007199254740993]",
|
||||
"$.$",
|
||||
]) {
|
||||
expect(jsonPathToSegments(path)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { syntaxTree } from "@codemirror/language";
|
||||
import type { EditorState } from "@codemirror/state";
|
||||
import type { SyntaxNode } from "@lezer/common";
|
||||
import type { JsonPathSegment } from "@yaakapp-internal/lib/jsonPath";
|
||||
|
||||
export { jsonPathToSegments } from "@yaakapp-internal/lib/jsonPath";
|
||||
export type { JsonPathSegment } from "@yaakapp-internal/lib/jsonPath";
|
||||
|
||||
// Lezer node names from the JSONC grammar (@shopify/lang-jsonc). A JSON value is
|
||||
// exactly one of these; everything else in the tree is punctuation or a comment.
|
||||
const VALUE_NODES = new Set(["Object", "Array", "String", "Number", "True", "False", "Null"]);
|
||||
// The nodes with children that a caret can land "between" — its whitespace and
|
||||
// punctuation belong to the container, not to any value inside it.
|
||||
const CONTAINER_NODES = new Set(["Object", "Array", "JsoncText"]);
|
||||
|
||||
/** Read a `PropertyName` (a quoted JSON string) back to its raw key. */
|
||||
function keyFromPropertyName(node: SyntaxNode, state: EditorState): string {
|
||||
const raw = state.doc.sliceString(node.from, node.to);
|
||||
try {
|
||||
return JSON.parse(raw) as string;
|
||||
} catch {
|
||||
// A half-typed or malformed key still deserves a best-effort label.
|
||||
return raw.replace(/^"|"$/g, "");
|
||||
}
|
||||
}
|
||||
|
||||
/** Index of `child` among the value elements of its parent `Array` node. */
|
||||
function indexInArray(array: SyntaxNode, child: SyntaxNode): number {
|
||||
let idx = 0;
|
||||
for (let c = array.firstChild; c != null; c = c.nextSibling) {
|
||||
if (c.from === child.from && c.to === child.to) return idx;
|
||||
if (VALUE_NODES.has(c.name)) idx++;
|
||||
}
|
||||
return idx;
|
||||
}
|
||||
|
||||
/**
|
||||
* The path from the document root to the member on the caret's line, or `null`
|
||||
* when the document has no JSON tree (a different language, or empty). An empty
|
||||
* array means the caret sits at the root value itself.
|
||||
*
|
||||
* A caret exactly on a token uses that token. A caret in a line's whitespace
|
||||
* resolves to the enclosing object or array, so it's re-anchored to the first
|
||||
* non-whitespace character on that line — in pretty-printed JSON each member
|
||||
* starts its own line, so that's the member's own token. This tracks "the line
|
||||
* I'm on" without guessing between neighbouring keys.
|
||||
*
|
||||
* From the anchor, each ancestor contributes a segment: a `Property` its key, a
|
||||
* value directly inside an `Array` its index. Collected deepest-first, reversed.
|
||||
*/
|
||||
export function jsonPathSegmentsAt(state: EditorState, pos: number): JsonPathSegment[] | null {
|
||||
const tree = syntaxTree(state);
|
||||
if (tree.topNode.name !== "JsoncText") return null;
|
||||
|
||||
let node: SyntaxNode = tree.resolveInner(pos, -1);
|
||||
if (CONTAINER_NODES.has(node.name)) {
|
||||
const line = state.doc.lineAt(pos);
|
||||
const indent = line.text.length - line.text.trimStart().length;
|
||||
if (indent < line.text.length) {
|
||||
node = tree.resolveInner(line.from + indent, 1);
|
||||
}
|
||||
}
|
||||
|
||||
const segments: JsonPathSegment[] = [];
|
||||
let cur: SyntaxNode | null = node;
|
||||
while (cur != null) {
|
||||
const parent: SyntaxNode | null = cur.parent;
|
||||
if (cur.name === "Property") {
|
||||
const nameNode = cur.getChild("PropertyName");
|
||||
if (nameNode != null) {
|
||||
segments.push({ kind: "key", key: keyFromPropertyName(nameNode, state) });
|
||||
}
|
||||
} else if (parent != null && parent.name === "Array" && VALUE_NODES.has(cur.name)) {
|
||||
segments.push({ kind: "index", index: indexInArray(parent, cur) });
|
||||
}
|
||||
cur = parent;
|
||||
}
|
||||
|
||||
segments.reverse();
|
||||
return segments;
|
||||
}
|
||||
|
||||
const BARE_KEY = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
||||
|
||||
/** A single segment as JSONPath, choosing dot vs. bracket form for a key. */
|
||||
function segmentToJsonPath(segment: JsonPathSegment): string {
|
||||
if (segment.kind === "index") return `[${segment.index}]`;
|
||||
// jsonpath-plus interprets a bare `$` as its root operator.
|
||||
if (BARE_KEY.test(segment.key) && segment.key !== "$") return `.${segment.key}`;
|
||||
// Anything with a dot, space, quote, etc. must be a quoted bracket accessor.
|
||||
// JSON.stringify gives correct double-quoting and escaping, matching the
|
||||
// convention the JSONPath filter box already displays.
|
||||
return `[${JSON.stringify(segment.key)}]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a JSONPath expression for the first `count` segments (all of them by
|
||||
* default). `segmentsToJsonPath([])` is the root, `$`.
|
||||
*/
|
||||
export function segmentsToJsonPath(segments: JsonPathSegment[], count = segments.length): string {
|
||||
return "$" + segments.slice(0, count).map(segmentToJsonPath).join("");
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { CompletionContext } from "@codemirror/autocomplete";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { describe, expect, it, vi } from "vite-plus/test";
|
||||
import { jsonPathCompletion, jsonPathCompletionTarget } from "./pathCompletion";
|
||||
import type { JsonPathChildren } from "./pathCompletion";
|
||||
|
||||
const root: JsonPathChildren = {
|
||||
children: [
|
||||
{ label: "users", selector: ".users", kind: "array" },
|
||||
{ label: "total", selector: ".total", kind: "number" },
|
||||
{ label: "a.b", selector: '["a.b"]', kind: "text" },
|
||||
{ label: "a,b", selector: '["a,b"]', kind: "text" },
|
||||
],
|
||||
truncated: false,
|
||||
};
|
||||
function context(text: string, explicit = false, pos = text.length) {
|
||||
return new CompletionContext(EditorState.create({ doc: text }), pos, explicit);
|
||||
}
|
||||
|
||||
describe("progressive JSONPath completion", () => {
|
||||
it.each([
|
||||
["", "$", ""],
|
||||
["$", "$", ""],
|
||||
["$.", "$", "."],
|
||||
["us", "$", "us"],
|
||||
["users.na", "$.users", ".na"],
|
||||
["$.users[", "$.users", "["],
|
||||
["$.users[0].na", "$.users[0]", ".na"],
|
||||
["$.users[*].", "$.users[*]", "."],
|
||||
['$["a.b"].', '$["a.b"]', "."],
|
||||
['$["a.b', "$", '["a.b'],
|
||||
["$['a.b", "$", "['a.b"],
|
||||
['$["a,b', "$", '["a,b'],
|
||||
['$.users[?(@.name == "a.b")].', '$.users[?(@.name == "a.b")]', "."],
|
||||
])("finds the next level in %s", (text, parent, fragment) => {
|
||||
expect(jsonPathCompletionTarget(text)).toEqual({ parent, fragment });
|
||||
});
|
||||
|
||||
it("loads only the parent, filters the typed prefix, and inserts a complete escaped path", async () => {
|
||||
const load = vi.fn(async () => root);
|
||||
const complete = jsonPathCompletion(load);
|
||||
const result = await complete(context("us"));
|
||||
expect(load).toHaveBeenCalledExactlyOnceWith("$");
|
||||
expect(result?.options.map((o) => o.label)).toEqual(["users"]);
|
||||
const quoted = await complete(context('$["a.b'));
|
||||
const option = quoted!.options[0]!;
|
||||
const dispatch = vi.fn();
|
||||
expect(option.label).toBe("a.b");
|
||||
if (typeof option.apply !== "function") throw new Error("Expected a completion function");
|
||||
option.apply({ dispatch } as unknown as EditorView, option, 0, 6);
|
||||
expect(dispatch.mock.calls[0]?.[0].changes).toEqual({ from: 0, to: 6, insert: '$["a.b"]' });
|
||||
expect((await complete(context("$['a.b")))?.options.map((o) => o.label)).toEqual(["a.b"]);
|
||||
});
|
||||
|
||||
it("explores a completed container without turning the path into an invalid draft", async () => {
|
||||
const array: JsonPathChildren = {
|
||||
children: [
|
||||
{ label: "[*]", selector: "[*]", kind: "all items" },
|
||||
{ label: "[0]", selector: "[0]", kind: "object" },
|
||||
],
|
||||
truncated: false,
|
||||
};
|
||||
const load = vi.fn(async (parent: string) => (parent === "$.users" ? array : root));
|
||||
const result = await jsonPathCompletion(load)(context("$.users", true));
|
||||
expect(load).toHaveBeenCalledExactlyOnceWith("$.users");
|
||||
expect(result?.options.map((o) => o.label)).toEqual(["[*]", "[0]"]);
|
||||
expect(
|
||||
(await jsonPathCompletion(load)(context("$.users[0")))?.options.map((o) => o.label),
|
||||
).toEqual(["[0]"]);
|
||||
});
|
||||
|
||||
it("does not suggest descendants at the root or duplicate the current scalar path", async () => {
|
||||
const load = vi.fn(async (parent: string) =>
|
||||
parent === "$" ? root : { children: [], truncated: false },
|
||||
);
|
||||
const result = await jsonPathCompletion(load)(context(""));
|
||||
expect(result?.options.map((o) => o.label)).toEqual(["users", "total", "a.b", "a,b"]);
|
||||
expect(await jsonPathCompletion(load)(context("$.total", true))).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves complex expressions, mid-path editing, unavailable data, and stale results alone", async () => {
|
||||
const load = vi.fn(async () => root);
|
||||
expect(await jsonPathCompletion(load)(context("$.users[?(@.na"))).toBeNull();
|
||||
expect(await jsonPathCompletion(load)(context("$..na"))).toBeNull();
|
||||
expect(await jsonPathCompletion(load)(context("$.users[0].name", false, 5))).toBeNull();
|
||||
expect(load).not.toHaveBeenCalled();
|
||||
expect(await jsonPathCompletion(async () => null)(context("$"))).toBeNull();
|
||||
const stale = context("$.");
|
||||
Object.defineProperty(stale, "aborted", { get: () => true });
|
||||
expect(await jsonPathCompletion(load)(stale)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { CompletionContext, CompletionResult } from "@codemirror/autocomplete";
|
||||
import { startCompletion } from "@codemirror/autocomplete";
|
||||
import type { RpcSchema } from "@yaakapp-internal/rpc-schema";
|
||||
|
||||
export type JsonPathChildren = RpcSchema["cmd_http_response_json_children"][1];
|
||||
export type LoadJsonPathChildren = (parent: string) => Promise<JsonPathChildren | null>;
|
||||
|
||||
export function normalizeJsonPath(value: string): string {
|
||||
const text = value.trim();
|
||||
if (!text) return "$";
|
||||
return text.startsWith("$") ? text : text.startsWith("[") ? `$${text}` : `$.${text}`;
|
||||
}
|
||||
|
||||
/** Locate the unfinished child accessor, leaving complete JSONPath expressions to Rust. */
|
||||
export function jsonPathCompletionTarget(
|
||||
text: string,
|
||||
): { parent: string; fragment: string } | null {
|
||||
text = text.trim();
|
||||
if (text.length > 1024) return null;
|
||||
if (!text || text === "$") return { parent: "$", fragment: "" };
|
||||
let quote = "";
|
||||
let escaped = false;
|
||||
let depth = 0;
|
||||
let boundary = -1;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const c = text[i];
|
||||
if (quote) {
|
||||
if (escaped) escaped = false;
|
||||
else if (c === "\\") escaped = true;
|
||||
else if (c === quote) quote = "";
|
||||
continue;
|
||||
}
|
||||
if (depth > 0 && (c === '"' || c === "'")) quote = c;
|
||||
else if (c === "[") {
|
||||
if (depth === 0) boundary = i;
|
||||
depth++;
|
||||
} else if (c === "]") {
|
||||
if (--depth < 0) return null;
|
||||
} else if (depth === 0 && c === ".") boundary = i;
|
||||
}
|
||||
if (depth === 0 && text.endsWith("]")) return { parent: normalizeJsonPath(text), fragment: "" };
|
||||
if (boundary === -1) return text.startsWith("$") ? null : { parent: "$", fragment: text };
|
||||
const fragment = text.slice(boundary);
|
||||
// Don't try to complete inside filters, unions, or recursive descent.
|
||||
if (
|
||||
depth > 1 ||
|
||||
fragment.startsWith("[?") ||
|
||||
fragment.startsWith("[(") ||
|
||||
text[boundary - 1] === "." ||
|
||||
fragment.startsWith("[...") ||
|
||||
(fragment.includes(",") && !fragment.startsWith('["') && !fragment.startsWith("['"))
|
||||
)
|
||||
return null;
|
||||
return { parent: normalizeJsonPath(text.slice(0, boundary)), fragment };
|
||||
}
|
||||
|
||||
function matches(child: JsonPathChildren["children"][number], fragment: string): boolean {
|
||||
if (!fragment) return true;
|
||||
if (!fragment.startsWith("[")) return child.label.startsWith(fragment.replace(/^\./, ""));
|
||||
if (child.selector.startsWith("[") && !child.selector.startsWith('["'))
|
||||
return child.selector.startsWith(fragment);
|
||||
const quoted = JSON.stringify(child.label);
|
||||
const accessor = fragment.startsWith("['")
|
||||
? `['${quoted.slice(1, -1).replace(/\\"/g, '"').replace(/'/g, "\\'")}']`
|
||||
: `[${quoted}]`;
|
||||
return accessor.startsWith(fragment);
|
||||
}
|
||||
|
||||
/** Complete a path progressively. Discovery is cached by response + parent in the caller. */
|
||||
export function jsonPathCompletion(load: LoadJsonPathChildren) {
|
||||
return async (context: CompletionContext): Promise<CompletionResult | null> => {
|
||||
// Never replace later path segments while the user edits the middle of an expression.
|
||||
if (context.pos !== context.state.doc.length) return null;
|
||||
context.addEventListener("abort", () => {}, { onDocChange: true });
|
||||
const text = context.state.doc.toString();
|
||||
let target = jsonPathCompletionTarget(text);
|
||||
if (!target) return null;
|
||||
let children: JsonPathChildren | null = null;
|
||||
if (context.explicit && text.trim()) {
|
||||
// Accepting a container or explicitly opening completion explores its children, without
|
||||
// inserting an unfinished dot/bracket (the container itself may be the assertion target).
|
||||
const parent = normalizeJsonPath(text);
|
||||
children = await load(parent);
|
||||
if (context.aborted) return null;
|
||||
if (children?.children.length) target = { parent, fragment: "" };
|
||||
else children = null;
|
||||
}
|
||||
children ??= await load(target.parent);
|
||||
if (context.aborted || !children) return null;
|
||||
const parent = target.parent;
|
||||
const options = children.children
|
||||
.filter((child) => matches(child, target.fragment))
|
||||
.filter((child) => `${parent}${child.selector}` !== normalizeJsonPath(text))
|
||||
.map((child) => {
|
||||
const path = `${parent}${child.selector}`;
|
||||
return {
|
||||
label: child.label,
|
||||
displayLabel: child.selector.startsWith('["') ? child.selector : child.label,
|
||||
detail: child.kind,
|
||||
type: child.selector.startsWith("[") ? "variable" : "property",
|
||||
apply(
|
||||
view: Parameters<typeof startCompletion>[0],
|
||||
_completion: unknown,
|
||||
from: number,
|
||||
to: number,
|
||||
) {
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: path },
|
||||
selection: { anchor: from + path.length },
|
||||
userEvent: "input.complete",
|
||||
});
|
||||
if (["object", "array", "all items", "mixed"].includes(child.kind))
|
||||
startCompletion(view);
|
||||
},
|
||||
};
|
||||
});
|
||||
return options.length ? { from: 0, to: context.pos, options, filter: false } : null;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/* oxlint-disable no-template-curly-in-string */
|
||||
import { CompletionContext } from "@codemirror/autocomplete";
|
||||
import { json } from "@codemirror/lang-json";
|
||||
import { EditorState, type Extension, type TransactionSpec } from "@codemirror/state";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { singleLineExtensions } from "../singleLine";
|
||||
import { twigCompletion, type TwigCompletionOption } from "./completion";
|
||||
import { parser } from "./twig";
|
||||
|
||||
const options: TwigCompletionOption[] = [
|
||||
{ name: "someVar", label: "someVar", type: "variable", value: "value", onClick: () => {} },
|
||||
{ name: "uuid.v4", label: "uuid.v4", type: "function", args: [], value: null, onClick: () => {} },
|
||||
];
|
||||
const source = twigCompletion({ options });
|
||||
|
||||
// Both keyboard acceptance and clicking a suggestion invoke Completion.apply.
|
||||
function complete(input: string, label: string, extensions: Extension = []) {
|
||||
const cursor = input.indexOf("|");
|
||||
if (cursor < 0) throw new Error("Missing cursor marker");
|
||||
let state = EditorState.create({
|
||||
doc: input.slice(0, cursor) + input.slice(cursor + 1),
|
||||
selection: { anchor: cursor },
|
||||
extensions,
|
||||
});
|
||||
const result = source(new CompletionContext(state, cursor, false));
|
||||
const completion = result?.options.find((option) => option.label === label);
|
||||
expect(completion).toBeDefined();
|
||||
const view = {
|
||||
get state() {
|
||||
return state;
|
||||
},
|
||||
dispatch(spec: TransactionSpec) {
|
||||
state = state.update(spec).state;
|
||||
},
|
||||
} as EditorView;
|
||||
if (typeof completion?.apply !== "function" || !result) throw new Error("Missing apply");
|
||||
completion.apply(view, completion, result.from, cursor);
|
||||
return state;
|
||||
}
|
||||
|
||||
function expectValidTag(state: EditorState, expected: string) {
|
||||
expect(state.doc.toString()).toBe(expected);
|
||||
const tags: string[] = [];
|
||||
parser.parse(expected).iterate({
|
||||
enter(node) {
|
||||
expect(node.type.isError).toBe(false);
|
||||
if (node.name === "Tag") tags.push(expected.slice(node.from, node.to));
|
||||
},
|
||||
});
|
||||
expect(tags).toHaveLength(1);
|
||||
}
|
||||
|
||||
describe("twig completion", () => {
|
||||
test.each(["${[\nsome|", "${[ \n\n\tsome|", "${[\nsome|\n ]}", "${[\n |"])(
|
||||
"completes across multiline whitespace: %s",
|
||||
(input) => expectValidTag(complete(input, "someVar"), "${[ someVar ]}"),
|
||||
);
|
||||
|
||||
test.each(["${[\nuuid.v|", "${[\n uuid.v|()\n ]}"])(
|
||||
"completes a function across multiline whitespace: %s",
|
||||
(input) => expectValidTag(complete(input, "uuid.v4"), "${[ uuid.v4() ]}"),
|
||||
);
|
||||
|
||||
test("preserves surrounding lines and literal pipes", () => {
|
||||
expect(complete("before\n${[\nsome|\n]}\na | b", "someVar").doc.toString()).toBe(
|
||||
"before\n${[ someVar ]}\na | b",
|
||||
);
|
||||
});
|
||||
|
||||
test("does not search past non-whitespace for an opener or closer", () => {
|
||||
expect(complete("prefix\nsome|\nother ]}", "someVar").doc.toString()).toBe(
|
||||
"prefix\n${[ someVar ]}\nother ]}",
|
||||
);
|
||||
expect(complete("${[some|\nother ]}", "someVar").doc.toString()).toBe(
|
||||
"${[ someVar ]}\nother ]}",
|
||||
);
|
||||
});
|
||||
|
||||
test.each(["${[|", "${[ |", "${[ |"])(
|
||||
"offers completion immediately after a template opener: %s",
|
||||
(input) => {
|
||||
expectValidTag(complete(input, "someVar"), "${[ someVar ]}");
|
||||
},
|
||||
);
|
||||
|
||||
test.each(["$", "{", "${", '{"value": "'])(
|
||||
"does not automatically offer empty-name completions after %s",
|
||||
(doc) => {
|
||||
const state = EditorState.create({ doc });
|
||||
expect(source(new CompletionContext(state, doc.length, false))).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
test.each(["uuid|", "${[uuid|", "${[ uuid| ]}"])(
|
||||
"completes a namespace before wrapping the final function: %s",
|
||||
(input) => {
|
||||
const namespace = complete(input, "uuid.*");
|
||||
expect(namespace.doc.toString()).toBe(input.replace("uuid|", "uuid."));
|
||||
const cursor = namespace.selection.main.head;
|
||||
const next = namespace.sliceDoc(0, cursor) + "|" + namespace.sliceDoc(cursor);
|
||||
expectValidTag(complete(next, "uuid.v4"), "${[ uuid.v4() ]}");
|
||||
},
|
||||
);
|
||||
|
||||
test.each(["some|", "${[some|", "${[ some|", "${[ someVar|", "${[\tsome|"])(
|
||||
"completes a variable in a single-line value: %s",
|
||||
(input) => {
|
||||
const state = complete(input, "someVar", singleLineExtensions());
|
||||
expectValidTag(state, "${[ someVar ]}");
|
||||
expect(state.selection.main.head).toBe(state.doc.length);
|
||||
},
|
||||
);
|
||||
|
||||
test.each(["${[some|]}", "${[ some| ]}", "${[someVar|]}"])(
|
||||
"reuses closing delimiters: %s",
|
||||
(input) => {
|
||||
expectValidTag(complete(input, "someVar"), "${[ someVar ]}");
|
||||
},
|
||||
);
|
||||
|
||||
test.each(["uuid.v|", "${[uuid.v|", "${[ uuid.v| ]}", "${[uuid.v|()]}"])(
|
||||
"completes a function: %s",
|
||||
(input) => {
|
||||
expectValidTag(complete(input, "uuid.v4"), "${[ uuid.v4() ]}");
|
||||
},
|
||||
);
|
||||
|
||||
test("preserves surrounding JSON and its closing punctuation", () => {
|
||||
expectValidTag(
|
||||
complete('{"value":"${[some|","other":1}', "someVar", json()),
|
||||
'{"value":"${[ someVar ]}","other":1}',
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves surrounding text and other complete tags", () => {
|
||||
expect(complete("${[ someVar ]} ${[some|]} trailing", "someVar").doc.toString()).toBe(
|
||||
"${[ someVar ]} ${[ someVar ]} trailing",
|
||||
);
|
||||
});
|
||||
|
||||
test("does not consume closing punctuation without a template opener", () => {
|
||||
expect(complete("[some|]}", "someVar").doc.toString()).toBe("[${[ someVar ]}]}");
|
||||
});
|
||||
|
||||
test("invalidates cached suggestions when typing delimiters or a namespace separator", () => {
|
||||
const state = EditorState.create({ doc: "some" });
|
||||
const result = source(new CompletionContext(state, 4, false));
|
||||
expect(result?.validFor).toBeInstanceOf(RegExp);
|
||||
const validFor = result?.validFor as RegExp;
|
||||
expect(validFor.test("someVar")).toBe(true);
|
||||
expect(validFor.test("some ${[some")).toBe(false);
|
||||
expect(validFor.test("uuid.")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Completion, CompletionContext } from "@codemirror/autocomplete";
|
||||
import { startCompletion } from "@codemirror/autocomplete";
|
||||
import type { EditorState } from "@codemirror/state";
|
||||
import type { TemplateFunction } from "@yaakapp-internal/plugins";
|
||||
|
||||
const openTag = "${[ ";
|
||||
@@ -36,6 +37,22 @@ export interface TwigCompletionConfig {
|
||||
|
||||
const MIN_MATCH_NAME = 1;
|
||||
|
||||
// Only inspect adjacent whitespace, including newlines, rather than copying the
|
||||
// entire document to look for a delimiter in large request bodies.
|
||||
function skipWhitespace(state: EditorState, pos: number, direction: -1 | 1) {
|
||||
while (direction === -1 ? pos > 0 : pos < state.doc.length) {
|
||||
const from = direction === -1 ? pos - 1 : pos;
|
||||
if (!/\s/.test(state.sliceDoc(from, from + 1))) break;
|
||||
pos += direction;
|
||||
}
|
||||
return pos;
|
||||
}
|
||||
|
||||
function findOpenTag(state: EditorState, from: number) {
|
||||
const end = skipWhitespace(state, from, -1);
|
||||
return end >= 3 && state.sliceDoc(end - 3, end) === "${[" ? end - 3 : null;
|
||||
}
|
||||
|
||||
export function twigCompletion({ options }: TwigCompletionConfig) {
|
||||
return function completions(context: CompletionContext) {
|
||||
const toStartOfName = context.matchBefore(/[\w_.]*/);
|
||||
@@ -44,7 +61,8 @@ export function twigCompletion({ options }: TwigCompletionConfig) {
|
||||
if (toMatch === null) return null;
|
||||
|
||||
const matchLen = toMatch.to - toMatch.from;
|
||||
if (!context.explicit && toMatch.from > 0 && matchLen < MIN_MATCH_NAME) {
|
||||
const hasOpenTag = findOpenTag(context.state, toMatch.from) !== null;
|
||||
if (!context.explicit && !hasOpenTag && toMatch.from > 0 && matchLen < MIN_MATCH_NAME) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -83,6 +101,16 @@ export function twigCompletion({ options }: TwigCompletionConfig) {
|
||||
detail: o.type,
|
||||
type: o.type === "variable" ? "variable" : "function",
|
||||
apply: (view, _completion, from, to) => {
|
||||
// Keep the completion range name-only for filtering, but replace a manually
|
||||
// typed opener when accepting (through either the keyboard or the menu).
|
||||
const opener = findOpenTag(view.state, from);
|
||||
if (opener !== null) {
|
||||
from = opener;
|
||||
const hasParentheses =
|
||||
o.type === "function" && view.state.sliceDoc(to, to + 2) === "()";
|
||||
const closer = skipWhitespace(view.state, to + (hasParentheses ? 2 : 0), 1);
|
||||
if (view.state.sliceDoc(closer, closer + 2) === "]}") to = closer + 2;
|
||||
}
|
||||
const insert = openTag + inner + closeTag;
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: insert },
|
||||
@@ -103,7 +131,8 @@ export function twigCompletion({ options }: TwigCompletionConfig) {
|
||||
|
||||
return {
|
||||
matchLen,
|
||||
validFor: () => true, // Not really sure why this is all it needs
|
||||
// Delimiters change the replacement range; dots change namespace options.
|
||||
validFor: /^\w*$/,
|
||||
from: toMatch.from,
|
||||
options: sortedCompletions,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Link as RouterLink } from "@tanstack/react-router";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import classNames from "classnames";
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { appInfo } from "../../lib/appInfo";
|
||||
@@ -33,7 +34,10 @@ export function Link({ href, children, noUnderline, className, ...other }: Props
|
||||
href={finalHref}
|
||||
target="_blank"
|
||||
rel={isYaakLink ? undefined : "noopener noreferrer"}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void platform.openUrl(finalHref);
|
||||
}}
|
||||
className={className}
|
||||
{...other}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Icon, type IconProps } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { forwardRef, type ReactNode } from "react";
|
||||
import { Dropdown, type DropdownItem } from "./Dropdown";
|
||||
import { IconButton } from "./IconButton";
|
||||
|
||||
export interface PathListItem {
|
||||
path: string;
|
||||
icon: IconProps["icon"];
|
||||
/** Replaces the parent-path line under the name */
|
||||
subtitle?: ReactNode;
|
||||
failed?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: PathListItem[];
|
||||
disabled?: boolean;
|
||||
onRemove: (path: string) => void;
|
||||
addItems: DropdownItem[];
|
||||
addLabel: string;
|
||||
addHint?: string;
|
||||
hovering?: boolean;
|
||||
/** Rows rendered between the items and the add row, such as an inline input */
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/** Selected files, folders, or URLs as cards, with a dashed add row that opens a menu. */
|
||||
export const PathList = forwardRef<HTMLDivElement, Props>(function PathList(
|
||||
{ items, disabled, onRemove, addItems, addLabel, addHint, hovering, children },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
"flex flex-col gap-1.5 rounded-lg -m-1.5 p-1.5",
|
||||
hovering && "bg-surface-highlight",
|
||||
)}
|
||||
>
|
||||
{items.map((item) => {
|
||||
const { name, parent } = splitPath(item.path);
|
||||
return (
|
||||
<div
|
||||
key={item.path}
|
||||
className={classNames(
|
||||
"flex items-center gap-3 rounded-md pl-3 pr-1.5 py-2",
|
||||
item.failed ? "bg-danger/10" : "bg-surface-highlight/50",
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
icon={item.icon}
|
||||
className={classNames("shrink-0", item.failed ? "text-danger" : "text-text-subtle")}
|
||||
/>
|
||||
<div className="min-w-0 flex-1" title={item.path}>
|
||||
<div className="truncate text-sm font-semibold">{name}</div>
|
||||
<div
|
||||
className={classNames(
|
||||
"flex items-center gap-1.5 truncate text-xs",
|
||||
item.failed ? "text-danger" : "text-text-subtlest",
|
||||
)}
|
||||
>
|
||||
{item.subtitle ??
|
||||
(parent !== "" && <span className="truncate font-mono">{parent}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
<IconButton
|
||||
size="xs"
|
||||
icon="trash"
|
||||
iconColor="secondary"
|
||||
title={`Remove ${name}`}
|
||||
disabled={disabled}
|
||||
className="shrink-0"
|
||||
onClick={() => onRemove(item.path)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{children}
|
||||
<Dropdown items={addItems}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className={classNames(
|
||||
"h-sm w-full flex items-center gap-2 rounded-md border border-dashed px-3 text-sm text-text-subtle",
|
||||
"hocus:border-border-focus hocus:text-text disabled:opacity-disabled",
|
||||
hovering ? "border-notice" : "border-border",
|
||||
)}
|
||||
>
|
||||
<Icon icon="plus" size="sm" className="text-text-subtlest" />
|
||||
{hovering ? (
|
||||
"Drop to add"
|
||||
) : (
|
||||
<>
|
||||
<span>{addLabel}</span>
|
||||
{addHint != null && (
|
||||
<span className="ml-auto text-xs text-text-subtlest">{addHint}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/** The last segment as the name and everything before it, shortened, as the parent. */
|
||||
export function splitPath(path: string): { name: string; parent: string } {
|
||||
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(path)) {
|
||||
try {
|
||||
const url = new URL(path);
|
||||
const segments = url.pathname.split("/").filter(Boolean);
|
||||
const name = segments.at(-1) ?? url.host;
|
||||
const parent = segments.length > 0 ? `${url.host}/${segments.slice(0, -1).join("/")}` : "";
|
||||
return { name, parent: parent.replace(/\/$/, "") };
|
||||
} catch {
|
||||
return { name: path, parent: "" };
|
||||
}
|
||||
}
|
||||
const parts = path.split(/[/\\]/).filter(Boolean);
|
||||
const name = parts.at(-1) ?? path;
|
||||
const dirs = parts.slice(0, -1);
|
||||
const parent = dirs.length > 3 ? `…/${dirs.slice(-3).join("/")}` : dirs.join("/");
|
||||
return { name, parent };
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { FormInput, JsonPrimitive } from "@yaakapp-internal/plugins";
|
||||
import { HStack } from "@yaakapp-internal/ui";
|
||||
import type { FormEvent } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { generateId } from "../../lib/generateId";
|
||||
import { DynamicForm } from "../DynamicForm";
|
||||
import { Button } from "./Button";
|
||||
import { DialogFooter } from "./Dialog";
|
||||
|
||||
export interface PromptProps {
|
||||
inputs: FormInput[];
|
||||
@@ -48,19 +47,15 @@ export function Prompt({
|
||||
const id = `prompt.form.${useRef(generateId()).current}`;
|
||||
|
||||
return (
|
||||
<form
|
||||
className="grid grid-rows-[auto_auto] grid-cols-[minmax(0,1fr)] gap-4 mb-4"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<form id={id} className="grid grid-cols-[minmax(0,1fr)] mb-2" onSubmit={handleSubmit}>
|
||||
<DynamicForm inputs={inputs} onChange={setValue} data={value} stateKey={id} />
|
||||
<HStack space={2} justifyContent="end">
|
||||
<Button onClick={onCancel} variant="border" color="secondary">
|
||||
{cancelText || "Cancel"}
|
||||
</Button>
|
||||
<Button type="submit" color="primary">
|
||||
{confirmText || "Done"}
|
||||
</Button>
|
||||
</HStack>
|
||||
<DialogFooter
|
||||
inline
|
||||
actions={[
|
||||
{ label: cancelText || "Cancel", onClick: onCancel },
|
||||
{ label: confirmText || "Done", color: "primary", form: id },
|
||||
]}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import classNames from "classnames";
|
||||
import type { ReactNode, Ref } from "react";
|
||||
import type { CSSProperties, ReactNode, Ref } from "react";
|
||||
import {
|
||||
forwardRef,
|
||||
memo,
|
||||
@@ -27,11 +27,19 @@ import { fireAndForget } from "../../../lib/fireAndForget";
|
||||
import { ErrorBoundary } from "../../ErrorBoundary";
|
||||
import type { ButtonProps } from "../Button";
|
||||
import { Button } from "../Button";
|
||||
import { Dropdown } from "../Dropdown";
|
||||
import { IconButton } from "../IconButton";
|
||||
import { Icon } from "@yaakapp-internal/ui";
|
||||
import type { RadioDropdownProps } from "../RadioDropdown";
|
||||
import { RadioDropdown } from "../RadioDropdown";
|
||||
import { useFittingTabs } from "./useFittingTabs";
|
||||
import { MIN_TAB_GAP_SCALE, MIN_TAB_PADDING_SCALE } from "./fitTabs";
|
||||
|
||||
export type TabItem =
|
||||
export type TabItem = {
|
||||
/** Stable label for the visibility menu, including tabs with dropdown labels. */
|
||||
menuLabel?: string;
|
||||
hiddenByDefault?: boolean;
|
||||
} & (
|
||||
| {
|
||||
value: string;
|
||||
label: string;
|
||||
@@ -44,7 +52,8 @@ export type TabItem =
|
||||
options: Omit<RadioDropdownProps, "children">;
|
||||
leftSlot?: ReactNode;
|
||||
rightSlot?: ReactNode;
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
interface TabsStorage {
|
||||
order: string[];
|
||||
@@ -72,6 +81,8 @@ interface Props {
|
||||
storageKey?: string | string[];
|
||||
/** Key to identify which context this tab belongs to (e.g., request ID). Used for per-context active tab persistence. */
|
||||
activeTabKey?: string;
|
||||
/** Enables show/hide controls, scoped independently of tab order and selection. */
|
||||
visibilityStorageKey?: string | string[];
|
||||
}
|
||||
|
||||
export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
|
||||
@@ -87,6 +98,7 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
|
||||
layout = "vertical",
|
||||
storageKey,
|
||||
activeTabKey,
|
||||
visibilityStorageKey,
|
||||
}: Props,
|
||||
forwardedRef: Ref<TabsRef>,
|
||||
) {
|
||||
@@ -108,10 +120,33 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
|
||||
|
||||
const savedOrder = storage.order;
|
||||
|
||||
const visibilityKey = visibilityStorageKey ?? ["tabs", "default_visibility"];
|
||||
const { value: visibility, set: setVisibility } = useKeyValue<Record<string, boolean>>({
|
||||
namespace: "no_sync",
|
||||
key: visibilityKey,
|
||||
fallback: {},
|
||||
});
|
||||
const { value: wrapPreference, set: setWrapPreference } = useKeyValue<boolean>({
|
||||
namespace: "no_sync",
|
||||
key: [...(Array.isArray(visibilityKey) ? visibilityKey : [visibilityKey]), "wrap"],
|
||||
fallback: false,
|
||||
});
|
||||
const isVisible = useCallback(
|
||||
(tab: TabItem) =>
|
||||
!("hidden" in tab && tab.hidden) &&
|
||||
(!visibilityStorageKey || (visibility?.[tab.value] ?? !tab.hiddenByDefault)),
|
||||
[visibility, visibilityStorageKey],
|
||||
);
|
||||
|
||||
// Get the active tab value - prefer storage (if activeTabKey), then defaultValue, then first tab
|
||||
const storedActiveTab = activeTabKey ? storage?.activeTabs?.[activeTabKey] : undefined;
|
||||
const [internalValue, setInternalValue] = useState<string | undefined>(undefined);
|
||||
const value = storedActiveTab ?? internalValue ?? defaultValue ?? originalTabs[0]?.value;
|
||||
const requestedValue = storedActiveTab ?? internalValue ?? defaultValue ?? originalTabs[0]?.value;
|
||||
// Dynamic tabs can disappear when switching responses or other contexts.
|
||||
// Keep the saved preference, but display an available tab until it returns.
|
||||
const value = originalTabs.some((t) => t.value === requestedValue && isVisible(t))
|
||||
? requestedValue
|
||||
: originalTabs.find(isVisible)?.value;
|
||||
|
||||
// Helper to normalize storage (handle migration from old format)
|
||||
const normalizeStorage = useCallback(
|
||||
@@ -123,6 +158,9 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
|
||||
// Handle tab change - update internal state, storage if we have a key, and call prop callback
|
||||
const onChangeValue = useCallback(
|
||||
async (newValue: string) => {
|
||||
if (visibilityStorageKey) {
|
||||
await setVisibility((s) => ({ ...s, [newValue]: true }));
|
||||
}
|
||||
setInternalValue(newValue);
|
||||
if (storageKey && activeTabKey) {
|
||||
await setStorage((s) => {
|
||||
@@ -135,7 +173,15 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
|
||||
}
|
||||
onChangeValueProp?.(newValue);
|
||||
},
|
||||
[storageKey, activeTabKey, setStorage, onChangeValueProp, normalizeStorage],
|
||||
[
|
||||
storageKey,
|
||||
activeTabKey,
|
||||
setStorage,
|
||||
onChangeValueProp,
|
||||
normalizeStorage,
|
||||
visibilityStorageKey,
|
||||
setVisibility,
|
||||
],
|
||||
);
|
||||
|
||||
// Expose imperative methods via ref
|
||||
@@ -199,6 +245,18 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
|
||||
}, [originalTabs, savedOrder, storageKey]);
|
||||
|
||||
const tabs = storageKey ? orderedTabs : originalTabs;
|
||||
const responsive = !!visibilityStorageKey && layout === "vertical";
|
||||
const wrapTabs = responsive && !!wrapPreference;
|
||||
const { viewportRef, measurementsRef, fittedTabs } = useFittingTabs(
|
||||
responsive && !wrapTabs,
|
||||
value,
|
||||
);
|
||||
const fitsInStrip = useCallback(
|
||||
(tab: TabItem) =>
|
||||
isVisible(tab) && (fittedTabs == null || fittedTabs.some((t) => t.value === tab.value)),
|
||||
[isVisible, fittedTabs],
|
||||
);
|
||||
const overflowTabs = tabs.filter((tab) => isVisible(tab) && !fitsInStrip(tab));
|
||||
|
||||
// Update tabs when value changes
|
||||
useEffect(() => {
|
||||
@@ -290,69 +348,168 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
|
||||
const tabButtons = useMemo(() => {
|
||||
const items: ReactNode[] = [];
|
||||
tabs.forEach((t, i) => {
|
||||
if ("hidden" in t && t.hidden) {
|
||||
if (!fitsInStrip(t)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isActive = t.value === value;
|
||||
const showDropMarkerBefore = hoveredIndex === i;
|
||||
|
||||
if (showDropMarkerBefore) {
|
||||
items.push(
|
||||
<div
|
||||
key={`marker-${t.value}`}
|
||||
className={classNames("relative", layout === "vertical" ? "w-0" : "h-0")}
|
||||
>
|
||||
<DropMarker orientation={layout === "vertical" ? "vertical" : "horizontal"} />
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
const next = tabs[i + 1];
|
||||
const fitted = fittedTabs?.find((tab) => tab.value === t.value);
|
||||
items.push(
|
||||
<TabButton
|
||||
key={t.value}
|
||||
tab={t}
|
||||
isActive={isActive}
|
||||
isActive={t.value === value}
|
||||
addBorders={addBorders}
|
||||
layout={layout}
|
||||
reorderable={reorderable}
|
||||
isDragging={isDragging?.value === t.value}
|
||||
onChangeValue={onChangeValue}
|
||||
width={fitted?.width}
|
||||
spacingCompression={fitted?.spacingCompression}
|
||||
wrap={wrapTabs}
|
||||
dropBefore={hoveredIndex === i}
|
||||
// A drop after the last fitted tab still inserts into the full saved order.
|
||||
dropAfter={hoveredIndex === i + 1 && (!next || !fitsInStrip(next))}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
return items;
|
||||
}, [tabs, value, addBorders, layout, reorderable, isDragging, onChangeValue, hoveredIndex]);
|
||||
}, [
|
||||
tabs,
|
||||
value,
|
||||
addBorders,
|
||||
layout,
|
||||
reorderable,
|
||||
isDragging,
|
||||
onChangeValue,
|
||||
hoveredIndex,
|
||||
fitsInStrip,
|
||||
fittedTabs,
|
||||
wrapTabs,
|
||||
]);
|
||||
|
||||
const tabList = (
|
||||
const tabStrip = (
|
||||
<div
|
||||
ref={viewportRef}
|
||||
role="tablist"
|
||||
aria-label={label}
|
||||
className={classNames(
|
||||
tabListClassName,
|
||||
!visibilityStorageKey && tabListClassName,
|
||||
addBorders && layout === "horizontal" && "pl-3 -ml-1",
|
||||
addBorders && layout === "vertical" && "ml-0 mb-2",
|
||||
"flex items-center hide-scrollbars",
|
||||
layout === "horizontal" && "h-full overflow-auto p-2",
|
||||
layout === "vertical" && "overflow-x-auto overflow-y-visible ",
|
||||
layout === "vertical" &&
|
||||
(responsive ? "relative overflow-hidden py-1" : "overflow-x-auto overflow-y-visible"),
|
||||
// Give space for button focus states within overflow boundary.
|
||||
!addBorders && layout === "vertical" && "py-1 pl-3 -ml-5 pr-1",
|
||||
!responsive && !addBorders && layout === "vertical" && "py-1 pl-3 -ml-5 pr-1",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
layout === "horizontal" && "flex flex-col w-full pb-3 mb-auto",
|
||||
layout === "vertical" && "flex flex-row shrink-0 w-full",
|
||||
wrapTabs && "flex-wrap gap-y-1",
|
||||
)}
|
||||
>
|
||||
{tabButtons}
|
||||
{hoveredIndex === tabs.length && (
|
||||
<div className={classNames("relative", layout === "vertical" ? "w-0" : "h-0")}>
|
||||
<DropMarker orientation={layout === "vertical" ? "vertical" : "horizontal"} />
|
||||
</div>
|
||||
</div>
|
||||
{responsive && !wrapTabs && (
|
||||
<div
|
||||
ref={measurementsRef}
|
||||
aria-hidden
|
||||
inert
|
||||
className="absolute invisible pointer-events-none flex w-max"
|
||||
>
|
||||
{tabs.filter(isVisible).map((tab) => (
|
||||
<div key={tab.value} data-measure-tab={tab.value} className="flex shrink-0">
|
||||
<TabButton
|
||||
tab={tab}
|
||||
isActive={tab.value === value}
|
||||
addBorders={addBorders}
|
||||
layout={layout}
|
||||
reorderable={false}
|
||||
isDragging={false}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Keep the menu outside the scroll viewport so tabs cannot paint behind or past it.
|
||||
const tabList = visibilityStorageKey ? (
|
||||
<div
|
||||
className={classNames(
|
||||
tabListClassName,
|
||||
"flex shrink-0 min-w-0 min-h-0",
|
||||
layout === "vertical" ? "items-start" : "flex-col",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 min-h-0 flex-1">{tabStrip}</div>
|
||||
<div
|
||||
className={classNames(
|
||||
"shrink-0 pl-1",
|
||||
layout === "vertical" && "flex items-center h-md my-1",
|
||||
)}
|
||||
>
|
||||
<Dropdown
|
||||
items={[
|
||||
...(overflowTabs.length > 0
|
||||
? [
|
||||
{ type: "separator" as const, label: "More tabs" },
|
||||
...overflowTabs.map((tab) => ({
|
||||
label: tab.menuLabel ?? ("label" in tab ? tab.label : tab.value),
|
||||
icon: tab.value === value ? ("check" as const) : ("empty" as const),
|
||||
rightSlot: tab.rightSlot,
|
||||
onSelect: () => onChangeValue(tab.value),
|
||||
})),
|
||||
{ type: "separator" as const },
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: "Show tabs",
|
||||
submenu: [
|
||||
{ type: "separator", label: "In this workspace" },
|
||||
...tabs
|
||||
.filter((t) => !("hidden" in t && t.hidden))
|
||||
.map((tab) => ({
|
||||
label: tab.menuLabel ?? ("label" in tab ? tab.label : tab.value),
|
||||
icon: isVisible(tab)
|
||||
? ("check_square_checked" as const)
|
||||
: ("check_square_unchecked" as const),
|
||||
disabled: isVisible(tab) && tabs.filter(isVisible).length <= 1,
|
||||
keepOpenOnSelect: true,
|
||||
onSelect: async () => {
|
||||
if (!isVisible(tab)) {
|
||||
await onChangeValue(tab.value);
|
||||
} else {
|
||||
if (tab.value === value) {
|
||||
const next = tabs.find((t) => t.value !== tab.value && isVisible(t));
|
||||
if (next) await onChangeValue(next.value);
|
||||
}
|
||||
await setVisibility((s) => ({ ...s, [tab.value]: false }));
|
||||
}
|
||||
},
|
||||
})),
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Wrap tabs",
|
||||
hidden: layout !== "vertical",
|
||||
icon: wrapTabs ? "check_square_checked" : "check_square_unchecked",
|
||||
keepOpenOnSelect: true,
|
||||
onSelect: () => setWrapPreference((wrap) => !wrap),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<IconButton icon="ellipsis" title="More tabs and visibility" size="sm" />
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
tabStrip
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -361,9 +518,13 @@ export const Tabs = forwardRef<TabsRef, Props>(function Tabs(
|
||||
className={classNames(
|
||||
className,
|
||||
"tabs-container",
|
||||
"h-full grid",
|
||||
"h-full",
|
||||
// Size wrapped rows at the resolved pane width instead of using an intrinsic grid track.
|
||||
responsive
|
||||
? "flex flex-col min-h-0 [&>.tab-content]:flex-1 [&>.tab-content]:min-h-0"
|
||||
: "grid",
|
||||
layout === "horizontal" && "grid-rows-1 grid-cols-[auto_minmax(0,1fr)]",
|
||||
layout === "vertical" && "grid-rows-[auto_minmax(0,1fr)] grid-cols-1",
|
||||
!responsive && layout === "vertical" && "grid-rows-[auto_minmax(0,1fr)] grid-cols-1",
|
||||
)}
|
||||
>
|
||||
{reorderable ? (
|
||||
@@ -409,6 +570,11 @@ interface TabButtonProps {
|
||||
isDragging: boolean;
|
||||
onChangeValue?: (value: string) => void;
|
||||
overlay?: boolean;
|
||||
width?: number;
|
||||
spacingCompression?: number;
|
||||
wrap?: boolean;
|
||||
dropBefore?: boolean;
|
||||
dropAfter?: boolean;
|
||||
}
|
||||
|
||||
function TabButton({
|
||||
@@ -420,35 +586,26 @@ function TabButton({
|
||||
isDragging,
|
||||
onChangeValue,
|
||||
overlay = false,
|
||||
width,
|
||||
spacingCompression = 0,
|
||||
wrap = false,
|
||||
dropBefore = false,
|
||||
dropAfter = false,
|
||||
}: TabButtonProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef: setDraggableRef,
|
||||
} = useDraggable({
|
||||
id: tab.value,
|
||||
disabled: !reorderable,
|
||||
// The button inside handles focus
|
||||
attributes: { tabIndex: -1 },
|
||||
});
|
||||
const { setNodeRef: setDroppableRef } = useDroppable({
|
||||
id: tab.value,
|
||||
disabled: !reorderable,
|
||||
});
|
||||
|
||||
const handleSetWrapperRef = useCallback(
|
||||
(n: HTMLDivElement | null) => {
|
||||
if (reorderable) {
|
||||
setDraggableRef(n);
|
||||
setDroppableRef(n);
|
||||
}
|
||||
},
|
||||
[reorderable, setDraggableRef, setDroppableRef],
|
||||
);
|
||||
|
||||
const constrained = width !== undefined;
|
||||
const btnProps: Partial<ButtonProps> = {
|
||||
color: "custom",
|
||||
justify: layout === "horizontal" ? "start" : "center",
|
||||
innerClassName: classNames(
|
||||
"tab-button-label",
|
||||
constrained && "w-auto! shrink-0 overflow-visible! text-clip!",
|
||||
wrap && "whitespace-normal! overflow-visible! text-clip! [overflow-wrap:anywhere]",
|
||||
),
|
||||
style: constrained
|
||||
? {
|
||||
paddingInline: `calc(var(--spacing) * ${2 * (1 - spacingCompression * (1 - MIN_TAB_PADDING_SCALE))})`,
|
||||
}
|
||||
: undefined,
|
||||
onClick: isActive
|
||||
? undefined
|
||||
: (e: React.MouseEvent) => {
|
||||
@@ -457,7 +614,8 @@ function TabButton({
|
||||
},
|
||||
className: classNames(
|
||||
"flex items-center rounded-sm whitespace-nowrap",
|
||||
"px-2! ml-px",
|
||||
"ml-px",
|
||||
!constrained && "px-2!",
|
||||
"outline-hidden",
|
||||
"ring-none",
|
||||
"focus-visible-or-class:outline-2",
|
||||
@@ -471,6 +629,9 @@ function TabButton({
|
||||
layout === "horizontal" && "min-w-40",
|
||||
isDragging && "opacity-50",
|
||||
overlay && "opacity-80",
|
||||
constrained && "w-[calc(100%_-_1px)]",
|
||||
wrap && "max-w-[calc(100%_-_1px)]! h-auto! min-h-md",
|
||||
(constrained || wrap) && "[&>div:not(.tab-button-label)]:shrink-0",
|
||||
),
|
||||
};
|
||||
|
||||
@@ -487,6 +648,9 @@ function TabButton({
|
||||
onChange={tab.options.onChange}
|
||||
>
|
||||
<Button
|
||||
title={
|
||||
(constrained || wrap) && typeof option?.label === "string" ? option.label : undefined
|
||||
}
|
||||
leftSlot={tab.leftSlot}
|
||||
rightSlot={
|
||||
<div className="flex items-center">
|
||||
@@ -511,22 +675,91 @@ function TabButton({
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button leftSlot={tab.leftSlot} rightSlot={tab.rightSlot} {...btnProps}>
|
||||
<Button
|
||||
title={(constrained || wrap) && "label" in tab ? tab.label : undefined}
|
||||
leftSlot={tab.leftSlot}
|
||||
rightSlot={tab.rightSlot}
|
||||
{...btnProps}
|
||||
>
|
||||
{"label" in tab && tab.label ? tab.label : tab.value}
|
||||
</Button>
|
||||
);
|
||||
})();
|
||||
|
||||
// Apply drag handlers to wrapper, not button
|
||||
const wrapperProps = reorderable && !overlay ? { ...attributes, ...listeners } : {};
|
||||
const wrapperClassName = classNames(
|
||||
"relative shrink-0",
|
||||
layout === "vertical" && (constrained ? "min-w-0" : "mr-2"),
|
||||
wrap && "min-w-0 max-w-[calc(100%_-_0.5rem)]",
|
||||
);
|
||||
const wrapperStyle: CSSProperties = {
|
||||
width,
|
||||
paddingRight: constrained
|
||||
? `calc(var(--spacing) * ${2 * (1 - spacingCompression * (1 - MIN_TAB_GAP_SCALE))})`
|
||||
: undefined,
|
||||
};
|
||||
// Anchor markers to their tabs so they follow the tab when it wraps onto another row.
|
||||
const content = (
|
||||
<>
|
||||
{dropBefore && (
|
||||
<DropMarker
|
||||
key="before"
|
||||
orientation={layout === "vertical" ? "vertical" : "horizontal"}
|
||||
className={layout === "vertical" ? "left-0" : "top-0"}
|
||||
/>
|
||||
)}
|
||||
{buttonContent}
|
||||
{dropAfter && (
|
||||
<DropMarker
|
||||
key="after"
|
||||
orientation={layout === "vertical" ? "vertical" : "horizontal"}
|
||||
className={layout === "vertical" ? "right-0" : "bottom-0"}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return reorderable && !overlay ? (
|
||||
<DraggableTab value={tab.value} className={wrapperClassName} style={wrapperStyle}>
|
||||
{content}
|
||||
</DraggableTab>
|
||||
) : (
|
||||
<div className={wrapperClassName} style={wrapperStyle}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Measurement copies and drag overlays must not register duplicate draggable IDs.
|
||||
function DraggableTab({
|
||||
value,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
}: {
|
||||
value: string;
|
||||
className: string;
|
||||
style?: CSSProperties;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef: setDraggableRef,
|
||||
} = useDraggable({
|
||||
id: value,
|
||||
attributes: { tabIndex: -1 },
|
||||
});
|
||||
const { setNodeRef: setDroppableRef } = useDroppable({ id: value });
|
||||
const setRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
setDraggableRef(node);
|
||||
setDroppableRef(node);
|
||||
},
|
||||
[setDraggableRef, setDroppableRef],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={handleSetWrapperRef}
|
||||
className={classNames("relative", layout === "vertical" && "mr-2")}
|
||||
{...wrapperProps}
|
||||
>
|
||||
{buttonContent}
|
||||
<div ref={setRef} className={className} style={style} {...attributes} {...listeners}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { fitTabs } from "./fitTabs";
|
||||
|
||||
const tabs = [
|
||||
{ value: "body", width: 80, compactWidth: 66 },
|
||||
{ value: "headers", width: 100, compactWidth: 86 },
|
||||
{ value: "info", width: 60, compactWidth: 46 },
|
||||
{ value: "assertions", width: 120, compactWidth: 106 },
|
||||
];
|
||||
|
||||
describe("responsive request tabs", () => {
|
||||
test("restores the saved order when the pane has room for every tab", () => {
|
||||
expect(fitTabs(tabs, 500, "assertions")).toEqual(
|
||||
tabs.map(({ value, width }) => ({ value, width, spacingCompression: 0 })),
|
||||
);
|
||||
});
|
||||
|
||||
test("tightens gaps and padding without taking any space from labels", () => {
|
||||
expect(fitTabs(tabs, 332, "assertions")).toEqual([
|
||||
{ value: "body", width: 73, spacingCompression: 0.5 },
|
||||
{ value: "headers", width: 93, spacingCompression: 0.5 },
|
||||
{ value: "info", width: 53, spacingCompression: 0.5 },
|
||||
{ value: "assertions", width: 113, spacingCompression: 0.5 },
|
||||
]);
|
||||
expect(fitTabs(tabs, 304, "assertions")).toEqual(
|
||||
tabs.map(({ value, compactWidth }) => ({
|
||||
value,
|
||||
width: compactWidth,
|
||||
spacingCompression: 1,
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
test("moves whole tabs into overflow when compact spacing is not enough", () => {
|
||||
expect(fitTabs(tabs, 258, "assertions")).toEqual([
|
||||
{ value: "body", width: 66, spacingCompression: 1 },
|
||||
{ value: "headers", width: 86, spacingCompression: 1 },
|
||||
{ value: "assertions", width: 106, spacingCompression: 1 },
|
||||
]);
|
||||
expect(fitTabs(tabs, 257, "assertions").map((tab) => tab.value)).toEqual([
|
||||
"body",
|
||||
"assertions",
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps an active tab from the end visible without reordering it", () => {
|
||||
expect(fitTabs(tabs, 172, "assertions")).toEqual([
|
||||
{ value: "body", width: 66, spacingCompression: 1 },
|
||||
{ value: "assertions", width: 106, spacingCompression: 1 },
|
||||
]);
|
||||
expect(fitTabs(tabs, 170, "headers").map((tab) => tab.value)).toEqual(["body", "headers"]);
|
||||
});
|
||||
|
||||
test("puts even the active tab into overflow if it cannot fit on its own", () => {
|
||||
expect(fitTabs(tabs, 40, "assertions")).toEqual([]);
|
||||
expect(fitTabs(tabs, 0, "assertions")).toEqual([]);
|
||||
expect(fitTabs(tabs, 90, "assertions")).toEqual([
|
||||
{ value: "body", width: 80, spacingCompression: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("recalculates after a dropdown label or badge changes width", () => {
|
||||
const longLabel = [{ value: "body", width: 200, compactWidth: 186 }, ...tabs.slice(1)];
|
||||
const result = fitTabs(longLabel, 300, "body");
|
||||
expect(result.map((tab) => tab.value)).toEqual(["body", "headers"]);
|
||||
expect(result.reduce((sum, tab) => sum + tab.width, 0)).toBeCloseTo(300);
|
||||
|
||||
const largeBadge = [{ value: "body", width: 240, compactWidth: 226 }, ...tabs.slice(1)];
|
||||
expect(fitTabs(largeBadge, 280, "body").map((tab) => tab.value)).toEqual(["body"]);
|
||||
});
|
||||
|
||||
test("does not skip a long tab to squeeze later tabs into the leading group", () => {
|
||||
expect(fitTabs(tabs, 200, "assertions").map((tab) => tab.value)).toEqual([
|
||||
"body",
|
||||
"assertions",
|
||||
]);
|
||||
expect(fitTabs(tabs, 160).map((tab) => tab.value)).toEqual(["body", "headers"]);
|
||||
});
|
||||
|
||||
test("handles tabs with no spacing to compress", () => {
|
||||
const shortTabs = tabs.filter((tab) => tab.value === "body" || tab.value === "info");
|
||||
expect(fitTabs(shortTabs, 126, "body")).toEqual([
|
||||
{ value: "body", width: 73, spacingCompression: 0.5 },
|
||||
{ value: "info", width: 53, spacingCompression: 0.5 },
|
||||
]);
|
||||
const fixedTabs = shortTabs.map((tab) => ({ ...tab, compactWidth: tab.width }));
|
||||
expect(fitTabs(fixedTabs, 126, "body")).toEqual([
|
||||
{ value: "body", width: 80, spacingCompression: 0 },
|
||||
]);
|
||||
expect(fitTabs(fixedTabs, 40, "body")).toEqual([]);
|
||||
});
|
||||
|
||||
test("stays within the pane and each label's bounds throughout resizing", () => {
|
||||
for (let width = 0; width <= 500; width++) {
|
||||
for (const active of tabs) {
|
||||
const result = fitTabs(tabs, width, active.value);
|
||||
expect(result.some((tab) => tab.value === active.value)).toBe(width >= active.compactWidth);
|
||||
expect(result.reduce((sum, tab) => sum + tab.width, 0)).toBeLessThanOrEqual(width + 0.001);
|
||||
for (const tab of result) {
|
||||
const original = tabs.find((t) => t.value === tab.value)!;
|
||||
expect(tab.width).toBeGreaterThanOrEqual(original.compactWidth);
|
||||
expect(tab.width).toBeLessThanOrEqual(original.width);
|
||||
expect(tab.spacingCompression).toBeGreaterThanOrEqual(0);
|
||||
expect(tab.spacingCompression).toBeLessThanOrEqual(1);
|
||||
// All removed width must come from spacing, at every pane size.
|
||||
expect(original.width - tab.width).toBeCloseTo(
|
||||
tab.spacingCompression * (original.width - original.compactWidth),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
export const MIN_TAB_PADDING_SCALE = 0.5;
|
||||
export const MIN_TAB_GAP_SCALE = 0.25;
|
||||
|
||||
export interface TabWidth {
|
||||
value: string;
|
||||
width: number;
|
||||
/** Full label and badges, with the smallest padding and gap. */
|
||||
compactWidth: number;
|
||||
}
|
||||
|
||||
/** Tighten spacing to fit whole tabs, keeping every visible label intact. */
|
||||
export function fitTabs(
|
||||
tabs: TabWidth[],
|
||||
availableWidth: number,
|
||||
activeValue?: string,
|
||||
): { value: string; width: number; spacingCompression: number }[] {
|
||||
const available = Math.max(0, availableWidth);
|
||||
// Even the active tab goes into overflow if it cannot fit on its own.
|
||||
const active = tabs.find((tab) => tab.value === activeValue && tab.compactWidth <= available);
|
||||
let remaining = available - (active?.compactWidth ?? 0);
|
||||
const fitted = new Set(active ? [active.value] : []);
|
||||
for (const tab of tabs) {
|
||||
if (tab === active) continue;
|
||||
if (tab.compactWidth > remaining) break;
|
||||
fitted.add(tab.value);
|
||||
remaining -= tab.compactWidth;
|
||||
}
|
||||
const visible = tabs.filter((tab) => fitted.has(tab.value));
|
||||
const naturalWidth = visible.reduce((sum, tab) => sum + tab.width, 0);
|
||||
const deficit = Math.max(0, naturalWidth - available);
|
||||
const spareSpacing = visible.reduce((sum, tab) => sum + tab.width - tab.compactWidth, 0);
|
||||
const spacingCompression = spareSpacing > 0 ? Math.min(1, deficit / spareSpacing) : 0;
|
||||
return visible.map((tab) => ({
|
||||
value: tab.value,
|
||||
spacingCompression,
|
||||
width: tab.width - spacingCompression * (tab.width - tab.compactWidth),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { fitTabs, MIN_TAB_GAP_SCALE, MIN_TAB_PADDING_SCALE } from "./fitTabs";
|
||||
|
||||
export function useFittingTabs(enabled: boolean, activeValue?: string) {
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const measurementsRef = useRef<HTMLDivElement>(null);
|
||||
const [fittedTabs, setFittedTabs] = useState<ReturnType<typeof fitTabs> | null>(null);
|
||||
|
||||
// Reobserve after render: tab labels, badges and available tabs can all change.
|
||||
useLayoutEffect(() => {
|
||||
const viewport = viewportRef.current;
|
||||
const measurements = measurementsRef.current;
|
||||
if (!enabled || !viewport || !measurements) return;
|
||||
|
||||
const measure = () => {
|
||||
const widths = Array.from(measurements.children, (child) => {
|
||||
const width = child.getBoundingClientRect().width;
|
||||
const button = child.querySelector("button");
|
||||
const buttonStyle = button ? getComputedStyle(button) : null;
|
||||
const padding = buttonStyle
|
||||
? Number.parseFloat(buttonStyle.paddingLeft) + Number.parseFloat(buttonStyle.paddingRight)
|
||||
: 0;
|
||||
const gap = child.firstElementChild
|
||||
? Number.parseFloat(getComputedStyle(child.firstElementChild).marginRight)
|
||||
: 0;
|
||||
const compactWidth =
|
||||
width - padding * (1 - MIN_TAB_PADDING_SCALE) - gap * (1 - MIN_TAB_GAP_SCALE);
|
||||
return {
|
||||
value: child.getAttribute("data-measure-tab") ?? "",
|
||||
width,
|
||||
compactWidth,
|
||||
};
|
||||
});
|
||||
const next = fitTabs(widths, viewport.clientWidth, activeValue);
|
||||
setFittedTabs((previous) =>
|
||||
previous?.length === next.length &&
|
||||
previous.every(
|
||||
(tab, i) =>
|
||||
tab.value === next[i]?.value &&
|
||||
tab.width === next[i]?.width &&
|
||||
tab.spacingCompression === next[i]?.spacingCompression,
|
||||
)
|
||||
? previous
|
||||
: next,
|
||||
);
|
||||
};
|
||||
measure();
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(viewport);
|
||||
for (const child of measurements.children) observer.observe(child);
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
return { viewportRef, measurementsRef, fittedTabs: enabled ? fittedTabs : null };
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { HStack, VStack } from "@yaakapp-internal/ui";
|
||||
import { useState } from "react";
|
||||
import { Button } from "../core/Button";
|
||||
import { Select } from "../core/Select";
|
||||
|
||||
interface Props {
|
||||
branches: string[];
|
||||
onCancel: () => void;
|
||||
onSelect: (branch: string) => void;
|
||||
selectText: string;
|
||||
}
|
||||
|
||||
export function BranchSelectionDialog({ branches, onCancel, onSelect, selectText }: Props) {
|
||||
const [branch, setBranch] = useState<string>("__NONE__");
|
||||
return (
|
||||
<VStack
|
||||
className="mb-4"
|
||||
as="form"
|
||||
space={4}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSelect(branch);
|
||||
}}
|
||||
>
|
||||
<Select
|
||||
name="branch"
|
||||
hideLabel
|
||||
label="Branch"
|
||||
value={branch}
|
||||
options={branches.map((b) => ({ label: b, value: b }))}
|
||||
onChange={setBranch}
|
||||
/>
|
||||
<HStack space={2} justifyContent="end">
|
||||
<Button onClick={onCancel} variant="border" color="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" color="primary">
|
||||
{selectText}
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GitStatusEntry } from "@yaakapp-internal/git";
|
||||
import type { GitStatus, GitStatusEntry } from "@yaakapp-internal/git";
|
||||
import { useGit } from "@yaakapp-internal/git";
|
||||
import type {
|
||||
Environment,
|
||||
@@ -9,8 +9,7 @@ import type {
|
||||
Workspace,
|
||||
} from "@yaakapp-internal/models";
|
||||
import { Banner, HStack, Icon, InlineCode, SplitLayout } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { type ComponentProps, useCallback, useMemo, useState } from "react";
|
||||
import { modelToYaml } from "../../lib/diffYaml";
|
||||
import { trackFeatureUsage } from "../../lib/featureFeedback";
|
||||
import { resolvedModelName } from "../../lib/resolvedModelName";
|
||||
@@ -19,6 +18,7 @@ import { showErrorToast } from "../../lib/toast";
|
||||
import { sync } from "../../init/sync";
|
||||
import { CommercialUseBanner } from "../CommercialUseBanner";
|
||||
import { Button } from "../core/Button";
|
||||
import { Chip } from "../core/Chip";
|
||||
import type { CheckboxProps } from "../core/Checkbox";
|
||||
import { Checkbox } from "../core/Checkbox";
|
||||
import type { CheckboxTreeNode } from "../core/CheckboxTree";
|
||||
@@ -333,16 +333,7 @@ function CommitTreeRow({ node }: { node: CommitTreeNode }) {
|
||||
)}
|
||||
<div className="truncate flex-1">{resolvedModelName(node.model)}</div>
|
||||
{node.status.status !== "current" && (
|
||||
<InlineCode
|
||||
className={classNames(
|
||||
"py-0 bg-transparent w-24 text-center shrink-0",
|
||||
node.status.status === "modified" && "text-info",
|
||||
node.status.status === "untracked" && "text-success",
|
||||
node.status.status === "removed" && "text-danger",
|
||||
)}
|
||||
>
|
||||
{node.status.status}
|
||||
</InlineCode>
|
||||
<Chip color={statusColor(node.status.status)}>{node.status.status}</Chip>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
@@ -376,16 +367,9 @@ function ExternalTreeNode({
|
||||
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] gap-1 w-full items-center">
|
||||
<Icon color="secondary" icon="file_code" />
|
||||
<div className="truncate">{displayPath}</div>
|
||||
<InlineCode
|
||||
className={classNames(
|
||||
"py-0 ml-auto bg-transparent w-24 text-center",
|
||||
entry.status === "modified" && "text-info",
|
||||
entry.status === "untracked" && "text-success",
|
||||
entry.status === "removed" && "text-danger",
|
||||
)}
|
||||
>
|
||||
<Chip className="ml-auto" color={statusColor(entry.status)}>
|
||||
{entry.status}
|
||||
</InlineCode>
|
||||
</Chip>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
@@ -479,3 +463,20 @@ function DiffPanel({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function statusColor(status: GitStatus): ComponentProps<typeof Chip>["color"] {
|
||||
switch (status) {
|
||||
case "modified":
|
||||
return "info";
|
||||
case "untracked":
|
||||
return "success";
|
||||
case "removed":
|
||||
return "danger";
|
||||
case "conflict":
|
||||
return "warning";
|
||||
case "current":
|
||||
case "renamed":
|
||||
case "type_change":
|
||||
return "default";
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { DivergedStrategy } from "@yaakapp-internal/git";
|
||||
import { HStack, InlineCode } from "@yaakapp-internal/ui";
|
||||
import { InlineCode } from "@yaakapp-internal/ui";
|
||||
import { useState } from "react";
|
||||
import { showDialog } from "../../lib/dialog";
|
||||
import { Button } from "../core/Button";
|
||||
import { DialogFooter } from "../core/Dialog";
|
||||
import { RadioCards } from "../core/RadioCards";
|
||||
|
||||
type Resolution = "force_reset" | "merge";
|
||||
@@ -34,7 +34,7 @@ function DivergedDialog({ remote, branch, onResult, onHide }: DivergedDialogProp
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 mb-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-text-subtle">
|
||||
Your local branch has diverged from{" "}
|
||||
<InlineCode>
|
||||
@@ -59,18 +59,18 @@ function DivergedDialog({ remote, branch, onResult, onHide }: DivergedDialogProp
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<HStack space={2} justifyContent="start" className="flex-row-reverse">
|
||||
<Button
|
||||
color={selected === "force_reset" ? "danger" : "primary"}
|
||||
disabled={selected == null}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{selected != null ? resolutionLabel[selected] : "Select an option"}
|
||||
</Button>
|
||||
<Button variant="border" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</HStack>
|
||||
<DialogFooter
|
||||
inline
|
||||
actions={[
|
||||
{ label: "Cancel", onClick: handleCancel },
|
||||
{
|
||||
label: selected != null ? resolutionLabel[selected] : "Select an option",
|
||||
color: selected === "force_reset" ? "danger" : "primary",
|
||||
disabled: selected == null,
|
||||
onClick: handleSubmit,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,18 +3,15 @@ import "react-pdf/dist/Page/AnnotationLayer.css";
|
||||
import "./PdfViewer.css";
|
||||
import type { PDFDocumentProxy } from "pdfjs-dist";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { Document, Page } from "react-pdf";
|
||||
import { Document, Page, pdfjs } from "react-pdf";
|
||||
import { useContainerSize } from "@yaakapp-internal/ui";
|
||||
import { fireAndForget } from "../../lib/fireAndForget";
|
||||
|
||||
fireAndForget(
|
||||
import("react-pdf").then(({ pdfjs }) => {
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
||||
"pdfjs-dist/build/pdf.worker.min.mjs",
|
||||
import.meta.url,
|
||||
).toString();
|
||||
}),
|
||||
);
|
||||
// Document can start loading during render, so configure its worker synchronously.
|
||||
// Vite bundles the installed PDF.js worker as a local asset, including offline builds.
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
|
||||
"pdfjs-dist/build/pdf.worker.min.mjs",
|
||||
import.meta.url,
|
||||
).toString();
|
||||
|
||||
interface Props {
|
||||
/** A URL for the body the host already stored. */
|
||||
@@ -59,6 +56,8 @@ export function PdfViewer({ bodyUrl, data }: Props) {
|
||||
return (
|
||||
<div ref={containerRef} className="w-full h-full overflow-y-auto">
|
||||
<Document
|
||||
// Keep the copied data stable: suspending the initial mount discards useMemo.
|
||||
suspense={false}
|
||||
file={src}
|
||||
options={options}
|
||||
onLoadSuccess={onDocumentLoadSuccess}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { Icon } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import type { JsonPathSegment } from "../core/Editor/json/jsonPath";
|
||||
import { IconButton } from "../core/IconButton";
|
||||
import { Tooltip } from "../core/Tooltip";
|
||||
|
||||
interface Props {
|
||||
segments: JsonPathSegment[];
|
||||
appliedFilter: string | null;
|
||||
/**
|
||||
* How many leading segments make up the applied filter. `null` when unfiltered, or
|
||||
* when the filter isn't a plain path and is shown as raw text instead.
|
||||
*/
|
||||
appliedDepth: number | null;
|
||||
filterError: boolean;
|
||||
/** Called with the number of leading segments to filter to. `0` clears the filter. */
|
||||
onSelect: (count: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows where the cursor sits inside a response (`$ > sort > [0] > id`). An applied
|
||||
* plain-path filter is the start of the trail, so any crumb moves it. While filtered,
|
||||
* the bar is tinted so a lingering filter is hard to miss.
|
||||
*/
|
||||
export function ResponseBreadcrumbBar({
|
||||
segments,
|
||||
appliedFilter,
|
||||
appliedDepth,
|
||||
filterError,
|
||||
onSelect,
|
||||
}: Props) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [overflow, setOverflow] = useState({ left: false, right: false });
|
||||
|
||||
const measure = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el == null) return;
|
||||
const maxScroll = el.scrollWidth - el.clientWidth;
|
||||
setOverflow({
|
||||
left: el.scrollLeft > 1,
|
||||
right: el.scrollLeft < maxScroll - 1,
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Keep the deepest crumb (where the cursor is) in view as the path changes.
|
||||
useLayoutEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el == null) return;
|
||||
el.scrollLeft = el.scrollWidth;
|
||||
measure();
|
||||
}, [segments, measure]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el == null) return;
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [measure]);
|
||||
|
||||
const scrollBy = useCallback((direction: -1 | 1) => {
|
||||
const el = scrollRef.current;
|
||||
if (el == null) return;
|
||||
el.scrollBy({ left: direction * Math.max(120, el.clientWidth * 0.6), behavior: "smooth" });
|
||||
}, []);
|
||||
|
||||
const isFiltered = appliedFilter != null;
|
||||
|
||||
return (
|
||||
<div className="rounded bg-surface shadow pointer-events-auto">
|
||||
<div
|
||||
className={classNames(
|
||||
"flex items-stretch h-6 rounded border overflow-hidden font-mono text-xs text-text-subtle select-none",
|
||||
!isFiltered && "border-border-subtle",
|
||||
isFiltered && !filterError && "border-primary/40 bg-primary/10",
|
||||
filterError && "border-danger/40 bg-danger/10",
|
||||
)}
|
||||
>
|
||||
{isFiltered && appliedDepth == null ? (
|
||||
<span
|
||||
className={classNames(
|
||||
"flex items-center min-w-0 truncate px-2",
|
||||
filterError ? "text-danger" : "text-primary",
|
||||
)}
|
||||
>
|
||||
{appliedFilter}
|
||||
</span>
|
||||
) : (
|
||||
<Crumb
|
||||
label="$"
|
||||
tooltip="Clear filter"
|
||||
isApplied={isFiltered}
|
||||
onClick={appliedDepth != null && appliedDepth > 0 ? () => onSelect(0) : null}
|
||||
/>
|
||||
)}
|
||||
{overflow.left && (
|
||||
<IconButton
|
||||
size="xs"
|
||||
icon="chevron_left"
|
||||
title="Scroll breadcrumbs left"
|
||||
iconColor="secondary"
|
||||
onClick={() => scrollBy(-1)}
|
||||
className="shrink-0 h-auto!"
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={measure}
|
||||
className="flex items-stretch min-w-0 overflow-x-auto hide-scrollbars whitespace-nowrap"
|
||||
>
|
||||
{segments.map((segment, i) => {
|
||||
const count = i + 1;
|
||||
const isApplied = appliedDepth != null && count <= appliedDepth;
|
||||
const isClickable = (!isFiltered || appliedDepth != null) && count !== appliedDepth;
|
||||
return (
|
||||
<div key={i} className="flex items-stretch shrink-0">
|
||||
<Icon
|
||||
icon="chevron_right"
|
||||
size="xs"
|
||||
className="text-text-subtlest shrink-0 self-center"
|
||||
/>
|
||||
<Crumb
|
||||
label={segment.kind === "index" ? `[${segment.index}]` : segment.key}
|
||||
tooltip={
|
||||
segment.kind === "index"
|
||||
? `Filter to element ${segment.index}`
|
||||
: `Filter to ${segment.key}`
|
||||
}
|
||||
isApplied={isApplied}
|
||||
onClick={isClickable ? () => onSelect(count) : null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{overflow.right && (
|
||||
<IconButton
|
||||
size="xs"
|
||||
icon="chevron_right"
|
||||
title="Scroll breadcrumbs right"
|
||||
iconColor="secondary"
|
||||
onClick={() => scrollBy(1)}
|
||||
className="shrink-0 h-auto!"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Crumb({
|
||||
label,
|
||||
tooltip,
|
||||
isApplied,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
tooltip: string;
|
||||
isApplied: boolean;
|
||||
onClick: (() => void) | null;
|
||||
}) {
|
||||
const outerClassName = classNames("flex items-center px-0.5 py-0.5", isApplied && "text-primary");
|
||||
const innerClassName = "flex items-center h-full px-1 rounded-sm";
|
||||
if (onClick == null) {
|
||||
return (
|
||||
<span className={classNames(outerClassName, "shrink-0")}>
|
||||
<span className={innerClassName}>{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip content={tooltip} className="shrink-0 items-stretch!">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={classNames(outerClassName, "group/crumb focus-visible:outline-none")}
|
||||
>
|
||||
<span
|
||||
className={classNames(
|
||||
innerClassName,
|
||||
"transition-colors group-hover/crumb:text-text group-hover/crumb:bg-surface-highlight",
|
||||
"group-focus-visible/crumb:ring-1 group-focus-visible/crumb:ring-border-focus",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,18 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Children, useCallback, useMemo } from "react";
|
||||
import { Banner, HStack, Icon, InlineCode } from "@yaakapp-internal/ui";
|
||||
import { Children, useCallback, useMemo, useState } from "react";
|
||||
import { useFormatText } from "../../hooks/useFormatText";
|
||||
import type { ResponseFilterApi } from "../../hooks/useResponseFilter";
|
||||
import { Button } from "../core/Button";
|
||||
import type { EditorProps } from "../core/Editor/Editor";
|
||||
import { jsonBreadcrumbExtension } from "../core/Editor/json/breadcrumbExtension";
|
||||
import type { JsonPathSegment } from "../core/Editor/json/jsonPath";
|
||||
import { jsonPathToSegments, segmentsToJsonPath } from "../core/Editor/json/jsonPath";
|
||||
import { hyperlink } from "../core/Editor/hyperlink/extension";
|
||||
import { Editor } from "../core/Editor/LazyEditor";
|
||||
import { IconButton } from "../core/IconButton";
|
||||
import { Input } from "../core/Input";
|
||||
import { ResponseBreadcrumbBar } from "./ResponseBreadcrumbBar";
|
||||
import { RecentFiltersDropdown } from "./RecentFiltersDropdown";
|
||||
|
||||
const extraExtensions = [hyperlink];
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
language: EditorProps["language"];
|
||||
@@ -38,12 +38,43 @@ export function TextViewer({
|
||||
filter,
|
||||
filterResult,
|
||||
}: Props) {
|
||||
// Track the JSON path under the cursor to drive the breadcrumb bar. Selection
|
||||
// works even in this read-only editor, so it updates as the user clicks around.
|
||||
const [breadcrumbSegments, setBreadcrumbSegments] = useState<JsonPathSegment[]>([]);
|
||||
const handleBreadcrumbUpdate = useCallback(
|
||||
({ segments }: { segments: JsonPathSegment[] | null }) =>
|
||||
setBreadcrumbSegments((prev) => {
|
||||
const next = segments ?? [];
|
||||
return segmentsToJsonPath(prev) === segmentsToJsonPath(next) ? prev : next;
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const extraExtensions = useMemo(
|
||||
() =>
|
||||
language === "json"
|
||||
? [hyperlink, jsonBreadcrumbExtension(handleBreadcrumbUpdate)]
|
||||
: [hyperlink],
|
||||
[language, handleBreadcrumbUpdate],
|
||||
);
|
||||
|
||||
const canFilter =
|
||||
filter != null && (language === "json" || language === "xml" || language === "html");
|
||||
const isSearching = filter?.isSearching ?? false;
|
||||
const appliedFilter = filter?.appliedFilter ?? null;
|
||||
const resultError = filterResult?.error ?? false;
|
||||
|
||||
// Filter output is always an array of matches, so under a plain-path filter the
|
||||
// cursor's leading index is that single match and the rest extends the filter.
|
||||
const appliedSegments =
|
||||
appliedFilter != null && language === "json" ? jsonPathToSegments(appliedFilter) : null;
|
||||
const pathSegments =
|
||||
appliedSegments != null
|
||||
? [...appliedSegments, ...breadcrumbSegments.slice(1)]
|
||||
: breadcrumbSegments;
|
||||
const showBreadcrumbs =
|
||||
filter != null &&
|
||||
(appliedFilter != null || (language === "json" && breadcrumbSegments.length > 0));
|
||||
|
||||
const handleFilterKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (filter == null) return;
|
||||
@@ -155,16 +186,7 @@ export function TextViewer({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full w-full">
|
||||
{appliedFilter && filter != null ? (
|
||||
<AppliedFilterBar
|
||||
filter={appliedFilter}
|
||||
error={resultError}
|
||||
onClear={() => filter.replaceFilter("")}
|
||||
/>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<div className="relative h-full w-full">
|
||||
<Editor
|
||||
readOnly
|
||||
className={className}
|
||||
@@ -174,45 +196,23 @@ export function TextViewer({
|
||||
extraExtensions={extraExtensions}
|
||||
stateKey={stateKey}
|
||||
/>
|
||||
{showBreadcrumbs && (
|
||||
<div className="absolute top-0 right-3 max-w-[70%] pointer-events-none">
|
||||
<ResponseBreadcrumbBar
|
||||
segments={language === "json" ? pathSegments : []}
|
||||
appliedFilter={appliedFilter}
|
||||
appliedDepth={appliedSegments?.length ?? null}
|
||||
filterError={resultError}
|
||||
onSelect={(count) =>
|
||||
filter.replaceFilter(count === 0 ? "" : segmentsToJsonPath(pathSegments, count))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows what's actually filtering the body, which the filter box below can't convey
|
||||
* once it holds an edited expression that hasn't been applied yet.
|
||||
*/
|
||||
function AppliedFilterBar({
|
||||
filter,
|
||||
error,
|
||||
onClear,
|
||||
}: {
|
||||
filter: string;
|
||||
error: boolean;
|
||||
onClear: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Banner color={error ? "danger" : "info"} className="py-1! mb-2! text-sm">
|
||||
<HStack space={2} className="min-w-0">
|
||||
<Icon icon="filter" size="xs" className="shrink-0 opacity-70" />
|
||||
<span className="truncate min-w-0" title={filter}>
|
||||
Response filtered by <InlineCode>{filter}</InlineCode>
|
||||
{error && " (invalid expression)"}
|
||||
</span>
|
||||
<Button
|
||||
size="2xs"
|
||||
variant="border"
|
||||
color={error ? "danger" : "info"}
|
||||
className="ml-auto shrink-0"
|
||||
onClick={onClear}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</HStack>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
|
||||
/** Convert \uXXXX to actual Unicode characters */
|
||||
function decodeUnicodeLiterals(text: string): string {
|
||||
return text.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => {
|
||||
|
||||
@@ -23,7 +23,6 @@ export function useExportData() {
|
||||
id: "export-data",
|
||||
title: "Export Data",
|
||||
size: "md",
|
||||
noPadding: true,
|
||||
render: ({ hide }) => (
|
||||
<ExportDataDialog
|
||||
onHide={hide}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vite-plus/test";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
platform: { httpProxyUrl: "https://web.yaak.test" as string | null },
|
||||
showConfirm: vi.fn<() => Promise<boolean>>(),
|
||||
flushAllModelWrites: vi.fn(),
|
||||
rpc: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@yaakapp-internal/platform", () => ({ platform: mocks.platform }));
|
||||
vi.mock("@yaakapp-internal/models", () => ({ flushAllModelWrites: mocks.flushAllModelWrites }));
|
||||
vi.mock("../lib/confirm", () => ({ showConfirm: mocks.showConfirm }));
|
||||
vi.mock("../lib/appInfo", () => ({ appInfo: { identifier: "app.yaak.web" } }));
|
||||
vi.mock("../lib/rpc", () => ({ rpc: mocks.rpc }));
|
||||
vi.mock("../lib/toast", () => ({ showToast: vi.fn() }));
|
||||
vi.mock("./useActiveCookieJar", () => ({ getActiveCookieJar: () => ({ id: "cj_test" }) }));
|
||||
vi.mock("./useActiveEnvironment", () => ({ getActiveEnvironment: () => ({ id: "ev_test" }) }));
|
||||
|
||||
async function sender() {
|
||||
return (await import("./useSendAnyHttpRequest")).sendAnyHttpRequest;
|
||||
}
|
||||
|
||||
describe("web proxy confirmation before sending", () => {
|
||||
let stored: Map<string, string>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.resetAllMocks();
|
||||
mocks.platform.httpProxyUrl = "https://web.yaak.test";
|
||||
mocks.rpc.mockResolvedValue({ id: "rs_test" });
|
||||
stored = new Map();
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: (key: string) => stored.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => stored.set(key, value),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
test("blocks all pending sends until one shared confirmation is accepted", async () => {
|
||||
let decide!: (accepted: boolean) => void;
|
||||
mocks.showConfirm.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
decide = resolve;
|
||||
}),
|
||||
);
|
||||
const send = await sender();
|
||||
const first = send.mutateAsync("rq_one");
|
||||
const second = send.mutateAsync("rq_two");
|
||||
|
||||
expect(mocks.showConfirm).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.flushAllModelWrites).not.toHaveBeenCalled();
|
||||
expect(mocks.rpc).not.toHaveBeenCalled();
|
||||
decide(true);
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(mocks.rpc).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.rpc).toHaveBeenCalledWith("cmd_send_http_request", {
|
||||
requestId: "rq_one",
|
||||
environmentId: "ev_test",
|
||||
cookieJarId: "cj_test",
|
||||
});
|
||||
expect(stored.size).toBe(1);
|
||||
});
|
||||
|
||||
test("cancel sends nothing, remembers nothing, and asks again next time", async () => {
|
||||
mocks.showConfirm.mockResolvedValue(false);
|
||||
const send = await sender();
|
||||
expect(await send.mutateAsync("rq_one")).toBeNull();
|
||||
expect(await send.mutateAsync("rq_one")).toBeNull();
|
||||
expect(mocks.showConfirm).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.flushAllModelWrites).not.toHaveBeenCalled();
|
||||
expect(mocks.rpc).not.toHaveBeenCalled();
|
||||
expect(stored.size).toBe(0);
|
||||
});
|
||||
|
||||
test("cancel applies to every send waiting on the dialog", async () => {
|
||||
mocks.showConfirm.mockResolvedValue(false);
|
||||
const send = await sender();
|
||||
expect(await Promise.all([send.mutateAsync("rq_one"), send.mutateAsync("rq_two")])).toEqual([
|
||||
null,
|
||||
null,
|
||||
]);
|
||||
expect(mocks.showConfirm).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.rpc).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("remembers acceptance across module reloads for the same proxy", async () => {
|
||||
mocks.showConfirm.mockResolvedValue(true);
|
||||
await (await sender()).mutateAsync("rq_one");
|
||||
vi.resetModules();
|
||||
await (await sender()).mutateAsync("rq_two");
|
||||
expect(mocks.showConfirm).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.rpc).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("requires a new decision when the proxy changes", async () => {
|
||||
mocks.showConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(false);
|
||||
const send = await sender();
|
||||
await send.mutateAsync("rq_one");
|
||||
mocks.platform.httpProxyUrl = "https://another-proxy.test";
|
||||
expect(await send.mutateAsync("rq_two")).toBeNull();
|
||||
expect(mocks.showConfirm).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.rpc).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("still asks and remembers in this tab if browser storage is unavailable", async () => {
|
||||
const unavailable = () => {
|
||||
throw new Error("Storage unavailable");
|
||||
};
|
||||
vi.stubGlobal("localStorage", { getItem: unavailable, setItem: unavailable });
|
||||
mocks.showConfirm.mockResolvedValue(true);
|
||||
const send = await sender();
|
||||
await send.mutateAsync("rq_one");
|
||||
await send.mutateAsync("rq_two");
|
||||
expect(mocks.showConfirm).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.rpc).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("desktop sends directly without confirmation", async () => {
|
||||
mocks.platform.httpProxyUrl = null;
|
||||
await (await sender()).mutateAsync("rq_one");
|
||||
expect(mocks.showConfirm).not.toHaveBeenCalled();
|
||||
expect(mocks.rpc).toHaveBeenCalledTimes(1);
|
||||
expect(stored.size).toBe(0);
|
||||
});
|
||||
|
||||
test("an empty selection does not prompt or send", async () => {
|
||||
expect(await (await sender()).mutateAsync(null)).toBeNull();
|
||||
expect(mocks.showConfirm).not.toHaveBeenCalled();
|
||||
expect(mocks.rpc).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||
import { flushAllModelWrites } from "@yaakapp-internal/models";
|
||||
import { confirmWebProxy } from "../lib/confirmWebProxy";
|
||||
import { rpc } from "../lib/rpc";
|
||||
import { getActiveCookieJar } from "./useActiveCookieJar";
|
||||
import { getActiveEnvironment } from "./useActiveEnvironment";
|
||||
@@ -10,6 +11,10 @@ async function sendAnyHttpRequestById(id: string | null): Promise<HttpResponse |
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!(await confirmWebProxy())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await flushAllModelWrites();
|
||||
|
||||
return rpc("cmd_send_http_request", {
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link rel="icon" type="image/png" href="/icon-128.png" sizes="128x128" />
|
||||
<link rel="apple-touch-icon" href="/icon-128.png" />
|
||||
<title>Yaak App</title>
|
||||
<!-- <script src="http://localhost:8097"></script>-->
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vite-plus/test";
|
||||
import type { DialogInstance } from "../components/Dialogs";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
platform: { httpProxyUrl: "https://web.yaak.app" },
|
||||
showDialog: vi.fn<(dialog: DialogInstance) => void>(),
|
||||
}));
|
||||
|
||||
vi.mock("@yaakapp-internal/platform", () => ({ platform: mocks.platform }));
|
||||
vi.mock("./appInfo", () => ({ appInfo: { identifier: "app.yaak.web" } }));
|
||||
vi.mock("./dialog", () => ({ showDialog: mocks.showDialog }));
|
||||
vi.mock("../hooks/useHotKey", () => ({
|
||||
useHotKey: vi.fn(),
|
||||
useFormattedHotkey: () => null,
|
||||
}));
|
||||
|
||||
describe("proxy consent disclosure", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("localStorage", { getItem: () => null });
|
||||
});
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
test.each([
|
||||
["Yaak-hosted", "https://web.yaak.app"],
|
||||
["self-hosted", "https://yaak.example.com"],
|
||||
["local", "http://localhost:8080"],
|
||||
["split deployment", "https://send.example.com/relay"],
|
||||
])("identifies the %s proxy and requires an explicit decision", async (_, proxyUrl) => {
|
||||
mocks.platform.httpProxyUrl = proxyUrl;
|
||||
const { confirmWebProxy } = await import("./confirmWebProxy");
|
||||
void confirmWebProxy();
|
||||
|
||||
const dialog = mocks.showDialog.mock.calls[0]?.[0];
|
||||
expect(dialog).toMatchObject({
|
||||
id: "web-proxy-consent",
|
||||
title: "Requests in Yaak Web use a proxy",
|
||||
size: "sm",
|
||||
disableClose: true,
|
||||
});
|
||||
if (dialog == null) throw new Error("Expected the proxy consent dialog");
|
||||
|
||||
const disclosure = renderToStaticMarkup(<>{dialog.description}</>);
|
||||
expect(disclosure).toContain(`>${proxyUrl}</code>`);
|
||||
expect(disclosure).toMatch(/request and response data/i);
|
||||
expect(disclosure).toContain("credentials");
|
||||
expect(disclosure).toContain("server shown below");
|
||||
expect(disclosure).not.toContain("Yaak’s servers");
|
||||
expect(disclosure).toContain(
|
||||
'href="https://yaak.app/docs/getting-started/web-proxy?ref=app.yaak.web"',
|
||||
);
|
||||
expect(disclosure).toContain('target="_blank"');
|
||||
expect(disclosure).toContain("how the proxy works");
|
||||
|
||||
const Actions = dialog.render;
|
||||
const actions = renderToStaticMarkup(<Actions hide={vi.fn()} />);
|
||||
expect(actions).toMatch(/<button[^>]*type="submit"/);
|
||||
expect(actions).toContain(">Send via Proxy<");
|
||||
expect(actions).toContain(">Cancel<");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { InlineCode } from "@yaakapp-internal/ui";
|
||||
import { Link } from "../components/core/Link";
|
||||
import { showConfirm } from "./confirm";
|
||||
|
||||
const acceptedProxies = new Set<string>();
|
||||
const pendingConfirmations = new Map<string, Promise<boolean>>();
|
||||
|
||||
/** One decision for all pending sends; only acceptance is remembered. */
|
||||
export function confirmWebProxy(): Promise<boolean> {
|
||||
const proxyUrl = platform.httpProxyUrl;
|
||||
if (proxyUrl == null) return Promise.resolve(true);
|
||||
|
||||
const key = `yaak.webProxyConsent.v1:${proxyUrl}`;
|
||||
try {
|
||||
if (localStorage.getItem(key) === "accepted") return Promise.resolve(true);
|
||||
} catch {
|
||||
// Storage can be unavailable in private or restricted browsing.
|
||||
}
|
||||
if (acceptedProxies.has(proxyUrl)) return Promise.resolve(true);
|
||||
|
||||
const pending = pendingConfirmations.get(proxyUrl);
|
||||
if (pending != null) return pending;
|
||||
|
||||
const confirmation = showConfirm({
|
||||
id: "web-proxy-consent",
|
||||
title: "Requests in Yaak Web use a proxy",
|
||||
confirmText: "Send via Proxy",
|
||||
description: (
|
||||
<div className="space-y-3">
|
||||
<p>
|
||||
Yaak Web sends requests through a hosted proxy. Request and response data, including
|
||||
credentials, pass through the server shown below.
|
||||
</p>
|
||||
<p>
|
||||
<InlineCode className="break-all">{proxyUrl}</InlineCode>
|
||||
</p>
|
||||
<p>
|
||||
Learn more about{" "}
|
||||
<Link href="https://yaak.app/docs/getting-started/web-proxy">how the proxy works</Link>.
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
.then((accepted) => {
|
||||
if (accepted) {
|
||||
acceptedProxies.add(proxyUrl);
|
||||
try {
|
||||
localStorage.setItem(key, "accepted");
|
||||
} catch {
|
||||
// Keep acceptance for this tab if it cannot be persisted.
|
||||
}
|
||||
}
|
||||
return accepted;
|
||||
})
|
||||
.finally(() => pendingConfirmations.delete(proxyUrl));
|
||||
|
||||
pendingConfirmations.set(proxyUrl, confirmation);
|
||||
return confirmation;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import MimeType from "whatwg-mimetype";
|
||||
import { MIMEType } from "whatwg-mimetype";
|
||||
import type { EditorProps } from "../components/core/Editor/Editor";
|
||||
|
||||
export function languageFromContentType(
|
||||
@@ -90,10 +90,10 @@ export function isProbablyTextContentType(contentType: string | null): boolean {
|
||||
].some((textType) => normalized === textType || normalized.endsWith(textType));
|
||||
}
|
||||
|
||||
export function getMimeTypeFromContentType(contentType: string): MimeType {
|
||||
export function getMimeTypeFromContentType(contentType: string): MIMEType {
|
||||
try {
|
||||
return new MimeType(contentType);
|
||||
return new MIMEType(contentType);
|
||||
} catch {
|
||||
return new MimeType("text/plain");
|
||||
return new MIMEType("text/plain");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { GraphQLSchema, IntrospectionQuery } from "graphql";
|
||||
import { errorMessage } from "./errorMessage";
|
||||
import { buildClientSchema, buildSchema, introspectionFromSchema } from "graphql";
|
||||
|
||||
// Accepts either a GraphQL introspection JSON ({ data: { __schema } } or
|
||||
@@ -45,7 +46,3 @@ export function tryBuildIntrospectionFromFile(
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import { FormattedError, VStack } from "@yaakapp-internal/ui";
|
||||
import { Button } from "../components/core/Button";
|
||||
import { ImportDataDialog } from "../components/ImportDataDialog";
|
||||
import { activeFolderAtom } from "../hooks/useActiveFolder";
|
||||
import type { ImportSourcePath } from "../components/ImportSourceList";
|
||||
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
|
||||
import { createFastMutation } from "../hooks/useFastMutation";
|
||||
import { showAlert } from "./alert";
|
||||
@@ -20,10 +20,17 @@ import { rpc } from "./rpc";
|
||||
|
||||
// Stable identities so the dialog's effects don't re-run (and cancel in-flight
|
||||
// fetches) every time the dialog container re-renders.
|
||||
const planFile = (filePath: string, destination: ImportDestination) =>
|
||||
rpc<ImportPlan>("cmd_import_data", { filePath, destination });
|
||||
const planUrl = (url: string, destination: ImportDestination) =>
|
||||
rpc<ImportPlan>("cmd_import_url", { url, destination });
|
||||
const planSources = (sources: ImportSourcePath[], destination: ImportDestination) =>
|
||||
rpc<ImportPlan>("cmd_import_data", {
|
||||
filePaths: sources.filter((source) => source.kind !== "url").map((source) => source.path),
|
||||
urls: sources.filter((source) => source.kind === "url").map((source) => source.path),
|
||||
destination,
|
||||
});
|
||||
const detectSource = (source: ImportSourcePath) =>
|
||||
rpc<string>(
|
||||
"cmd_detect_import_source",
|
||||
source.kind === "url" ? { url: source.path } : { filePath: source.path },
|
||||
);
|
||||
const listSources = (workspaceId: string) =>
|
||||
rpc<ImportSource[]>("cmd_list_import_sources", { workspaceId });
|
||||
const findSourcesForOrigin = (args: { filePath?: string; url?: string }) =>
|
||||
@@ -43,11 +50,11 @@ export const importData = createFastMutation({
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const currentWorkspace = jotaiStore.get(activeWorkspaceAtom);
|
||||
const workspaces = jotaiStore.get(workspacesAtom);
|
||||
const selectedFolder = jotaiStore.get(activeFolderAtom);
|
||||
showDialog({
|
||||
id: "import",
|
||||
title: "Import Data",
|
||||
size: "lg",
|
||||
className: "h-[36rem]",
|
||||
disableClose: true,
|
||||
render: ({ hide }) => {
|
||||
const cancel = () => {
|
||||
@@ -68,9 +75,8 @@ export const importData = createFastMutation({
|
||||
<ImportDataDialog
|
||||
currentWorkspace={currentWorkspace}
|
||||
workspaces={workspaces}
|
||||
selectedFolder={selectedFolder}
|
||||
planFile={planFile}
|
||||
planUrl={planUrl}
|
||||
planSources={planSources}
|
||||
detectSource={detectSource}
|
||||
listSources={listSources}
|
||||
findSourcesForOrigin={findSourcesForOrigin}
|
||||
commit={commit}
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { SniffedValue } from "../components/core/Editor/sniffValue";
|
||||
import { isEncodedRun } from "../components/core/Editor/sniffValue";
|
||||
import { copyToClipboard } from "./copy";
|
||||
import { fireAndForget } from "./fireAndForget";
|
||||
import { rpc } from "./rpc";
|
||||
import { showToast } from "./toast";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
|
||||
@@ -212,35 +211,26 @@ export function copyImage(text: string, sniffed: SniffedValue, fallback: string)
|
||||
});
|
||||
}
|
||||
|
||||
/** Base64 of a byte array, in chunks so a few megabytes don't blow the argument limit. */
|
||||
function toBase64(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i += 0x8000) {
|
||||
binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a collapsed value to a file the user picks.
|
||||
*
|
||||
* A value that is already base64 goes straight to the backend as-is — decoding it here only to
|
||||
* A value that is already base64 is handed over as base64. These are the values
|
||||
* the editor collapsed for being large, so decoding one here only for a host to
|
||||
* encode it again would walk megabytes twice for nothing.
|
||||
*/
|
||||
export async function saveValue(text: string, sniffed: SniffedValue | null, name: string) {
|
||||
const ext = sniffed == null ? "txt" : (mime.getExtension(sniffed.mime) ?? "bin");
|
||||
const filepath = await platform.dialog.save({ defaultPath: `${name}.${ext}`, title: "Save Value" });
|
||||
if (filepath == null) {
|
||||
const content =
|
||||
sniffed == null
|
||||
? new TextEncoder().encode(text)
|
||||
: sniffed.encoding === "base64"
|
||||
? { base64: normalizeBase64(payloadOf(text, sniffed)) }
|
||||
: decodeValue(text, sniffed);
|
||||
|
||||
const savedTo = await platform.files.save(`${name}.${ext}`, content);
|
||||
if (savedTo == null) {
|
||||
return; // Cancelled
|
||||
}
|
||||
|
||||
const data =
|
||||
sniffed == null
|
||||
? toBase64(new TextEncoder().encode(text))
|
||||
: sniffed.encoding === "base64"
|
||||
? normalizeBase64(payloadOf(text, sniffed))
|
||||
: toBase64(decodeValue(text, sniffed));
|
||||
|
||||
await rpc("cmd_save_base64_to_binary", { filepath, data });
|
||||
showToast({ message: `Saved to ${filepath}` });
|
||||
showToast({ message: `Saved to ${savedTo}` });
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { BatchUpsertResult } from "@yaakapp-internal/models";
|
||||
import { createGlobalModel } from "@yaakapp-internal/models";
|
||||
import { router } from "./router";
|
||||
import { rpc } from "./rpc";
|
||||
import { setKeyValue } from "./keyValueStore";
|
||||
|
||||
export type OnboardingChoice =
|
||||
| "import"
|
||||
| "import_yaak"
|
||||
| "example"
|
||||
| "fresh"
|
||||
| "open_folder"
|
||||
| "clone_git";
|
||||
|
||||
const NEW_WORKSPACE_NAME = "My Workspace";
|
||||
|
||||
/**
|
||||
* Remembered so later surfaces can lean toward what the user came for (an importer, the
|
||||
* example, or a blank slate). Nothing reads it yet.
|
||||
*/
|
||||
export function recordOnboardingChoice(choice: OnboardingChoice) {
|
||||
setKeyValue({
|
||||
namespace: "global",
|
||||
key: "onboarding",
|
||||
value: { choice, at: new Date().toISOString() },
|
||||
}).catch(console.error);
|
||||
}
|
||||
|
||||
export async function createFreshWorkspace() {
|
||||
const workspaceId = await createGlobalModel({ model: "workspace", name: NEW_WORKSPACE_NAME });
|
||||
await router.navigate({ to: "/workspaces/$workspaceId", params: { workspaceId } });
|
||||
}
|
||||
|
||||
export async function createExampleWorkspace() {
|
||||
const created = await rpc<BatchUpsertResult>("cmd_create_example_workspace", {});
|
||||
const workspace = created.workspaces[0];
|
||||
if (workspace == null) throw new Error("Example workspace was not created");
|
||||
const firstRequest = created.httpRequests.find((r) => r.name === "List posts");
|
||||
await router.navigate({
|
||||
to: "/workspaces/$workspaceId",
|
||||
params: { workspaceId: workspace.id },
|
||||
search: firstRequest ? { request_id: firstRequest.id } : {},
|
||||
});
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.8.1",
|
||||
"@codemirror/commands": "^6.11.1",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/lang-markdown": "^6.3.2",
|
||||
@@ -19,7 +19,7 @@
|
||||
"@codemirror/merge": "^6.11.2",
|
||||
"@codemirror/search": "^6.5.11",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@gilbarbara/deep-equal": "^0.3.1",
|
||||
"@gilbarbara/deep-equal": "^0.4.1",
|
||||
"@lezer/highlight": "^1.1.3",
|
||||
"@lezer/lr": "^1.3.3",
|
||||
"@mjackson/multipart-parser": "^0.10.1",
|
||||
@@ -27,14 +27,14 @@
|
||||
"@replit/codemirror-vim": "^6.3.0",
|
||||
"@replit/codemirror-vscode-keymap": "^6.0.2",
|
||||
"@shopify/lang-jsonc": "^1.0.1",
|
||||
"@tanstack/react-query": "^5.90.5",
|
||||
"@tanstack/react-router": "^1.133.13",
|
||||
"@tanstack/react-virtual": "^3.14.9",
|
||||
"@tanstack/react-query": "^5.103.2",
|
||||
"@tanstack/react-router": "^1.170.38",
|
||||
"@tanstack/react-virtual": "^3.14.13",
|
||||
"@tauri-apps/api": "^2.11.0",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-fs": "^2.5.1",
|
||||
"@tauri-apps/plugin-log": "^2.8.0",
|
||||
"@tauri-apps/plugin-log": "^2.9.2",
|
||||
"@tauri-apps/plugin-opener": "^2.5.4",
|
||||
"@tauri-apps/plugin-os": "^2.3.2",
|
||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||
@@ -45,26 +45,26 @@
|
||||
"date-fns": "^4.1.0",
|
||||
"deep-equal": "^2.2.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"focus-trap-react": "^11.0.4",
|
||||
"focus-trap-react": "^12.0.3",
|
||||
"fuzzbunny": "^1.0.1",
|
||||
"graphql": "^16.13.1",
|
||||
"hexy": "^0.3.5",
|
||||
"graphql": "^17.0.2",
|
||||
"hexy": "^0.4.0",
|
||||
"history": "^5.3.0",
|
||||
"jotai": "^2.18.0",
|
||||
"jotai-family": "^1.0.1",
|
||||
"js-md5": "^0.8.3",
|
||||
"js-md5": "^0.9.2",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"lucide-react": "^0.525.0",
|
||||
"lucide-react": "^1.47.0",
|
||||
"mime": "^4.0.4",
|
||||
"motion": "^12.4.7",
|
||||
"nanoid": "^5.1.16",
|
||||
"motion": "^13.4.0",
|
||||
"nanoid": "^6.0.1",
|
||||
"papaparse": "^5.4.1",
|
||||
"parse-color": "^1.0.0",
|
||||
"react": "^19.2.0",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-pdf": "^10.0.1",
|
||||
"react-pdf": "^11.0.0",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"react-use": "^17.6.1",
|
||||
"rehype-stringify": "^10.0.1",
|
||||
@@ -73,32 +73,30 @@
|
||||
"slugify": "^1.6.6",
|
||||
"uuid": "^14.0.0",
|
||||
"vkbeautify": "^0.99.3",
|
||||
"whatwg-mimetype": "^4.0.0",
|
||||
"whatwg-mimetype": "^5.0.0",
|
||||
"yaml": "^2.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lezer/generator": "^1.8.0",
|
||||
"@rolldown/plugin-babel": "^0.2.3",
|
||||
"@tanstack/router-plugin": "^1.127.5",
|
||||
"@tanstack/router-plugin": "^1.168.40",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"@types/node": "^24.0.13",
|
||||
"@types/node": "^22.20.4",
|
||||
"@types/papaparse": "^5.3.16",
|
||||
"@types/parse-color": "^1.0.3",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/whatwg-mimetype": "^3.0.2",
|
||||
"@types/whatwg-mimetype": "^5.0.0",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@yaakapp-internal/theme": "^1.0.0",
|
||||
"@yaakapp-internal/ui": "^1.0.0",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"internal-ip": "^8.0.0",
|
||||
"rollup": "^4.60.3",
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.9",
|
||||
"vite-plugin-static-copy": "^3.3.0",
|
||||
"vite-plugin-svgr": "^4.5.0",
|
||||
"rollup": "^4.63.4",
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.3.3",
|
||||
"vite-plugin-static-copy": "^4.1.1",
|
||||
"vite-plugin-wasm": "^3.5.0",
|
||||
"vite-plus": "^0.3.0"
|
||||
"vite-plus": "^0.3.3"
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,19 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Onboarding } from "../components/Onboarding";
|
||||
import { RedirectToLatestWorkspace } from "../components/RedirectToLatestWorkspace";
|
||||
|
||||
type IndexSearchSchema = {
|
||||
/** Show the home screen even when workspaces exist. Set by the dev menu. */
|
||||
home?: true;
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: RouteComponent,
|
||||
validateSearch: (search: Record<string, unknown>): IndexSearchSchema =>
|
||||
search.home === true ? { home: true } : {},
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
return <RedirectToLatestWorkspace />;
|
||||
const { home } = Route.useSearch();
|
||||
return home ? <Onboarding /> : <RedirectToLatestWorkspace />;
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
// @ts-ignore
|
||||
import { tanstackRouter } from "@tanstack/router-plugin/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import fs from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { defineConfig, normalizePath } from "vite-plus";
|
||||
import { viteStaticCopy } from "vite-plugin-static-copy";
|
||||
import svgr from "vite-plugin-svgr";
|
||||
import wasm from "vite-plugin-wasm";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
@@ -16,6 +16,12 @@ const standardFontsDir = normalizePath(
|
||||
path.join(path.dirname(require.resolve("pdfjs-dist/package.json")), "standard_fonts"),
|
||||
);
|
||||
|
||||
// The app's own icons, served as the page icon rather than copied into the client:
|
||||
// the bundled app and the tab should not be able to disagree about what Yaak looks like.
|
||||
const iconsDir = normalizePath(
|
||||
path.join(import.meta.dirname, "../../crates-tauri/yaak-app-client/icons/release"),
|
||||
);
|
||||
|
||||
/**
|
||||
* Which host the platform package installs. `web` builds Yaak to run in a plain
|
||||
* browser tab, with its own IndexedDB store instead of the Rust engine; anything
|
||||
@@ -23,6 +29,84 @@ const standardFontsDir = normalizePath(
|
||||
*/
|
||||
const yaakTarget = process.env.YAAK_TARGET === "web" ? "web" : "desktop";
|
||||
|
||||
const webUrl = "https://web.yaak.app";
|
||||
const webDir = normalizePath(path.join(import.meta.dirname, "web"));
|
||||
|
||||
/** Link previews for the hosted web app. Meaningless inside the desktop window. */
|
||||
function webHead() {
|
||||
return {
|
||||
name: "web-head",
|
||||
transformIndexHtml() {
|
||||
const title = "Yaak in your browser";
|
||||
const description = "No download, no setup. Open a tab and send your first request.";
|
||||
return [
|
||||
{ tag: "meta", attrs: { name: "description", content: description } },
|
||||
{ tag: "meta", attrs: { property: "og:type", content: "website" } },
|
||||
{ tag: "meta", attrs: { property: "og:url", content: webUrl } },
|
||||
{ tag: "meta", attrs: { property: "og:title", content: title } },
|
||||
{ tag: "meta", attrs: { property: "og:description", content: description } },
|
||||
{ tag: "meta", attrs: { property: "og:image", content: `${webUrl}/og.png` } },
|
||||
{ tag: "meta", attrs: { property: "og:image:width", content: "1200" } },
|
||||
{ tag: "meta", attrs: { property: "og:image:height", content: "630" } },
|
||||
{ tag: "meta", attrs: { name: "twitter:card", content: "summary_large_image" } },
|
||||
{ tag: "meta", attrs: { name: "twitter:site", content: "@yaakapp" } },
|
||||
{ tag: "meta", attrs: { name: "twitter:title", content: title } },
|
||||
{ tag: "meta", attrs: { name: "twitter:description", content: description } },
|
||||
{ tag: "meta", attrs: { name: "twitter:image", content: `${webUrl}/og.png` } },
|
||||
];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Where `yaak-web` is listening, taken from the same variables that put it there.
|
||||
*
|
||||
* `HOST` is an instruction about what the server accepts rather than an address to
|
||||
* dial, so a wildcard becomes loopback: the dev server and the send server share a
|
||||
* machine.
|
||||
*/
|
||||
function sendServerUrl(): string {
|
||||
const host = process.env.HOST?.trim();
|
||||
const port = process.env.PORT?.trim() || "9227";
|
||||
const wildcard = !host || host === "0.0.0.0" || host === "::" || host === "[::]";
|
||||
return `http://${wildcard ? "127.0.0.1" : host}:${port}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails the build when a copied asset is not where the app fetches it from.
|
||||
*
|
||||
* `PdfViewer` and the page head address these by URL, but nothing ties those
|
||||
* URLs to the copy targets below, and a target that mirrors its source path
|
||||
* instead of landing at the root still builds cleanly — the miss only shows up
|
||||
* as a 404 once the app runs.
|
||||
*/
|
||||
function verifyServedAssets(expected: string[]) {
|
||||
let outDir = "";
|
||||
return {
|
||||
name: "verify-served-assets",
|
||||
apply: "build" as const,
|
||||
configResolved(config: { root: string; build: { outDir: string } }) {
|
||||
outDir = path.resolve(config.root, config.build.outDir);
|
||||
},
|
||||
// After viteStaticCopy, which writes on `writeBundle`.
|
||||
closeBundle() {
|
||||
const missing = expected.filter((asset) => {
|
||||
const full = path.join(outDir, asset);
|
||||
if (!fs.existsSync(full)) return true;
|
||||
const stat = fs.statSync(full);
|
||||
return stat.isDirectory() && fs.readdirSync(full).length === 0;
|
||||
});
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`Copied assets missing from ${outDir}: ${missing.join(", ")}. ` +
|
||||
`Every viteStaticCopy target needs to strip its base path, or it lands ` +
|
||||
`under a copy of the directories it came from instead.`,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(async () => {
|
||||
return {
|
||||
@@ -56,14 +140,44 @@ export default defineConfig(async () => {
|
||||
generatedRouteTree: "./routeTree.gen.ts",
|
||||
autoCodeSplitting: true,
|
||||
}),
|
||||
svgr(),
|
||||
react(),
|
||||
viteStaticCopy({
|
||||
// v4 matches only files and always mirrors the source tree into the
|
||||
// output, so every target here needs stripBase to land where it is
|
||||
// actually served from — without it these end up under a copy of the
|
||||
// path they came from, and nothing fails until the request 404s.
|
||||
targets: [
|
||||
{ src: cMapsDir, dest: "" },
|
||||
{ src: standardFontsDir, dest: "" },
|
||||
{ src: `${cMapsDir}/*`, dest: "cmaps", rename: { stripBase: true } },
|
||||
{
|
||||
src: `${standardFontsDir}/*`,
|
||||
dest: "standard_fonts",
|
||||
rename: { stripBase: true },
|
||||
},
|
||||
// `/favicon.ico` is requested by browsers whether or not anything links to it,
|
||||
// so it is served under that name to keep a 404 out of every console.
|
||||
{
|
||||
src: `${iconsDir}/icon.ico`,
|
||||
dest: "",
|
||||
rename: { name: "favicon.ico", stripBase: true },
|
||||
},
|
||||
{
|
||||
src: `${iconsDir}/128x128.png`,
|
||||
dest: "",
|
||||
rename: { name: "icon-128.png", stripBase: true },
|
||||
},
|
||||
...(yaakTarget === "web"
|
||||
? [{ src: `${webDir}/og.png`, dest: "", rename: { stripBase: true } }]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
...(yaakTarget === "web" ? [webHead()] : []),
|
||||
verifyServedAssets([
|
||||
"cmaps",
|
||||
"standard_fonts",
|
||||
"favicon.ico",
|
||||
"icon-128.png",
|
||||
...(yaakTarget === "web" ? ["og.png"] : []),
|
||||
]),
|
||||
],
|
||||
build: {
|
||||
target: "esnext",
|
||||
@@ -84,8 +198,28 @@ export default defineConfig(async () => {
|
||||
},
|
||||
clearScreen: false,
|
||||
server: {
|
||||
// `HOST` names the interface, as it does most places: unset leaves Vite on
|
||||
// loopback, `0.0.0.0` exposes it for reaching the dev server from another
|
||||
// device.
|
||||
host: process.env.HOST,
|
||||
// Vite refuses a `Host` it does not recognise, which stops a page on a name the
|
||||
// attacker controls from rebinding that name here and driving `/v1` as its own
|
||||
// origin. Addresses are allowed already; only names need listing, so reaching
|
||||
// this as `dev-box.example` means naming it. Deliberately not widened to "any
|
||||
// host when exposed": the proxy below leads to an unauthenticated sender.
|
||||
allowedHosts: process.env.ALLOWED_HOSTS?.split(",")
|
||||
.map((h) => h.trim())
|
||||
.filter(Boolean),
|
||||
port: parseInt(process.env.YAAK_CLIENT_DEV_PORT ?? process.env.YAAK_DEV_PORT ?? "1420", 10),
|
||||
strictPort: true,
|
||||
// A web dev server is one origin, the way the built app is: `/v1` is passed
|
||||
// through to `yaak-web` rather than the tab being told to call it directly.
|
||||
// That is one address to open instead of two, no CORS in the loop, and a
|
||||
// dev build that sends exactly the way a production build does.
|
||||
proxy:
|
||||
yaakTarget === "web"
|
||||
? { "/v1": { target: sendServerUrl(), changeOrigin: true } }
|
||||
: undefined,
|
||||
},
|
||||
envPrefix: ["VITE_", "TAURI_"],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
Source for og.png (1200x630). Re-render after editing:
|
||||
cd apps/yaak-client/web && "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new --disable-gpu --hide-scrollbars --window-size=1200,630 --force-device-scale-factor=1 --virtual-time-budget=10000 --screenshot=og.png "file://$PWD/og.html"
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@500;600;800&family=JetBrains+Mono:wght@400;500&display=block"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
width: 1200px;
|
||||
height: 630px;
|
||||
overflow: hidden;
|
||||
}
|
||||
body {
|
||||
font-family: Inter, sans-serif;
|
||||
color: hsl(245, 23%, 85%);
|
||||
background:
|
||||
radial-gradient(900px 600px at 0% 100%, hsl(160, 40%, 16%) 0%, transparent 60%),
|
||||
radial-gradient(900px 600px at 100% 0%, hsl(275, 40%, 20%) 0%, transparent 60%),
|
||||
linear-gradient(135deg, hsl(244, 23%, 12%) 0%, hsl(244, 23%, 15%) 100%);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.dots {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: radial-gradient(hsla(245, 23%, 85%, 0.07) 1.2px, transparent 1.2px);
|
||||
background-size: 28px 28px;
|
||||
mask-image: radial-gradient(ellipse at 50% 40%, black 30%, transparent 75%);
|
||||
}
|
||||
.page {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
padding: 56px 64px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
font-weight: 600;
|
||||
font-size: 26px;
|
||||
}
|
||||
.brand img {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
h1 {
|
||||
margin-top: 52px;
|
||||
font-size: 84px;
|
||||
line-height: 1.05;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
color: hsl(245, 23%, 92%);
|
||||
}
|
||||
h1 em {
|
||||
font-style: normal;
|
||||
color: hsl(48, 80%, 63%);
|
||||
}
|
||||
p {
|
||||
margin-top: 28px;
|
||||
font-size: 34px;
|
||||
line-height: 1.4;
|
||||
font-weight: 500;
|
||||
color: hsl(245, 18%, 68%);
|
||||
max-width: 1040px;
|
||||
}
|
||||
.bar {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 22px;
|
||||
height: 72px;
|
||||
padding: 0 28px;
|
||||
border-radius: 12px;
|
||||
background: hsla(244, 23%, 22%, 0.6);
|
||||
border: 1px solid hsl(244, 23%, 28%);
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 28px;
|
||||
box-shadow: 0 10px 40px hsla(244, 40%, 5%, 0.4);
|
||||
}
|
||||
.method {
|
||||
color: hsl(245, 18%, 62%);
|
||||
font-weight: 500;
|
||||
}
|
||||
.url .scheme {
|
||||
color: hsl(245, 18%, 55%);
|
||||
}
|
||||
.url .host {
|
||||
color: hsl(245, 23%, 92%);
|
||||
}
|
||||
.status {
|
||||
margin-left: auto;
|
||||
color: hsl(150, 99%, 44%);
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="dots"></div>
|
||||
<div class="page">
|
||||
<div class="brand">
|
||||
<img src="../../../crates-tauri/yaak-app-client/icons/release/128x128@2x.png" alt="" />
|
||||
<span>Yaak</span>
|
||||
</div>
|
||||
<h1>Your favorite API client<br /><em>in your browser</em></h1>
|
||||
<p>No download, no setup. Open a tab and send your first request.</p>
|
||||
<div class="bar">
|
||||
<span class="method">GET</span>
|
||||
<span class="url"><span class="scheme">https://</span><span class="host">web.yaak.app</span></span>
|
||||
<span class="status">200 OK</span>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 295 KiB |
@@ -9,7 +9,7 @@
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.90.5",
|
||||
"@tanstack/react-query": "^5.103.2",
|
||||
"@tauri-apps/api": "^2.11.0",
|
||||
"@tauri-apps/plugin-os": "^2.3.2",
|
||||
"@yaakapp-internal/model-store": "^1.0.0",
|
||||
@@ -19,7 +19,7 @@
|
||||
"classnames": "^2.5.1",
|
||||
"jotai": "^2.18.0",
|
||||
"jotai-family": "^1.0.1",
|
||||
"motion": "^12.4.7",
|
||||
"motion": "^13.4.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
@@ -31,7 +31,7 @@
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.9",
|
||||
"vite-plus": "^0.3.0"
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.3.3",
|
||||
"vite-plus": "^0.3.3"
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ name = "yaak"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
yaak-assertions = { workspace = true }
|
||||
arboard = "3"
|
||||
base64 = "0.22"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
@@ -22,8 +23,11 @@ include_dir = "0.7"
|
||||
keyring = { workspace = true, features = ["apple-native", "windows-native", "sync-secret-service"] }
|
||||
log = { workspace = true }
|
||||
rand = "0.8"
|
||||
reqwest = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["form"] }
|
||||
rolldown = "0.1.0"
|
||||
# Pinned exactly: rolldown_resolver takes `oxc_resolver = "^11"`, so cargo
|
||||
# drifts it forward and breaks on 11.11 (TsconfigOptions became
|
||||
# TsconfigDiscovery). This dep exists only to hold that resolution.
|
||||
oxc_resolver = "=11.10.0"
|
||||
schemars = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
||||
@@ -4,7 +4,7 @@ description: >
|
||||
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
|
||||
an OpenAPI spec, a Postman, Insomnia, or Bruno collection, or a cURL command, or run
|
||||
saved requests across environments. Prefer over one-off `curl` when requests
|
||||
should be saved, reused, shared, or run as a set.
|
||||
allowed-tools: Bash(yaak:*), Bash(which:*), Bash(command:*), Bash(npm:*), Bash(npx:*)
|
||||
@@ -98,9 +98,9 @@ re-run it after acting.
|
||||
|
||||
## Core workflows
|
||||
|
||||
**Start from a spec when one exists.** `yaak import <file>` auto-detects OpenAPI,
|
||||
Swagger, Postman, Insomnia, cURL, and Yaak exports, and beats authoring requests
|
||||
by hand every time.
|
||||
**Start from a spec when one exists.** `yaak import <path>` auto-detects OpenAPI,
|
||||
Swagger, Postman, Insomnia, Bruno, cURL, and Yaak exports, and beats authoring requests
|
||||
by hand every time. Bruno collections can also be imported from a ZIP or directory.
|
||||
|
||||
**Make the host swappable.** Put the base URL in a base-environment variable,
|
||||
reference it as `${[ base_url ]}`, then add a sub-environment per deployment
|
||||
|
||||
@@ -45,7 +45,7 @@ pub enum Commands {
|
||||
/// Authentication commands
|
||||
Auth(AuthArgs),
|
||||
|
||||
/// Import API data from Yaak, OpenAPI, Postman, Insomnia, Swagger, or cURL
|
||||
/// Import API data from Yaak, OpenAPI, Postman, Insomnia, Bruno, Swagger, or cURL
|
||||
Import(ImportArgs),
|
||||
|
||||
/// Export Yaak workspace data
|
||||
@@ -71,6 +71,9 @@ pub enum Commands {
|
||||
/// Send a request, folder, or workspace by ID
|
||||
Send(SendArgs),
|
||||
|
||||
/// Run HTTP assertions (exit 0: passed, 1: failed/error, 2: invalid configuration)
|
||||
Test(TestArgs),
|
||||
|
||||
/// Cookie jar commands
|
||||
CookieJar(CookieJarArgs),
|
||||
|
||||
@@ -186,6 +189,24 @@ pub enum AgentCommands {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct TestArgs {
|
||||
/// Request, folder, or workspace ID; sends HTTP requests sequentially in sidebar order
|
||||
pub id: String,
|
||||
|
||||
/// Stop after the first failed check or request error
|
||||
#[arg(long)]
|
||||
pub fail_fast: bool,
|
||||
|
||||
/// Write a JSON report without URLs, headers, bodies, or assertion values
|
||||
#[arg(long, value_name = "FILE")]
|
||||
pub json: Option<PathBuf>,
|
||||
|
||||
/// Write a JUnit XML report without request or assertion values
|
||||
#[arg(long, value_name = "FILE")]
|
||||
pub junit: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct SendArgs {
|
||||
/// Request, folder, or workspace ID
|
||||
@@ -202,7 +223,7 @@ pub struct SendArgs {
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct ImportArgs {
|
||||
/// Path to the file to import
|
||||
/// Path to the file, ZIP archive, or directory to import
|
||||
pub file: PathBuf,
|
||||
|
||||
/// Existing workspace ID to import into when supported by the importer
|
||||
|
||||
@@ -2,13 +2,12 @@ use crate::cli::{ExportArgs, ImportArgs};
|
||||
use crate::context::CliContext;
|
||||
use crate::utils::workspace::resolve_workspace_id;
|
||||
use std::fs;
|
||||
use std::io::ErrorKind;
|
||||
use yaak::export::{self, ExportDataParams};
|
||||
use yaak::import;
|
||||
use yaak_models::export::{self, ExportDataParams};
|
||||
use yaak_models::util::{
|
||||
BatchUpsertResult, ImportDestination, ImportOrigin, ImportPlanAction, ImportPlanItem,
|
||||
};
|
||||
use yaak_plugins::events::{ImportResources, PluginContext};
|
||||
use yaak_plugins::events::{ImportRequest, ImportResources, PluginContext};
|
||||
|
||||
type CommandResult<T = ()> = std::result::Result<T, String>;
|
||||
|
||||
@@ -51,11 +50,12 @@ async fn import(
|
||||
.map_err(|e| format!("Failed to get workspace '{workspace_id}': {e}"))?;
|
||||
}
|
||||
|
||||
let file_contents = read_import_file(&args.file)?;
|
||||
let input = ImportRequest::from_path(&args.file)
|
||||
.map_err(|err| format!("Unable to read import source {}: {err}", args.file.display()))?;
|
||||
let plugin_context = PluginContext::new(None, args.workspace_id.clone());
|
||||
let plugin_manager = ctx.plugin_manager();
|
||||
let import_result = plugin_manager
|
||||
.import_data(&plugin_context, &file_contents)
|
||||
.import_input(&plugin_context, &input)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to import data: {e}"))?;
|
||||
let importer = import_result.importer;
|
||||
@@ -127,17 +127,61 @@ fn format_skipped(items: &[ImportPlanItem]) -> Option<String> {
|
||||
if parts.is_empty() { None } else { Some(parts.join(", ")) }
|
||||
}
|
||||
|
||||
/// Write a file so that afterwards it is either the old one or the whole new one.
|
||||
///
|
||||
/// Writing in place would truncate the previous export before the new one existed, so an
|
||||
/// interrupted run loses both. Instead the bytes go to a neighbouring file, are flushed, and
|
||||
/// take the target's name in one step. The directory is flushed too: `sync_all` on a file
|
||||
/// promises its contents, not that the name it just gained will survive a power cut.
|
||||
fn write_durably(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
use std::io::Write;
|
||||
|
||||
let dir = match path.parent() {
|
||||
Some(p) if !p.as_os_str().is_empty() => p,
|
||||
_ => std::path::Path::new("."),
|
||||
};
|
||||
// Alongside the target, because a rename is only atomic within one filesystem.
|
||||
let mut name = path.file_name().unwrap_or_default().to_os_string();
|
||||
name.push(format!(".tmp{}", std::process::id()));
|
||||
let tmp = dir.join(name);
|
||||
|
||||
let write = |tmp: &std::path::Path| -> std::io::Result<()> {
|
||||
let mut file = std::fs::File::create(tmp)?;
|
||||
file.write_all(bytes)?;
|
||||
file.sync_all()
|
||||
};
|
||||
if let Err(e) = write(&tmp) {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
if let Err(e) = std::fs::rename(&tmp, path) {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// Only Unix lets a directory be opened for this. Elsewhere the rename is as much as
|
||||
// the platform offers, and the file itself is already flushed.
|
||||
#[cfg(unix)]
|
||||
std::fs::File::open(dir)?.sync_all()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn export(ctx: &CliContext, args: ExportArgs) -> CommandResult<usize> {
|
||||
let workspace_ids = resolve_export_workspace_ids(ctx, args.workspace_ids, args.all)?;
|
||||
let workspace_id_refs: Vec<&str> = workspace_ids.iter().map(String::as_str).collect();
|
||||
export::export_data(ExportDataParams {
|
||||
let document = export::export_data(ExportDataParams {
|
||||
query_manager: ctx.query_manager(),
|
||||
yaak_version: env!("CARGO_PKG_VERSION"),
|
||||
export_path: &args.file,
|
||||
workspace_ids: workspace_id_refs,
|
||||
include_private_environments: args.include_private_environments,
|
||||
})
|
||||
.map_err(|e| format!("Failed to export data: {e}"))?;
|
||||
// Flushed before reporting success: an export is a backup, and a backup that the
|
||||
// command called done while it was still only in the page cache is the one case
|
||||
// where saying nothing went wrong is worst.
|
||||
write_durably(&args.file, document.as_bytes())
|
||||
.map_err(|e| format!("Failed to write {}: {e}", args.file.display()))?;
|
||||
|
||||
Ok(workspace_ids.len())
|
||||
}
|
||||
@@ -168,19 +212,6 @@ fn resolve_export_workspace_ids(
|
||||
Ok(workspace_ids)
|
||||
}
|
||||
|
||||
fn read_import_file(path: &std::path::Path) -> CommandResult<String> {
|
||||
fs::read_to_string(path).map_err(|err| {
|
||||
if err.kind() == ErrorKind::InvalidData {
|
||||
format!(
|
||||
"Import file must be UTF-8 text; binary files are not supported: {}",
|
||||
path.display()
|
||||
)
|
||||
} else {
|
||||
format!("Unable to read import file {}: {err}", path.display())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn resources_need_current_workspace(resources: &ImportResources) -> bool {
|
||||
resources.workspaces.iter().any(|w| w.id == "CURRENT_WORKSPACE")
|
||||
|| resources.environments.iter().any(|e| {
|
||||
|
||||
@@ -10,3 +10,5 @@ pub mod response;
|
||||
pub mod send;
|
||||
pub mod template_function;
|
||||
pub mod workspace;
|
||||
|
||||
pub mod test;
|
||||
@@ -0,0 +1,442 @@
|
||||
use crate::cli::TestArgs;
|
||||
use crate::commands::request::resolve_cookie_jar_id;
|
||||
use crate::context::CliContext;
|
||||
use serde::Serialize;
|
||||
use std::collections::HashSet;
|
||||
use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
|
||||
use yaak_assertions::{AssertionReport, HttpAssertions};
|
||||
use yaak_models::models::{Folder, HttpRequest};
|
||||
use yaak_models::queries::any_request::AnyRequest;
|
||||
use yaak_models::util::UpdateSource;
|
||||
use yaak_plugins::events::PluginContext;
|
||||
|
||||
// Reports intentionally contain no names, URLs, selectors, expected/actual values, or transport
|
||||
// error strings (which can embed URLs/tokens). The stored response has details for local inspection.
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CheckResult {
|
||||
assertion_id: String,
|
||||
outcome: String,
|
||||
reason: String,
|
||||
}
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RequestResult {
|
||||
request_id: String,
|
||||
outcome: String,
|
||||
reason: String,
|
||||
checks: Vec<CheckResult>,
|
||||
}
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RunReport {
|
||||
version: u32,
|
||||
exit_code: i32,
|
||||
requests: Vec<RequestResult>,
|
||||
}
|
||||
|
||||
pub async fn run(
|
||||
ctx: &CliContext,
|
||||
args: TestArgs,
|
||||
environment: Option<&str>,
|
||||
cookie_jar: Option<&str>,
|
||||
) -> i32 {
|
||||
match run_inner(ctx, &args, environment, cookie_jar).await {
|
||||
Ok(report) => {
|
||||
if let Err(e) = write_reports(&args, &report) {
|
||||
eprintln!("Could not write test report: {e}");
|
||||
return 2;
|
||||
}
|
||||
for request in &report.requests {
|
||||
println!(
|
||||
"{} {}: {}",
|
||||
request.outcome.to_uppercase(),
|
||||
request.request_id,
|
||||
request.reason
|
||||
);
|
||||
for check in &request.checks {
|
||||
println!(
|
||||
" {} {}: {}",
|
||||
check.outcome.to_uppercase(),
|
||||
check.assertion_id,
|
||||
check.reason
|
||||
);
|
||||
}
|
||||
}
|
||||
let count = |outcome| report.requests.iter().filter(|r| r.outcome == outcome).count();
|
||||
println!(
|
||||
"Test summary: {} passed, {} failed, {} errors, {} unchecked, {} skipped",
|
||||
count("passed"),
|
||||
count("failed"),
|
||||
count("error"),
|
||||
count("unchecked"),
|
||||
count("skipped")
|
||||
);
|
||||
report.exit_code
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("Invalid test configuration: {error}");
|
||||
2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_inner(
|
||||
ctx: &CliContext,
|
||||
args: &TestArgs,
|
||||
environment: Option<&str>,
|
||||
cookie_jar: Option<&str>,
|
||||
) -> Result<RunReport, String> {
|
||||
let (workspace_id, requests) = select_requests(ctx, &args.id)?;
|
||||
let jar = resolve_cookie_jar_id(ctx, &workspace_id, cookie_jar)?;
|
||||
if let Some(id) = environment {
|
||||
ctx.db().get_environment_for_workspace(&workspace_id, id).map_err(|_| {
|
||||
"Selected environment is missing or does not belong to this workspace".to_owned()
|
||||
})?;
|
||||
}
|
||||
// Validate the entire selection before sending anything, including unchecked setup requests.
|
||||
let mut enabled = 0;
|
||||
for request in &requests {
|
||||
yaak_assertions::validate(&request.assertions)
|
||||
.map_err(|e| format!("{}: {e}", request.id))?;
|
||||
enabled += request.assertions.checks.iter().filter(|c| c.enabled).count();
|
||||
}
|
||||
if enabled == 0 {
|
||||
return Err("Selected requests have no enabled assertions".into());
|
||||
}
|
||||
if args.json.is_some() && args.json == args.junit {
|
||||
return Err("JSON and JUnit reports require different files".into());
|
||||
}
|
||||
let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false);
|
||||
let cancel_task = tokio::spawn(async move {
|
||||
if tokio::signal::ctrl_c().await.is_ok() {
|
||||
let _ = cancel_tx.send(true);
|
||||
}
|
||||
});
|
||||
let plugin_context = PluginContext::new(Some("cli".into()), Some(workspace_id));
|
||||
let response_dir = ctx.data_dir().join("responses");
|
||||
let mut report = RunReport { version: 1, exit_code: 0, requests: Vec::new() };
|
||||
let mut stopped = false;
|
||||
for request in requests {
|
||||
if stopped || *cancel_rx.borrow() {
|
||||
report.requests.push(skipped(
|
||||
&request.id,
|
||||
&request.assertions,
|
||||
if *cancel_rx.borrow() { "Canceled" } else { "Stopped after failure" },
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let id = request.id.clone();
|
||||
let definition = request.assertions.clone();
|
||||
let result = send_http_request_with_plugins(SendHttpRequestWithPluginsParams {
|
||||
query_manager: ctx.query_manager(),
|
||||
blob_manager: ctx.blob_manager(),
|
||||
request,
|
||||
environment_id: environment,
|
||||
update_source: UpdateSource::Sync,
|
||||
cookie_jar_id: jar.clone(),
|
||||
response_dir: &response_dir,
|
||||
emit_events_to: None,
|
||||
emit_response_body_chunks_to: None,
|
||||
existing_response: None,
|
||||
plugin_manager: ctx.plugin_manager(),
|
||||
encryption_manager: ctx.encryption_manager.clone(),
|
||||
plugin_context: &plugin_context,
|
||||
cancelled_rx: Some(cancel_rx.clone()),
|
||||
connection_manager: ctx.connection_manager(),
|
||||
})
|
||||
.await;
|
||||
let request_result = match result {
|
||||
Ok(result) if result.response.error.is_none() && !*cancel_rx.borrow() => {
|
||||
summarize(&id, &definition, result.response.assertion_results.as_ref())
|
||||
}
|
||||
_ => RequestResult {
|
||||
request_id: id,
|
||||
outcome: "error".into(),
|
||||
reason: if *cancel_rx.borrow() {
|
||||
"Request canceled"
|
||||
} else {
|
||||
"Request did not complete successfully"
|
||||
}
|
||||
.into(),
|
||||
checks: definition
|
||||
.checks
|
||||
.iter()
|
||||
.map(|c| CheckResult {
|
||||
assertion_id: c.id.clone(),
|
||||
outcome: if c.enabled { "error" } else { "skipped" }.into(),
|
||||
reason: if c.enabled {
|
||||
"Request did not complete successfully"
|
||||
} else {
|
||||
"Disabled"
|
||||
}
|
||||
.into(),
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
};
|
||||
if matches!(request_result.outcome.as_str(), "failed" | "error") {
|
||||
report.exit_code = 1;
|
||||
stopped = args.fail_fast;
|
||||
}
|
||||
report.requests.push(request_result);
|
||||
}
|
||||
if *cancel_rx.borrow() {
|
||||
report.exit_code = 130;
|
||||
}
|
||||
cancel_task.abort();
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn summarize(
|
||||
id: &str,
|
||||
definition: &HttpAssertions,
|
||||
report: Option<&AssertionReport>,
|
||||
) -> RequestResult {
|
||||
let checks: Vec<CheckResult> = report
|
||||
.map(|r| {
|
||||
r.results
|
||||
.iter()
|
||||
.map(|c| CheckResult {
|
||||
assertion_id: c.assertion_id.clone(),
|
||||
outcome: c.outcome.clone(),
|
||||
reason: c.reason.clone(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let enabled = definition.checks.iter().filter(|c| c.enabled).count();
|
||||
let (outcome, reason) = if report.is_some_and(|r| r.error.is_some())
|
||||
|| checks.iter().any(|c| matches!(c.outcome.as_str(), "error" | "invalid"))
|
||||
{
|
||||
("error", "Assertions could not be evaluated")
|
||||
} else if checks.iter().any(|c| c.outcome == "failed") {
|
||||
("failed", "Assertions failed")
|
||||
} else if enabled == 0 {
|
||||
("unchecked", "No enabled assertions")
|
||||
} else if checks.iter().filter(|c| c.outcome == "passed").count() != enabled {
|
||||
("error", "Assertion results are incomplete")
|
||||
} else {
|
||||
("passed", "All enabled assertions passed")
|
||||
};
|
||||
RequestResult { request_id: id.into(), outcome: outcome.into(), reason: reason.into(), checks }
|
||||
}
|
||||
|
||||
fn skipped(id: &str, definition: &HttpAssertions, reason: &str) -> RequestResult {
|
||||
RequestResult {
|
||||
request_id: id.into(),
|
||||
outcome: "skipped".into(),
|
||||
reason: reason.into(),
|
||||
checks: definition
|
||||
.checks
|
||||
.iter()
|
||||
.map(|c| CheckResult {
|
||||
assertion_id: c.id.clone(),
|
||||
outcome: "skipped".into(),
|
||||
reason: reason.into(),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn select_requests(ctx: &CliContext, id: &str) -> Result<(String, Vec<HttpRequest>), String> {
|
||||
let db = ctx.db();
|
||||
if let Ok(request) = db.get_any_request(id) {
|
||||
return match request {
|
||||
AnyRequest::HttpRequest(r) => Ok((r.workspace_id.clone(), vec![r])),
|
||||
_ => Err("Assertions currently support HTTP requests only".into()),
|
||||
};
|
||||
}
|
||||
let (workspace_id, parent) = if let Ok(folder) = db.get_folder(id) {
|
||||
(folder.workspace_id, Some(folder.id))
|
||||
} else if let Ok(workspace) = db.get_workspace(id) {
|
||||
(workspace.id, None)
|
||||
} else {
|
||||
return Err("Could not resolve request, folder, or workspace".into());
|
||||
};
|
||||
let folders = db.list_folders(&workspace_id).map_err(|e| e.to_string())?;
|
||||
let requests = db.list_http_requests(&workspace_id).map_err(|e| e.to_string())?;
|
||||
// Refuse mixed protocols rather than silently producing a green partial suite.
|
||||
let mut folder_ids = HashSet::new();
|
||||
let ordered = ordered_requests(&folders, &requests, parent.as_deref(), &mut folder_ids)?;
|
||||
let selected = |folder: Option<&String>| {
|
||||
parent.is_none() || folder.is_some_and(|f| folder_ids.contains(f))
|
||||
};
|
||||
if db
|
||||
.list_grpc_requests(&workspace_id)
|
||||
.map_err(|e| e.to_string())?
|
||||
.iter()
|
||||
.any(|r| selected(r.folder_id.as_ref()))
|
||||
|| db
|
||||
.list_websocket_requests(&workspace_id)
|
||||
.map_err(|e| e.to_string())?
|
||||
.iter()
|
||||
.any(|r| selected(r.folder_id.as_ref()))
|
||||
{
|
||||
return Err(
|
||||
"Selection includes gRPC or WebSocket requests; choose an HTTP request or folder"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if parent.is_none() && ordered.len() != requests.len() {
|
||||
return Err("Request folder hierarchy is invalid".into());
|
||||
}
|
||||
Ok((workspace_id, ordered))
|
||||
}
|
||||
|
||||
fn ordered_requests(
|
||||
folders: &[Folder],
|
||||
requests: &[HttpRequest],
|
||||
parent: Option<&str>,
|
||||
visited: &mut HashSet<String>,
|
||||
) -> Result<Vec<HttpRequest>, String> {
|
||||
if let Some(id) = parent {
|
||||
if !visited.insert(id.to_owned()) {
|
||||
return Err("Request folder hierarchy contains a cycle".into());
|
||||
}
|
||||
}
|
||||
let mut children: Vec<_> = folders
|
||||
.iter()
|
||||
.filter(|f| f.folder_id.as_deref() == parent)
|
||||
.map(|f| (f.sort_priority, f.created_at, f.id.as_str(), None::<&HttpRequest>))
|
||||
.collect();
|
||||
children.extend(
|
||||
requests
|
||||
.iter()
|
||||
.filter(|r| r.folder_id.as_deref() == parent)
|
||||
.map(|r| (r.sort_priority, r.created_at, r.id.as_str(), Some(r))),
|
||||
);
|
||||
children.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)).then(a.2.cmp(b.2)));
|
||||
let mut ordered = Vec::new();
|
||||
for (_, _, id, request) in children {
|
||||
if let Some(request) = request {
|
||||
ordered.push(request.clone());
|
||||
} else {
|
||||
ordered.extend(ordered_requests(folders, requests, Some(id), visited)?);
|
||||
}
|
||||
}
|
||||
Ok(ordered)
|
||||
}
|
||||
|
||||
fn write_reports(args: &TestArgs, report: &RunReport) -> Result<(), String> {
|
||||
if let Some(path) = &args.json {
|
||||
std::fs::write(path, serde_json::to_vec_pretty(report).map_err(|e| e.to_string())?)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
if let Some(path) = &args.junit {
|
||||
std::fs::write(path, junit(report)).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn xml(s: &str) -> String {
|
||||
s.chars()
|
||||
.filter(|&c| {
|
||||
matches!(c, '\t' | '\n' | '\r') || c >= ' ' && c != '\u{fffe}' && c != '\u{ffff}'
|
||||
})
|
||||
.collect::<String>()
|
||||
.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
fn junit(report: &RunReport) -> String {
|
||||
let mut cases = Vec::new();
|
||||
for request in &report.requests {
|
||||
for check in &request.checks {
|
||||
cases.push((
|
||||
request.request_id.as_str(),
|
||||
check.assertion_id.as_str(),
|
||||
check.outcome.as_str(),
|
||||
check.reason.as_str(),
|
||||
));
|
||||
}
|
||||
if request.checks.is_empty()
|
||||
|| request.outcome == "error"
|
||||
&& !request.checks.iter().any(|c| c.outcome == "error" || c.outcome == "invalid")
|
||||
{
|
||||
cases.push((
|
||||
request.request_id.as_str(),
|
||||
"request",
|
||||
if request.outcome == "unchecked" { "skipped" } else { &request.outcome },
|
||||
request.reason.as_str(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let count = |outcome| cases.iter().filter(|c| c.2 == outcome).count();
|
||||
let mut out = format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<testsuite name=\"Yaak\" tests=\"{}\" failures=\"{}\" errors=\"{}\" skipped=\"{}\">\n",
|
||||
cases.len(),
|
||||
count("failed"),
|
||||
count("error") + count("invalid"),
|
||||
count("skipped")
|
||||
);
|
||||
for (request, check, outcome, reason) in cases {
|
||||
out.push_str(&format!(
|
||||
" <testcase classname=\"{}\" name=\"{}\">",
|
||||
xml(request),
|
||||
xml(check)
|
||||
));
|
||||
let tag = match outcome {
|
||||
"failed" => Some("failure"),
|
||||
"error" | "invalid" => Some("error"),
|
||||
"skipped" => Some("skipped"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(tag) = tag {
|
||||
out.push_str(&format!("<{tag} message=\"{}\"/>", xml(reason)));
|
||||
}
|
||||
out.push_str("</testcase>\n");
|
||||
}
|
||||
out.push_str("</testsuite>\n");
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn sidebar_order_interleaves_folders_and_requests() {
|
||||
let folders =
|
||||
vec![Folder { id: "folder".into(), sort_priority: 2.0, ..Default::default() }];
|
||||
let requests = vec![
|
||||
HttpRequest { id: "last".into(), sort_priority: 3.0, ..Default::default() },
|
||||
HttpRequest {
|
||||
id: "nested".into(),
|
||||
folder_id: Some("folder".into()),
|
||||
..Default::default()
|
||||
},
|
||||
HttpRequest { id: "first".into(), sort_priority: 1.0, ..Default::default() },
|
||||
];
|
||||
let ordered = ordered_requests(&folders, &requests, None, &mut HashSet::new()).unwrap();
|
||||
assert_eq!(
|
||||
ordered.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(),
|
||||
vec!["first", "nested", "last"]
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn junit_escapes_attributes_and_counts_all_outcomes() {
|
||||
let report = RunReport {
|
||||
version: 1,
|
||||
exit_code: 1,
|
||||
requests: vec![
|
||||
RequestResult {
|
||||
request_id: "rq_<&\"".into(),
|
||||
outcome: "failed".into(),
|
||||
reason: "Assertions failed".into(),
|
||||
checks: vec![CheckResult {
|
||||
assertion_id: "check".into(),
|
||||
outcome: "failed".into(),
|
||||
reason: "Value did not match".into(),
|
||||
}],
|
||||
},
|
||||
skipped("rq_skip", &HttpAssertions::default(), "Stopped after failure"),
|
||||
],
|
||||
};
|
||||
let xml = junit(&report);
|
||||
assert!(xml.contains("tests=\"2\" failures=\"1\" errors=\"0\" skipped=\"1\""));
|
||||
assert!(xml.contains("classname=\"rq_<&"\""));
|
||||
assert!(xml.contains("<skipped "));
|
||||
}
|
||||
}
|
||||
@@ -129,6 +129,32 @@ async fn main() {
|
||||
}
|
||||
}
|
||||
}
|
||||
Commands::Test(args) => {
|
||||
let mut context = CliContext::new(data_dir.clone(), app_id);
|
||||
match resolve_send_execution_context(
|
||||
&context,
|
||||
&args.id,
|
||||
environment.as_deref(),
|
||||
cookie_jar.as_deref(),
|
||||
) {
|
||||
Ok(execution_context) => {
|
||||
context.init_plugins(execution_context).await;
|
||||
let exit_code = commands::test::run(
|
||||
&context,
|
||||
args,
|
||||
environment.as_deref(),
|
||||
cookie_jar.as_deref(),
|
||||
)
|
||||
.await;
|
||||
context.shutdown().await;
|
||||
exit_code
|
||||
}
|
||||
Err(error) => {
|
||||
eprintln!("Error: {error}");
|
||||
2
|
||||
}
|
||||
}
|
||||
}
|
||||
Commands::CookieJar(args) => {
|
||||
let context = CliContext::new(data_dir.clone(), app_id);
|
||||
let exit_code = commands::cookie_jar::run(&context, args);
|
||||
|
||||
@@ -182,6 +182,31 @@ async fn build_plugin_reply(
|
||||
http_request.workspace_id = workspace_id;
|
||||
}
|
||||
|
||||
let environment_id = if let Some(environment_id) =
|
||||
send_http_request_request.environment_id.as_deref()
|
||||
{
|
||||
if shared_workspace_id.is_some_and(|id| id != http_request.workspace_id) {
|
||||
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
error: "HTTP request does not belong to the selected workspace"
|
||||
.to_string(),
|
||||
}));
|
||||
}
|
||||
match host_context
|
||||
.query_manager
|
||||
.connect()
|
||||
.get_environment_for_workspace(&http_request.workspace_id, environment_id)
|
||||
{
|
||||
Ok(environment) => Some(environment.id),
|
||||
Err(err) => {
|
||||
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
error: err.to_string(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
execution_context.environment_id.clone()
|
||||
};
|
||||
|
||||
let cookie_jar_id =
|
||||
if let Some(cookie_jar_id) = execution_context.cookie_jar_id.clone() {
|
||||
Some(cookie_jar_id)
|
||||
@@ -211,7 +236,7 @@ async fn build_plugin_reply(
|
||||
query_manager: &host_context.query_manager,
|
||||
blob_manager: &host_context.blob_manager,
|
||||
request: http_request,
|
||||
environment_id: execution_context.environment_id.as_deref(),
|
||||
environment_id: environment_id.as_deref(),
|
||||
update_source: UpdateSource::Plugin,
|
||||
cookie_jar_id,
|
||||
response_dir: &host_context.response_dir,
|
||||
@@ -1003,3 +1028,184 @@ fn prompt_label_for_base(base: &yaak_plugins::events::FormInputBase) -> String {
|
||||
}
|
||||
base.name.clone()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod environment_tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::time::{Duration, timeout};
|
||||
use yaak_models::models::{EnvironmentVariable, HttpRequest, HttpRequestHeader, Workspace};
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_send_uses_override_and_fallback_and_rejects_invalid_ids_before_sending() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let (received_tx, mut received_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let server = tokio::spawn(async move {
|
||||
loop {
|
||||
let (mut socket, _) = listener.accept().await.unwrap();
|
||||
let mut request = Vec::new();
|
||||
loop {
|
||||
let mut chunk = [0u8; 1024];
|
||||
let n = socket.read(&mut chunk).await.unwrap();
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
request.extend_from_slice(&chunk[..n]);
|
||||
if request.windows(4).any(|w| w == b"\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let request = String::from_utf8(request).unwrap();
|
||||
eprintln!("Local HTTP test received on {address}:\n{request}");
|
||||
received_tx.send(request).unwrap();
|
||||
socket
|
||||
.write_all(
|
||||
b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
});
|
||||
let (query_manager, blob_manager, _rx) = yaak_models::init_standalone(
|
||||
&dir.path().join("db.sqlite"),
|
||||
&dir.path().join("blobs.sqlite"),
|
||||
)
|
||||
.unwrap();
|
||||
let (base, a, b, request) = query_manager
|
||||
.with_tx(|db| {
|
||||
let source = &UpdateSource::Background;
|
||||
// Fresh databases no longer come with a workspace, so make one
|
||||
let workspace = db.upsert_workspace(
|
||||
&Workspace { name: "Test".into(), ..Default::default() },
|
||||
source,
|
||||
)?;
|
||||
let vars = |value: &str| {
|
||||
vec![EnvironmentVariable {
|
||||
enabled: true,
|
||||
name: "marker".into(),
|
||||
value: value.into(),
|
||||
..Default::default()
|
||||
}]
|
||||
};
|
||||
let base = db.ensure_base_environment(&workspace.id)?;
|
||||
let base = db.upsert_environment(
|
||||
&Environment { variables: vars("global"), ..base },
|
||||
source,
|
||||
)?;
|
||||
let sub = |name: &str| {
|
||||
db.upsert_environment(
|
||||
&Environment {
|
||||
name: name.into(),
|
||||
workspace_id: workspace.id.clone(),
|
||||
parent_model: "environment".into(),
|
||||
parent_id: Some(base.id.clone()),
|
||||
variables: vars(name),
|
||||
..Default::default()
|
||||
},
|
||||
source,
|
||||
)
|
||||
};
|
||||
let a = sub("a")?;
|
||||
let b = sub("b")?;
|
||||
let request = db.upsert_http_request(
|
||||
&HttpRequest {
|
||||
workspace_id: workspace.id,
|
||||
url: format!("http://{address}/echo"),
|
||||
method: "GET".into(),
|
||||
headers: vec![HttpRequestHeader {
|
||||
enabled: true,
|
||||
name: "X-Environment".into(),
|
||||
value: "${[ marker ]}".into(),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
source,
|
||||
)?;
|
||||
Ok::<_, yaak_models::error::Error>((base, a, b, request))
|
||||
})
|
||||
.unwrap();
|
||||
let plugin_dir = dir.path().join("plugins");
|
||||
std::fs::create_dir_all(&plugin_dir).unwrap();
|
||||
let plugin_manager = Arc::new(
|
||||
PluginManager::new(
|
||||
plugin_dir.clone(),
|
||||
plugin_dir,
|
||||
PathBuf::from("node"),
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../crates-tauri/yaak-app-client/vendored/plugin-runtime/index.cjs"),
|
||||
&query_manager,
|
||||
&PluginContext::new_empty(),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
let host = CliHostContext {
|
||||
encryption_manager: Arc::new(EncryptionManager::new(
|
||||
query_manager.clone(),
|
||||
"yaak-test",
|
||||
)),
|
||||
query_manager,
|
||||
blob_manager,
|
||||
plugin_manager: plugin_manager.clone(),
|
||||
connection_manager: Arc::new(HttpConnectionManager::new()),
|
||||
response_dir: dir.path().join("responses"),
|
||||
execution_context: CliExecutionContext {
|
||||
workspace_id: Some(base.workspace_id.clone()),
|
||||
environment_id: Some(a.id.clone()),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
std::fs::create_dir_all(&host.response_dir).unwrap();
|
||||
let event = |environment_id: Option<&str>| -> InternalEvent {
|
||||
let mut payload =
|
||||
json!({ "type": "send_http_request_request", "httpRequest": request });
|
||||
if let Some(id) = environment_id {
|
||||
payload["environmentId"] = json!(id);
|
||||
}
|
||||
serde_json::from_value(json!({
|
||||
"id": "test", "pluginRefId": "test", "pluginName": "test", "replyId": null,
|
||||
"context": PluginContext::new_empty(), "payload": payload
|
||||
}))
|
||||
.unwrap()
|
||||
};
|
||||
for (id, marker) in [
|
||||
(Some(b.id.as_str()), "b"),
|
||||
(None, "a"),
|
||||
(Some(base.id.as_str()), "global"),
|
||||
] {
|
||||
let reply =
|
||||
timeout(Duration::from_secs(10), build_plugin_reply(&host, &event(id), "test"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(reply, Some(InternalEventPayload::SendHttpRequestResponse(ref r)) if r.http_response.status == 200),
|
||||
"{reply:?}"
|
||||
);
|
||||
let received = received_rx.recv().await.unwrap().to_lowercase();
|
||||
assert!(received.contains(&format!("x-environment: {marker}\r\n")), "{received}");
|
||||
assert_eq!(host.execution_context.environment_id.as_deref(), Some(a.id.as_str()));
|
||||
}
|
||||
for id in ["", "ev_missing"] {
|
||||
let reply = build_plugin_reply(&host, &event(Some(id)), "test").await;
|
||||
assert!(matches!(reply, Some(InternalEventPayload::ErrorResponse(_))), "{reply:?}");
|
||||
}
|
||||
assert!(received_rx.try_recv().is_err());
|
||||
assert_eq!(
|
||||
host.query_manager
|
||||
.connect()
|
||||
.list_http_responses_for_request(&request.id, None)
|
||||
.unwrap()
|
||||
.len(),
|
||||
3
|
||||
);
|
||||
plugin_manager.terminate().await;
|
||||
server.abort();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
mod common;
|
||||
|
||||
use common::http_server::TestHttpServer;
|
||||
use common::{cli_cmd, query_manager, seed_workspace};
|
||||
use predicates::str::contains;
|
||||
use tempfile::TempDir;
|
||||
use yaak_models::models::{HttpAssertion, HttpAssertions, HttpRequest};
|
||||
use yaak_models::util::UpdateSource;
|
||||
|
||||
fn request(id: &str, url: &str, expected: &str) -> HttpRequest {
|
||||
HttpRequest {
|
||||
id: id.into(),
|
||||
workspace_id: "wk_test".into(),
|
||||
url: url.into(),
|
||||
name: "SECRET_REQUEST_NAME".into(),
|
||||
assertions: HttpAssertions {
|
||||
version: 1,
|
||||
checks: vec![HttpAssertion {
|
||||
id: "assertion".into(),
|
||||
selector: "$.token".into(),
|
||||
expected: expected.into(),
|
||||
..Default::default()
|
||||
}],
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
fn save(dir: &std::path::Path, request: &HttpRequest) {
|
||||
query_manager(dir).with_tx(|tx| tx.upsert_http_request(request, &UpdateSource::Sync)).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sends_assertions_persists_snapshots_and_writes_safe_reports() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
seed_workspace(dir.path(), "wk_test");
|
||||
let server = TestHttpServer::spawn_ok(r#"{"token":"SECRET_TOKEN"}"#);
|
||||
let mut req = request("rq_test", &server.url, "SECRET_TOKEN");
|
||||
save(dir.path(), &req);
|
||||
cli_cmd(dir.path())
|
||||
.args(["test", "rq_test", "--environment", "missing"])
|
||||
.assert()
|
||||
.code(2)
|
||||
.stderr(contains("Selected environment"));
|
||||
assert!(
|
||||
query_manager(dir.path())
|
||||
.connect()
|
||||
.list_http_responses("wk_test", None)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
let json = dir.path().join("results.json");
|
||||
let junit = dir.path().join("results.xml");
|
||||
let output = cli_cmd(dir.path())
|
||||
.args(["test", "rq_test", "--json"])
|
||||
.arg(&json)
|
||||
.arg("--junit")
|
||||
.arg(&junit)
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("1 passed, 0 failed"))
|
||||
.get_output()
|
||||
.clone();
|
||||
for text in [
|
||||
String::from_utf8_lossy(&output.stdout).into_owned(),
|
||||
std::fs::read_to_string(&json).unwrap(),
|
||||
std::fs::read_to_string(&junit).unwrap(),
|
||||
] {
|
||||
assert!(!text.contains("SECRET"));
|
||||
assert!(!text.contains(&server.url));
|
||||
assert!(!text.contains("$.token"));
|
||||
}
|
||||
let report: serde_json::Value = serde_json::from_slice(&std::fs::read(json).unwrap()).unwrap();
|
||||
assert_eq!(report["requests"][0]["checks"][0]["outcome"], "passed");
|
||||
let manager = query_manager(dir.path());
|
||||
let history = manager.connect().list_http_responses("wk_test", None).unwrap();
|
||||
assert_eq!(history.len(), 1);
|
||||
let original_id = history[0].id.clone();
|
||||
assert_eq!(history[0].assertion_results.as_ref().unwrap().definition, req.assertions);
|
||||
req.assertions.checks[0].expected = "DIFFERENT_SECRET".into();
|
||||
save(dir.path(), &req);
|
||||
assert_eq!(
|
||||
manager
|
||||
.connect()
|
||||
.get_http_response(&original_id)
|
||||
.unwrap()
|
||||
.assertion_results
|
||||
.unwrap()
|
||||
.definition
|
||||
.checks[0]
|
||||
.expected,
|
||||
"SECRET_TOKEN"
|
||||
);
|
||||
cli_cmd(dir.path()).args(["test", "rq_test"]).assert().code(1).stdout(contains("1 failed"));
|
||||
// Ordinary send still prints the response body and succeeds even when a check fails.
|
||||
cli_cmd(dir.path())
|
||||
.args(["send", "rq_test"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("SECRET_TOKEN"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_suite_and_empty_suite_do_not_send_and_fail_fast_records_skips() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
seed_workspace(dir.path(), "wk_test");
|
||||
let server = TestHttpServer::spawn_ok(r#"{"token":"actual"}"#);
|
||||
let mut first = request("rq_a", &server.url, "different");
|
||||
first.sort_priority = 0.0;
|
||||
let mut second = request("rq_b", &server.url, "actual");
|
||||
second.sort_priority = 1.0;
|
||||
second.assertions.checks[0].selector = "$[".into();
|
||||
save(dir.path(), &first);
|
||||
save(dir.path(), &second);
|
||||
cli_cmd(dir.path())
|
||||
.args(["test", "wk_test"])
|
||||
.assert()
|
||||
.code(2)
|
||||
.stderr(contains("valid JSONPath"));
|
||||
assert!(
|
||||
query_manager(dir.path())
|
||||
.connect()
|
||||
.list_http_responses("wk_test", None)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
second.assertions.checks[0].selector = "token".into();
|
||||
save(dir.path(), &second);
|
||||
let json = dir.path().join("results.json");
|
||||
cli_cmd(dir.path())
|
||||
.args(["test", "wk_test", "--fail-fast", "--json"])
|
||||
.arg(&json)
|
||||
.assert()
|
||||
.code(1)
|
||||
.stdout(contains("1 failed, 0 errors, 0 unchecked, 1 skipped"));
|
||||
let report: serde_json::Value = serde_json::from_slice(&std::fs::read(json).unwrap()).unwrap();
|
||||
assert_eq!(report["requests"][1]["requestId"], "rq_b");
|
||||
assert_eq!(report["requests"][1]["outcome"], "skipped");
|
||||
assert_eq!(
|
||||
query_manager(dir.path()).connect().list_http_responses("wk_test", None).unwrap().len(),
|
||||
1
|
||||
);
|
||||
first.assertions.checks.clear();
|
||||
save(dir.path(), &first);
|
||||
cli_cmd(dir.path())
|
||||
.args(["test", "rq_a"])
|
||||
.assert()
|
||||
.code(2)
|
||||
.stderr(contains("no enabled assertions"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_errors_are_distinct_from_failed_comparisons() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
seed_workspace(dir.path(), "wk_test");
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let url = format!("http://{}", listener.local_addr().unwrap());
|
||||
drop(listener);
|
||||
save(dir.path(), &request("rq_error", &url, "SECRET"));
|
||||
cli_cmd(dir.path())
|
||||
.args(["test", "rq_error"])
|
||||
.assert()
|
||||
.code(1)
|
||||
.stdout(contains("0 failed, 1 errors"));
|
||||
}
|
||||
@@ -27,6 +27,10 @@ impl TestHttpServer {
|
||||
while !shutdown_signal.load(Ordering::Relaxed) {
|
||||
match listener.accept() {
|
||||
Ok((mut stream, _)) => {
|
||||
// macOS and the BSDs hand back accepted sockets carrying the
|
||||
// listener's non-blocking flag, which leaves the read timeout
|
||||
// below with nothing to govern.
|
||||
let _ = stream.set_nonblocking(false);
|
||||
let _ = stream.set_read_timeout(Some(Duration::from_secs(1)));
|
||||
let mut request = Vec::new();
|
||||
let mut request_buf = [0u8; 4096];
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
use crate::data::{self, Post, Todo, User};
|
||||
use crate::error::{ApiError, ApiResult, parse_id, parse_json};
|
||||
use crate::{AppState, ClientIp};
|
||||
use axum::extract::rejection::BytesRejection;
|
||||
use axum::extract::{Extension, Path, Query, State};
|
||||
use axum::http::{HeaderValue, StatusCode, header};
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use bytes::Bytes;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
|
||||
const MAX_TITLE_CHARS: usize = 200;
|
||||
const MAX_BODY_CHARS: usize = 2000;
|
||||
|
||||
type Params = Query<HashMap<String, String>>;
|
||||
|
||||
struct Filters {
|
||||
user_id: Option<u64>,
|
||||
completed: Option<bool>,
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
fn filters(params: &HashMap<String, String>) -> ApiResult<Filters> {
|
||||
fn parse<T: std::str::FromStr>(
|
||||
params: &HashMap<String, String>,
|
||||
name: &str,
|
||||
expected: &str,
|
||||
) -> ApiResult<Option<T>> {
|
||||
params
|
||||
.get(name)
|
||||
.map(|v| {
|
||||
v.parse().map_err(|_| {
|
||||
ApiError::bad_request(format!("Query parameter `{name}` must be {expected}"))
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
Ok(Filters {
|
||||
user_id: parse(params, "userId", "a user ID")?,
|
||||
completed: parse(params, "completed", "true or false")?,
|
||||
limit: parse(params, "limit", "a number")?,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn list_users() -> Json<&'static [User]> {
|
||||
Json(data::users())
|
||||
}
|
||||
|
||||
pub async fn get_user(Path(id): Path<String>) -> ApiResult<Json<&'static User>> {
|
||||
let user_id = parse_id(&id, "user")?;
|
||||
data::user(user_id)
|
||||
.map(Json)
|
||||
.ok_or_else(|| ApiError::not_found(format!("No user with ID {id}")))
|
||||
}
|
||||
|
||||
pub async fn list_todos(Query(params): Params) -> ApiResult<Json<Vec<&'static Todo>>> {
|
||||
let f = filters(¶ms)?;
|
||||
Ok(Json(select_todos(f.user_id, f.completed, f.limit)))
|
||||
}
|
||||
|
||||
pub async fn list_user_todos(
|
||||
Path(id): Path<String>,
|
||||
Query(params): Params,
|
||||
) -> ApiResult<Json<Vec<&'static Todo>>> {
|
||||
let user = get_user(Path(id)).await?;
|
||||
let f = filters(¶ms)?;
|
||||
Ok(Json(select_todos(Some(user.id), f.completed, f.limit)))
|
||||
}
|
||||
|
||||
fn select_todos(
|
||||
user_id: Option<u64>,
|
||||
completed: Option<bool>,
|
||||
limit: Option<usize>,
|
||||
) -> Vec<&'static Todo> {
|
||||
data::todos()
|
||||
.iter()
|
||||
.filter(|t| user_id.is_none_or(|id| t.user_id == id))
|
||||
.filter(|t| completed.is_none_or(|c| t.completed == c))
|
||||
.take(limit.unwrap_or(usize::MAX))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn list_posts(
|
||||
State(state): State<AppState>,
|
||||
Extension(ClientIp(ip)): Extension<ClientIp>,
|
||||
Query(params): Params,
|
||||
) -> ApiResult<Json<Vec<Post>>> {
|
||||
let f = filters(¶ms)?;
|
||||
let posts = state.posts.read(ip, |posts| {
|
||||
posts
|
||||
.iter()
|
||||
.filter(|p| f.user_id.is_none_or(|id| p.user_id == id))
|
||||
.take(f.limit.unwrap_or(usize::MAX))
|
||||
.cloned()
|
||||
.collect()
|
||||
});
|
||||
Ok(Json(posts))
|
||||
}
|
||||
|
||||
pub async fn get_post(
|
||||
State(state): State<AppState>,
|
||||
Extension(ClientIp(ip)): Extension<ClientIp>,
|
||||
Path(id): Path<String>,
|
||||
) -> ApiResult<Json<Post>> {
|
||||
let post_id = parse_id(&id, "post")?;
|
||||
state
|
||||
.posts
|
||||
.read(ip, |posts| posts.iter().find(|p| p.id == post_id).cloned())
|
||||
.map(Json)
|
||||
.ok_or_else(|| post_not_found(&id))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct NewPost {
|
||||
user_id: u64,
|
||||
title: String,
|
||||
body: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PostPatch {
|
||||
user_id: Option<u64>,
|
||||
title: Option<String>,
|
||||
body: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn create_post(
|
||||
State(state): State<AppState>,
|
||||
Extension(ClientIp(ip)): Extension<ClientIp>,
|
||||
body: Result<Bytes, BytesRejection>,
|
||||
) -> ApiResult<Response> {
|
||||
let input: NewPost = parse_json(body)?;
|
||||
validate(input.user_id, &input.title, &input.body)?;
|
||||
let max = state.posts.max_posts;
|
||||
let post = state.posts.write(ip, |client| {
|
||||
if client.posts.len() >= max {
|
||||
return Err(ApiError::new(
|
||||
StatusCode::CONFLICT,
|
||||
format!(
|
||||
"You can have at most {max} posts. Delete one first, or wait for the reset"
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(client.insert(input.user_id, input.title, input.body))
|
||||
})?;
|
||||
let location = HeaderValue::from_str(&format!("/posts/{}", post.id)).expect("ascii");
|
||||
Ok((StatusCode::CREATED, [(header::LOCATION, location)], Json(post)).into_response())
|
||||
}
|
||||
|
||||
pub async fn replace_post(
|
||||
State(state): State<AppState>,
|
||||
Extension(ClientIp(ip)): Extension<ClientIp>,
|
||||
Path(id): Path<String>,
|
||||
body: Result<Bytes, BytesRejection>,
|
||||
) -> ApiResult<Json<Post>> {
|
||||
let post_id = parse_id(&id, "post")?;
|
||||
let input: NewPost = parse_json(body)?;
|
||||
validate(input.user_id, &input.title, &input.body)?;
|
||||
state.posts.write(ip, |client| {
|
||||
let post =
|
||||
client.posts.iter_mut().find(|p| p.id == post_id).ok_or_else(|| post_not_found(&id))?;
|
||||
post.user_id = input.user_id;
|
||||
post.title = input.title;
|
||||
post.body = input.body;
|
||||
Ok(Json(post.clone()))
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn update_post(
|
||||
State(state): State<AppState>,
|
||||
Extension(ClientIp(ip)): Extension<ClientIp>,
|
||||
Path(id): Path<String>,
|
||||
body: Result<Bytes, BytesRejection>,
|
||||
) -> ApiResult<Json<Post>> {
|
||||
let post_id = parse_id(&id, "post")?;
|
||||
let patch: PostPatch = parse_json(body)?;
|
||||
state.posts.write(ip, |client| {
|
||||
let post =
|
||||
client.posts.iter_mut().find(|p| p.id == post_id).ok_or_else(|| post_not_found(&id))?;
|
||||
let user_id = patch.user_id.unwrap_or(post.user_id);
|
||||
let title = patch.title.unwrap_or_else(|| post.title.clone());
|
||||
let body = patch.body.unwrap_or_else(|| post.body.clone());
|
||||
validate(user_id, &title, &body)?;
|
||||
post.user_id = user_id;
|
||||
post.title = title;
|
||||
post.body = body;
|
||||
Ok(Json(post.clone()))
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn delete_post(
|
||||
State(state): State<AppState>,
|
||||
Extension(ClientIp(ip)): Extension<ClientIp>,
|
||||
Path(id): Path<String>,
|
||||
) -> ApiResult<StatusCode> {
|
||||
let post_id = parse_id(&id, "post")?;
|
||||
state.posts.write(ip, |client| {
|
||||
let index =
|
||||
client.posts.iter().position(|p| p.id == post_id).ok_or_else(|| post_not_found(&id))?;
|
||||
client.posts.remove(index);
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
})
|
||||
}
|
||||
|
||||
fn post_not_found(id: &str) -> ApiError {
|
||||
ApiError::not_found(format!("No post with ID {id}"))
|
||||
}
|
||||
|
||||
fn validate(user_id: u64, title: &str, body: &str) -> ApiResult<()> {
|
||||
if data::user(user_id).is_none() {
|
||||
return Err(ApiError::bad_request(format!("No user with ID {user_id}")));
|
||||
}
|
||||
if title.trim().is_empty() {
|
||||
return Err(ApiError::bad_request("`title` can't be empty"));
|
||||
}
|
||||
if title.chars().count() > MAX_TITLE_CHARS {
|
||||
return Err(ApiError::bad_request(format!(
|
||||
"`title` must be {MAX_TITLE_CHARS} characters or fewer"
|
||||
)));
|
||||
}
|
||||
if body.chars().count() > MAX_BODY_CHARS {
|
||||
return Err(ApiError::bad_request(format!(
|
||||
"`body` must be {MAX_BODY_CHARS} characters or fewer"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"users": [
|
||||
{ "id": 1, "name": "Ada Lovelace", "username": "ada", "email": "ada@example.com" },
|
||||
{ "id": 2, "name": "Grace Hopper", "username": "grace", "email": "grace@example.com" },
|
||||
{ "id": 3, "name": "Alan Turing", "username": "alan", "email": "alan@example.com" },
|
||||
{ "id": 4, "name": "Katherine Johnson", "username": "katherine", "email": "katherine@example.com" },
|
||||
{ "id": 5, "name": "Margaret Hamilton", "username": "margaret", "email": "margaret@example.com" }
|
||||
],
|
||||
"posts": [
|
||||
{
|
||||
"id": 1,
|
||||
"userId": 1,
|
||||
"title": "Notes on the Analytical Engine",
|
||||
"body": "Requests are just messages. The trick is knowing exactly what you sent."
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"userId": 1,
|
||||
"title": "Why I keep my variables in environments",
|
||||
"body": "Switching from staging to production should be one click, not a find and replace."
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"userId": 2,
|
||||
"title": "Easier to ask forgiveness than permission",
|
||||
"body": "Unless the API returns a 403. Then it's time to check your token."
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"userId": 2,
|
||||
"title": "Debugging the first bug",
|
||||
"body": "The Timeline tab shows every redirect, header, and byte. No moths required."
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"userId": 3,
|
||||
"title": "Can machines send requests?",
|
||||
"body": "This one just did. Look through the response headers to see what came back."
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"userId": 3,
|
||||
"title": "On computable status codes",
|
||||
"body": "2xx is good news, 4xx usually means the request needs fixing, and 5xx usually means the server does."
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"userId": 4,
|
||||
"title": "Checking the math on pagination",
|
||||
"body": "Add a limit query parameter to get fewer results back."
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"userId": 4,
|
||||
"title": "Plotting a course between requests",
|
||||
"body": "Pull a value out of one response and send it in the next. That's all chaining is."
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"userId": 5,
|
||||
"title": "Error handling saved the landing",
|
||||
"body": "An API that returns clear errors is an API you can build on."
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"userId": 5,
|
||||
"title": "Logging in, then everything else",
|
||||
"body": "Get a token from one request and use it for the rest. Set it once on a folder and every request inside inherits it."
|
||||
}
|
||||
],
|
||||
"todos": [
|
||||
{ "id": 1, "userId": 1, "title": "Send your first request", "completed": true },
|
||||
{ "id": 2, "userId": 1, "title": "Create an environment", "completed": false },
|
||||
{ "id": 3, "userId": 1, "title": "Switch to a sub-environment", "completed": false },
|
||||
{ "id": 4, "userId": 2, "title": "Import an OpenAPI spec", "completed": true },
|
||||
{ "id": 5, "userId": 2, "title": "Add a header to a folder", "completed": false },
|
||||
{ "id": 6, "userId": 2, "title": "Read the response timeline", "completed": true },
|
||||
{ "id": 7, "userId": 3, "title": "Chain a value from another response", "completed": false },
|
||||
{ "id": 8, "userId": 3, "title": "Log in and use the token", "completed": false },
|
||||
{ "id": 9, "userId": 3, "title": "Copy a request as curl", "completed": true },
|
||||
{ "id": 10, "userId": 4, "title": "Sync a workspace to a folder", "completed": false },
|
||||
{ "id": 11, "userId": 4, "title": "Commit the workspace to Git", "completed": false },
|
||||
{ "id": 12, "userId": 4, "title": "Encrypt a secret", "completed": false },
|
||||
{ "id": 13, "userId": 5, "title": "Create a post", "completed": false },
|
||||
{ "id": 14, "userId": 5, "title": "Delete the post you created", "completed": false },
|
||||
{ "id": 15, "userId": 5, "title": "Delete the example workspace", "completed": false }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
use crate::data::{self, Post};
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Posts as each client sees them. A client that has never written reads the sample data; its
|
||||
/// first write takes a private copy, which is thrown away `reset_after` later. Nothing one
|
||||
/// client writes is ever served to another.
|
||||
pub struct PostStore {
|
||||
clients: Mutex<HashMap<IpAddr, ClientPosts>>,
|
||||
reset_after: Duration,
|
||||
max_clients: usize,
|
||||
pub max_posts: usize,
|
||||
}
|
||||
|
||||
pub struct ClientPosts {
|
||||
pub posts: Vec<Post>,
|
||||
next_id: u64,
|
||||
created: Instant,
|
||||
}
|
||||
|
||||
impl ClientPosts {
|
||||
pub fn insert(&mut self, user_id: u64, title: String, body: String) -> Post {
|
||||
let post = Post { id: self.next_id, user_id, title, body };
|
||||
self.next_id += 1;
|
||||
self.posts.push(post.clone());
|
||||
post
|
||||
}
|
||||
}
|
||||
|
||||
impl PostStore {
|
||||
pub fn new(reset_after: Duration, max_clients: usize, max_posts: usize) -> Self {
|
||||
Self { clients: Mutex::new(HashMap::new()), reset_after, max_clients, max_posts }
|
||||
}
|
||||
|
||||
pub fn read<R>(&self, client: IpAddr, f: impl FnOnce(&[Post]) -> R) -> R {
|
||||
let mut clients = self.lock();
|
||||
self.drop_if_expired(&mut clients, client, Instant::now());
|
||||
match clients.get(&client) {
|
||||
Some(c) => f(&c.posts),
|
||||
None => f(data::posts()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write<R>(&self, client: IpAddr, f: impl FnOnce(&mut ClientPosts) -> R) -> R {
|
||||
let mut clients = self.lock();
|
||||
let now = Instant::now();
|
||||
self.drop_if_expired(&mut clients, client, now);
|
||||
|
||||
if !clients.contains_key(&client) {
|
||||
if clients.len() >= self.max_clients {
|
||||
clients.retain(|_, c| now.duration_since(c.created) < self.reset_after);
|
||||
}
|
||||
if clients.len() >= self.max_clients
|
||||
&& let Some(oldest) =
|
||||
clients.iter().min_by_key(|(_, c)| c.created).map(|(ip, _)| *ip)
|
||||
{
|
||||
clients.remove(&oldest);
|
||||
}
|
||||
let posts = data::posts().to_vec();
|
||||
let next_id = posts.iter().map(|p| p.id).max().unwrap_or(0) + 1;
|
||||
clients.insert(client, ClientPosts { posts, next_id, created: now });
|
||||
}
|
||||
|
||||
f(clients.get_mut(&client).expect("inserted above"))
|
||||
}
|
||||
|
||||
fn lock(&self) -> MutexGuard<'_, HashMap<IpAddr, ClientPosts>> {
|
||||
self.clients.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn drop_if_expired(
|
||||
&self,
|
||||
clients: &mut HashMap<IpAddr, ClientPosts>,
|
||||
client: IpAddr,
|
||||
now: Instant,
|
||||
) {
|
||||
if clients.get(&client).is_some_and(|c| now.duration_since(c.created) >= self.reset_after) {
|
||||
clients.remove(&client);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
use axum::Router;
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::extract::ConnectInfo;
|
||||
use axum::http::{HeaderMap, Request, StatusCode, header};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeSet;
|
||||
use std::net::SocketAddr;
|
||||
use tower::ServiceExt;
|
||||
use yaak_playground::{Config, router};
|
||||
|
||||
const ALICE: [u8; 4] = [203, 0, 113, 1];
|
||||
const BOB: [u8; 4] = [203, 0, 113, 2];
|
||||
|
||||
fn config() -> Config {
|
||||
Config {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 0,
|
||||
rate_limit_per_minute: 1000,
|
||||
trust_forwarded_for: false,
|
||||
reset_after_secs: 3600,
|
||||
max_clients: 10,
|
||||
max_posts_per_client: 20,
|
||||
max_request_bytes: 4096,
|
||||
}
|
||||
}
|
||||
|
||||
struct Res {
|
||||
status: StatusCode,
|
||||
headers: HeaderMap,
|
||||
json: Value,
|
||||
}
|
||||
|
||||
async fn call(
|
||||
app: &Router,
|
||||
client: [u8; 4],
|
||||
method: &str,
|
||||
uri: &str,
|
||||
headers: &[(&str, &str)],
|
||||
body: Option<Value>,
|
||||
) -> Res {
|
||||
let mut req = Request::builder().method(method).uri(uri);
|
||||
for (name, value) in headers {
|
||||
req = req.header(*name, *value);
|
||||
}
|
||||
let body = match body {
|
||||
Some(v) => Body::from(v.to_string()),
|
||||
None => Body::empty(),
|
||||
};
|
||||
let mut req = req.body(body).unwrap();
|
||||
req.extensions_mut().insert(ConnectInfo(SocketAddr::from((client, 40000))));
|
||||
let res = app.clone().oneshot(req).await.unwrap();
|
||||
let status = res.status();
|
||||
let headers = res.headers().clone();
|
||||
let bytes = to_bytes(res.into_body(), 1024 * 1024).await.unwrap();
|
||||
let json = if bytes.is_empty() { Value::Null } else { serde_json::from_slice(&bytes).unwrap() };
|
||||
Res { status, headers, json }
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn users_and_todos() {
|
||||
let app = router(config());
|
||||
let res = call(&app, ALICE, "GET", "/users", &[], None).await;
|
||||
assert_eq!(res.status, StatusCode::OK);
|
||||
assert_eq!(res.json[0]["username"], "ada");
|
||||
|
||||
let res = call(&app, ALICE, "GET", "/users/2", &[], None).await;
|
||||
assert_eq!(res.json["name"], "Grace Hopper");
|
||||
|
||||
for uri in ["/users/99", "/users/abc", "/users/99/todos"] {
|
||||
let res = call(&app, ALICE, "GET", uri, &[], None).await;
|
||||
assert_eq!(res.status, StatusCode::NOT_FOUND, "{uri}");
|
||||
assert!(res.json["error"].is_string());
|
||||
}
|
||||
|
||||
let res = call(&app, ALICE, "GET", "/users/1/todos?completed=false&limit=1", &[], None).await;
|
||||
assert_eq!(res.json.as_array().unwrap().len(), 1);
|
||||
assert_eq!(res.json[0]["userId"], 1);
|
||||
assert_eq!(res.json[0]["completed"], false);
|
||||
|
||||
let res = call(&app, ALICE, "GET", "/todos?userId=3", &[], None).await;
|
||||
assert!(res.json.as_array().unwrap().iter().all(|t| t["userId"] == 3));
|
||||
|
||||
let res = call(&app, ALICE, "GET", "/todos?completed=maybe", &[], None).await;
|
||||
assert_eq!(res.status, StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn writes_are_private_to_the_client() {
|
||||
let app = router(config());
|
||||
let new_post = json!({ "userId": 1, "title": "Hello", "body": "From Yaak" });
|
||||
let res = call(&app, ALICE, "POST", "/posts", &[], Some(new_post)).await;
|
||||
assert_eq!(res.status, StatusCode::CREATED);
|
||||
let id = res.json["id"].as_u64().unwrap();
|
||||
assert_eq!(res.headers[header::LOCATION], format!("/posts/{id}"));
|
||||
|
||||
let uri = format!("/posts/{id}");
|
||||
assert_eq!(call(&app, ALICE, "GET", &uri, &[], None).await.status, StatusCode::OK);
|
||||
assert_eq!(call(&app, BOB, "GET", &uri, &[], None).await.status, StatusCode::NOT_FOUND);
|
||||
|
||||
let res = call(&app, ALICE, "PATCH", &uri, &[], Some(json!({ "title": "Edited" }))).await;
|
||||
assert_eq!(res.json["title"], "Edited");
|
||||
assert_eq!(res.json["body"], "From Yaak");
|
||||
|
||||
let replacement = json!({ "userId": 2, "title": "Replaced", "body": "" });
|
||||
let res = call(&app, ALICE, "PUT", &uri, &[], Some(replacement)).await;
|
||||
assert_eq!(res.json["userId"], 2);
|
||||
|
||||
let res = call(&app, ALICE, "DELETE", "/posts/1", &[], None).await;
|
||||
assert_eq!(res.status, StatusCode::NO_CONTENT);
|
||||
assert_eq!(call(&app, ALICE, "GET", "/posts/1", &[], None).await.status, StatusCode::NOT_FOUND);
|
||||
assert_eq!(call(&app, BOB, "GET", "/posts/1", &[], None).await.status, StatusCode::OK);
|
||||
|
||||
let res = call(&app, ALICE, "GET", "/posts?userId=2", &[], None).await;
|
||||
assert!(res.json.as_array().unwrap().iter().any(|p| p["title"] == "Replaced"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_posts_are_rejected() {
|
||||
let app = router(config());
|
||||
let cases = [
|
||||
json!({ "title": "No user", "body": "" }),
|
||||
json!({ "userId": 99, "title": "Unknown user", "body": "" }),
|
||||
json!({ "userId": 1, "title": " ", "body": "" }),
|
||||
json!({ "userId": 1, "title": "x".repeat(201), "body": "" }),
|
||||
];
|
||||
for body in cases {
|
||||
let res = call(&app, ALICE, "POST", "/posts", &[], Some(body.clone())).await;
|
||||
assert_eq!(res.status, StatusCode::BAD_REQUEST, "{body}");
|
||||
assert!(res.json["error"].is_string());
|
||||
}
|
||||
|
||||
let res = call(&app, ALICE, "POST", "/posts", &[], None).await;
|
||||
assert_eq!(res.status, StatusCode::BAD_REQUEST);
|
||||
|
||||
let huge = json!({ "userId": 1, "title": "Big", "body": "x".repeat(5000) });
|
||||
let res = call(&app, ALICE, "POST", "/posts", &[], Some(huge)).await;
|
||||
assert_eq!(res.status, StatusCode::PAYLOAD_TOO_LARGE);
|
||||
assert!(res.json["error"].is_string());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_cap_and_reset() {
|
||||
let app = router(Config { max_posts_per_client: 11, ..config() });
|
||||
let post = json!({ "userId": 1, "title": "One more", "body": "" });
|
||||
assert_eq!(
|
||||
call(&app, ALICE, "POST", "/posts", &[], Some(post.clone())).await.status,
|
||||
StatusCode::CREATED
|
||||
);
|
||||
assert_eq!(
|
||||
call(&app, ALICE, "POST", "/posts", &[], Some(post)).await.status,
|
||||
StatusCode::CONFLICT
|
||||
);
|
||||
|
||||
let app = router(Config { reset_after_secs: 0, ..config() });
|
||||
call(&app, ALICE, "DELETE", "/posts/1", &[], None).await;
|
||||
assert_eq!(call(&app, ALICE, "GET", "/posts/1", &[], None).await.status, StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bearer_token_flow() {
|
||||
let app = router(config());
|
||||
let res = call(&app, ALICE, "GET", "/auth/me", &[], None).await;
|
||||
assert_eq!(res.status, StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(res.headers[header::WWW_AUTHENTICATE], "Bearer");
|
||||
|
||||
let bad = json!({ "username": "ada", "password": "nope" });
|
||||
assert_eq!(
|
||||
call(&app, ALICE, "POST", "/auth/token", &[], Some(bad)).await.status,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
|
||||
let good = json!({ "username": "grace", "password": "yaak" });
|
||||
let res = call(&app, ALICE, "POST", "/auth/token", &[], Some(good)).await;
|
||||
assert_eq!(res.status, StatusCode::OK);
|
||||
let token = res.json["accessToken"].as_str().unwrap().to_string();
|
||||
|
||||
let auth = format!("Bearer {token}");
|
||||
let res = call(&app, BOB, "GET", "/auth/me", &[("authorization", &auth)], None).await;
|
||||
assert_eq!(res.json["username"], "grace");
|
||||
|
||||
let res =
|
||||
call(&app, ALICE, "GET", "/auth/me", &[("authorization", "Bearer wrong")], None).await;
|
||||
assert_eq!(res.status, StatusCode::UNAUTHORIZED);
|
||||
|
||||
let cookie = format!("session={token}");
|
||||
let res = call(&app, ALICE, "GET", "/auth/session", &[("cookie", &cookie)], None).await;
|
||||
assert_eq!(res.status, StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_cookie_flow() {
|
||||
let app = router(config());
|
||||
let login = json!({ "username": "alan", "password": "yaak" });
|
||||
let res =
|
||||
call(&app, ALICE, "POST", "/auth/session", &[("x-forwarded-proto", "https")], Some(login))
|
||||
.await;
|
||||
assert_eq!(res.status, StatusCode::OK);
|
||||
let set_cookie = res.headers[header::SET_COOKIE].to_str().unwrap();
|
||||
assert!(set_cookie.contains("HttpOnly") && set_cookie.contains("; Secure"), "{set_cookie}");
|
||||
let cookie = set_cookie.split(';').next().unwrap().to_string();
|
||||
|
||||
let headers = [("cookie", cookie.as_str())];
|
||||
let res = call(&app, ALICE, "GET", "/auth/session", &headers, None).await;
|
||||
assert_eq!(res.json["username"], "alan");
|
||||
|
||||
let id = cookie.trim_start_matches("session=");
|
||||
let auth = format!("Bearer {id}");
|
||||
let res = call(&app, ALICE, "GET", "/auth/me", &[("authorization", &auth)], None).await;
|
||||
assert_eq!(res.status, StatusCode::UNAUTHORIZED);
|
||||
|
||||
let res = call(&app, ALICE, "DELETE", "/auth/session", &headers, None).await;
|
||||
assert_eq!(res.status, StatusCode::NO_CONTENT);
|
||||
assert!(res.headers[header::SET_COOKIE].to_str().unwrap().contains("Max-Age=0"));
|
||||
assert_eq!(
|
||||
call(&app, ALICE, "GET", "/auth/session", &headers, None).await.status,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit() {
|
||||
let app = router(Config { rate_limit_per_minute: 2, ..config() });
|
||||
call(&app, ALICE, "GET", "/users", &[], None).await;
|
||||
call(&app, ALICE, "GET", "/users", &[], None).await;
|
||||
let res = call(&app, ALICE, "GET", "/users", &[], None).await;
|
||||
assert_eq!(res.status, StatusCode::TOO_MANY_REQUESTS);
|
||||
assert!(res.headers.contains_key(header::RETRY_AFTER));
|
||||
assert_eq!(call(&app, BOB, "GET", "/users", &[], None).await.status, StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openapi_matches_the_routes() {
|
||||
let app = router(config());
|
||||
let spec = call(&app, ALICE, "GET", "/openapi.json", &[], None).await.json;
|
||||
let mut documented = BTreeSet::new();
|
||||
for (path, item) in spec["paths"].as_object().unwrap() {
|
||||
for (method, _) in item.as_object().unwrap() {
|
||||
let method = method.to_uppercase();
|
||||
let uri = path.replace("{id}", "1");
|
||||
// A fresh client per call, so a DELETE can't hide the GET that follows it
|
||||
let client = [198, 51, 100, documented.len() as u8];
|
||||
let res = call(&app, client, &method, &uri, &[], None).await;
|
||||
assert!(
|
||||
res.status != StatusCode::NOT_FOUND && res.status != StatusCode::METHOD_NOT_ALLOWED,
|
||||
"{method} {path} is documented but not routed ({})",
|
||||
res.status
|
||||
);
|
||||
documented.insert(format!("{method} {path}"));
|
||||
}
|
||||
}
|
||||
|
||||
let index = call(&app, ALICE, "GET", "/", &[], None).await.json;
|
||||
let listed: BTreeSet<String> = index["endpoints"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|e| e.as_str().unwrap().to_string())
|
||||
.collect();
|
||||
assert_eq!(listed, documented);
|
||||
|
||||
assert_eq!(call(&app, ALICE, "GET", "/nope", &[], None).await.status, StatusCode::NOT_FOUND);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ description = "The server behind Yaak in the browser: executes sends, and can se
|
||||
|
||||
# The send engine (yaak-http) and the model types it speaks (yaak-models, for
|
||||
# HttpRequest / Cookie / HttpResponseEventData). Deliberately NOT yaak (the
|
||||
# render + storage orchestration), yaak-plugins, or the RPC router: this binary
|
||||
# render + storage orchestration), yaak-plugins, or the RPC router: this crate
|
||||
# opens no database, runs no plugins, and renders nothing. yaak-models comes
|
||||
# along only because yaak-http's types are its types; nothing here calls into
|
||||
# its query layer.
|
||||
@@ -34,3 +34,7 @@ url = "2"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
yaak-http = { workspace = true }
|
||||
yaak-models = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
@@ -46,7 +46,7 @@ docker run -p 8080:8080 \
|
||||
anything strangers can reach — see [What it refuses](#what-it-refuses-and-why).
|
||||
Turn it on for an instance on your own network, where calling the API on the
|
||||
next machine is the whole point. Note that "private" is relative to the
|
||||
*container*: `127.0.0.1` is the container itself, and reaching the Docker
|
||||
_container_: `127.0.0.1` is the container itself, and reaching the Docker
|
||||
host means `host.docker.internal` (or `--network host`).
|
||||
- **`YAAK_WEB_RATE_LIMIT_PER_MINUTE`** defaults to 120 sends per client IP,
|
||||
which suits a public instance and not a team of your own; `0` disables it.
|
||||
@@ -72,27 +72,44 @@ cargo run -p yaak-web
|
||||
YAAK_TARGET=web npm run dev --workspace @yaakapp/yaak-client
|
||||
```
|
||||
|
||||
A dev build looks for the server at `http://127.0.0.1:9227` (the Vite server is a
|
||||
different origin and serves no `/v1`); a production build sends to its own
|
||||
origin unless `VITE_YAAK_WEB_URL` was set when it was built.
|
||||
The dev server passes `/v1` through to this binary, so a dev build sends to its
|
||||
own origin exactly like a production build does — one address to open, and no
|
||||
CORS in the loop. `PORT` moves this server and the dev server follows it. A
|
||||
production build also sends to its own origin, unless `VITE_YAAK_WEB_URL` was
|
||||
set when it was built.
|
||||
|
||||
Reaching the dev server from another machine is `HOST=0.0.0.0`. Vite allows
|
||||
addresses but not names, so opening it as a hostname also needs
|
||||
`ALLOWED_HOSTS=that-name`.
|
||||
|
||||
## Configuration
|
||||
|
||||
Every flag has a `YAAK_WEB_*` environment variable, so a container needs no
|
||||
arguments; `--help` lists them all.
|
||||
Every flag has an environment variable, so a container needs no arguments;
|
||||
`--help` lists them all. The listen address uses the platform conventions —
|
||||
`HOST` and `PORT` — so the published image runs unchanged on a
|
||||
platform-as-a-service that assigns a port. Everything else is `YAAK_WEB_*`.
|
||||
|
||||
| Flag | Default | What |
|
||||
| --- | --- | --- |
|
||||
| `--serve` | off | Also serve a built web client from this directory, on the same origin. |
|
||||
| `--bind` | `127.0.0.1:9227` | Listen address. The image sets `0.0.0.0:8080`. |
|
||||
| `--allow-private-networks` | off | Allow sends to loopback, private and link-local addresses. |
|
||||
| `--allowed-origins` | `*` | CORS origins, comma-separated. Unused when the app is served from here: same origin, no CORS. |
|
||||
| `--max-request-bytes` | 16 MiB | Largest rendered request accepted from the tab. |
|
||||
| `--max-response-bytes` | 64 MiB | Largest upstream body relayed before the send is cut off. |
|
||||
| `--max-timeout-secs` | 60 | Ceiling on a send's timeout; a request asking for more (or none) gets this. |
|
||||
| `--rate-limit-per-minute` | 120 | Sends per client IP per minute; 0 disables. |
|
||||
| `--max-concurrent` | 256 | Sends in flight at once. |
|
||||
| `--trust-forwarded-for` | off | Take the client IP from `X-Forwarded-For`. Only behind a load balancer that sets it. |
|
||||
| Flag | Default | What |
|
||||
| -------------------------- | ----------- | --------------------------------------------------------------------------------------------- |
|
||||
| `--serve` | off | Also serve a built web client from this directory, on the same origin. |
|
||||
| `--host` | `127.0.0.1` | Interface to listen on. The image sets `0.0.0.0`. May be a name; `localhost` resolves. |
|
||||
| `--port` | `9227` | Port to listen on. The image sets `8080`. |
|
||||
| `--allow-private-networks` | off | Allow sends to loopback, private and link-local addresses. |
|
||||
| `--allowed-origins` | `*` | CORS origins, comma-separated. Unused when the app is served from here: same origin, no CORS. |
|
||||
| `--max-request-bytes` | 16 MiB | Largest rendered request accepted from the tab. |
|
||||
| `--max-response-bytes` | 64 MiB | Largest upstream body relayed before the send is cut off. |
|
||||
| `--max-timeout-secs` | 60 | Ceiling on a send's timeout; a request asking for more (or none) gets this. |
|
||||
| `--rate-limit-per-minute` | 120 | Sends per client IP per minute; 0 disables. |
|
||||
| `--max-concurrent` | 256 | Sends in flight at once. |
|
||||
| `--trust-forwarded-for` | off | Take the client IP from `X-Forwarded-For`. Only behind a load balancer that sets it. |
|
||||
|
||||
## Logging
|
||||
|
||||
Default logs contain a startup message and operational warnings and errors, not request URLs, client IPs,
|
||||
DNS lookups, or per-request timing. To diagnose a self-hosted instance, opt in with
|
||||
`RUST_LOG=warn,yaak_http=error,yaak_web=debug`. This includes request URLs and IPs,
|
||||
so only enable it while debugging. Hosting infrastructure may maintain its own
|
||||
access logs independently.
|
||||
|
||||
## Serving the app
|
||||
|
||||
@@ -150,8 +167,9 @@ caught, and so is a `Location:` header that points at one. It also refuses body
|
||||
types that would read files on its own disk (`binary`, multipart file
|
||||
fields), since no browser tab could legitimately mean those.
|
||||
|
||||
Refusals are logged with the reason. On a public instance (`web.yaak.app`, or
|
||||
anything else strangers can reach) this must stay on: the machine's private
|
||||
Refusal reasons are included in debug logs when enabled. On a public instance
|
||||
(`web.yaak.app`, or anything else strangers can reach), private-network protection
|
||||
must stay on: the machine's private
|
||||
network is the host's, not the user's, so a `localhost` or LAN API is not the
|
||||
user's to reach through it — the desktop app is what reaches those. On an
|
||||
instance you run for yourself, that reasoning is inverted, and
|
||||
@@ -162,7 +180,7 @@ other side is one the users are entitled to.
|
||||
There is no authentication either way: an instance is anonymous, protected by
|
||||
the per-client rate limit and the destination policy. Anything more (a shared
|
||||
token, per-user quotas) is a later slice and would sit in front of `send_http`
|
||||
in `main.rs`. Put TLS in front of a public instance.
|
||||
in `lib.rs`, or in middleware on the exported router. Put TLS in front of a public instance.
|
||||
|
||||
## The wire
|
||||
|
||||
@@ -172,7 +190,7 @@ in `main.rs`. Put TLS in front of a public instance.
|
||||
{
|
||||
"request": { "url": "https://…", "method": "GET", "headers": […], "body": {…}, "bodyType": null, "urlParameters": […] },
|
||||
"settings": { "validateCertificates": true, "followRedirects": true, "timeoutMs": 0, "sendCookies": true, "storeCookies": true },
|
||||
"cookies": [ … ]
|
||||
"cookies": [ … ]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -184,13 +202,13 @@ jar's contents (or `null` for no jar).
|
||||
The reply is `application/x-ndjson`, one JSON frame per line, in the order things
|
||||
happened:
|
||||
|
||||
| `type` | When | Carries |
|
||||
| --- | --- | --- |
|
||||
| `event` | as the engine produces them | one timeline event, in the desktop's `http_response_event.event` shape |
|
||||
| `type` | When | Carries |
|
||||
| ---------- | ----------------------------------------- | ---------------------------------------------------------------------------------- |
|
||||
| `event` | as the engine produces them | one timeline event, in the desktop's `http_response_event.event` shape |
|
||||
| `response` | once, when the final hop's headers arrive | status, all headers, request headers as sent, remote address, HTTP version, timing |
|
||||
| `body` | as the body is read | a decompressed chunk, base64 |
|
||||
| `done` | last, on success | elapsed, byte counts, and the cookie jar as the send left it |
|
||||
| `error` | last, on failure | the reason, and any cookies collected before the failure |
|
||||
| `body` | as the body is read | a decompressed chunk, base64 |
|
||||
| `done` | last, on success | elapsed, byte counts, and the cookie jar as the send left it |
|
||||
| `error` | last, on failure | the reason, and any cookies collected before the failure |
|
||||
|
||||
Refusals that happen before anything is sent (a blocked destination, a bad body,
|
||||
rate limit, capacity) are plain HTTP errors (`403`, `400`, `429`, `503`) with
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Strings intentionally retain unknown operators/types so they fail validation, rather than
|
||||
* disappearing during import or being treated as an empty, passing collection.
|
||||
*/
|
||||
export type HttpAssertion = { id: string, enabled: boolean, target: string, selector: string, operator: string, expected: string, expectedType: string, };
|
||||
|
||||
export type HttpAssertions = { version: number, checks: Array<HttpAssertion>, };
|
||||
+26
-108
@@ -1,135 +1,53 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { HttpAssertions } from "./gen_assertions";
|
||||
|
||||
export type Cookie = {
|
||||
name: string;
|
||||
value: string;
|
||||
domain: CookieDomain;
|
||||
expires: CookieExpires;
|
||||
path: string;
|
||||
secure: boolean;
|
||||
httpOnly: boolean;
|
||||
sameSite: CookieSameSite | null;
|
||||
};
|
||||
export type Cookie = { name: string, value: string, domain: CookieDomain, expires: CookieExpires, path: string, secure: boolean, httpOnly: boolean, sameSite: CookieSameSite | null, };
|
||||
|
||||
export type CookieDomain = { HostOnly: string } | { Suffix: string } | "NotPresent" | "Empty";
|
||||
export type CookieDomain = { "HostOnly": string } | { "Suffix": string } | "NotPresent" | "Empty";
|
||||
|
||||
export type CookieExpires = { AtUtc: string } | "SessionEnd";
|
||||
export type CookieExpires = { "AtUtc": string } | "SessionEnd";
|
||||
|
||||
export type CookieSameSite = "Strict" | "Lax" | "None";
|
||||
|
||||
export type HttpRequest = {
|
||||
model: "http_request";
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
folderId: string | null;
|
||||
authentication: Record<string, any>;
|
||||
authenticationType: string | null;
|
||||
body: Record<string, any>;
|
||||
bodyType: string | null;
|
||||
description: string;
|
||||
headers: Array<HttpRequestHeader>;
|
||||
method: string;
|
||||
name: string;
|
||||
sortPriority: number;
|
||||
url: string;
|
||||
/**
|
||||
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||
*/
|
||||
urlParameters: Array<HttpUrlParameter>;
|
||||
settingSendCookies: InheritedBoolSetting;
|
||||
settingStoreCookies: InheritedBoolSetting;
|
||||
settingValidateCertificates: InheritedBoolSetting;
|
||||
settingFollowRedirects: InheritedBoolSetting;
|
||||
settingRequestTimeout: InheritedIntSetting;
|
||||
settingHttpVersion: InheritedHttpVersionSetting;
|
||||
};
|
||||
export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, assertions: HttpAssertions, method: string, name: string, sortPriority: number, url: string,
|
||||
/**
|
||||
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||
*/
|
||||
urlParameters: Array<HttpUrlParameter>, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingFollowRedirects: InheritedBoolSetting, settingRequestTimeout: InheritedIntSetting, settingHttpVersion: InheritedHttpVersionSetting, };
|
||||
|
||||
export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
|
||||
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
|
||||
|
||||
/**
|
||||
* Serializable representation of HTTP response events for DB storage.
|
||||
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
|
||||
* The `From` impl is in yaak-http to avoid circular dependencies.
|
||||
*/
|
||||
export type HttpResponseEventData =
|
||||
| {
|
||||
type: "setting";
|
||||
name: string;
|
||||
value: string;
|
||||
source_model?: string;
|
||||
source_id?: string;
|
||||
source_name?: string;
|
||||
}
|
||||
| { type: "info"; message: string }
|
||||
| {
|
||||
type: "redirect";
|
||||
url: string;
|
||||
status: number;
|
||||
behavior: string;
|
||||
dropped_body: boolean;
|
||||
dropped_headers: Array<string>;
|
||||
}
|
||||
| {
|
||||
type: "send_url";
|
||||
method: string;
|
||||
scheme: string;
|
||||
username: string;
|
||||
password: string;
|
||||
host: string;
|
||||
port: number;
|
||||
path: string;
|
||||
query: string;
|
||||
fragment: string;
|
||||
}
|
||||
| { type: "receive_url"; version: string; status: string }
|
||||
| { type: "header_up"; name: string; value: string }
|
||||
| { type: "header_down"; name: string; value: string }
|
||||
| { type: "chunk_sent"; bytes: number }
|
||||
| { type: "chunk_received"; bytes: number }
|
||||
| {
|
||||
type: "dns_resolved";
|
||||
hostname: string;
|
||||
addresses: Array<string>;
|
||||
duration: bigint;
|
||||
overridden: boolean;
|
||||
};
|
||||
export type HttpResponseEventData = { "type": "setting", name: string, value: string, source_model?: string, source_id?: string, source_name?: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, dropped_body: boolean, dropped_headers: Array<string>, } | { "type": "send_url", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, };
|
||||
|
||||
export type HttpResponseHeader = { name: string; value: string };
|
||||
export type HttpResponseHeader = { name: string, value: string, };
|
||||
|
||||
/**
|
||||
* The resolved send settings, values only: what an executor has to obey, with the sources
|
||||
* (which model each came from) left behind in [`ResolvedHttpRequestSettings`]. This is what
|
||||
* crosses from a tab to the Yaak server, and what the server reads.
|
||||
*/
|
||||
export type HttpSendSettings = {
|
||||
validateCertificates: boolean;
|
||||
followRedirects: boolean;
|
||||
/**
|
||||
* Milliseconds. Zero or negative means no timeout.
|
||||
*/
|
||||
timeoutMs: number;
|
||||
sendCookies: boolean;
|
||||
storeCookies: boolean;
|
||||
httpVersion: HttpVersion;
|
||||
};
|
||||
export type HttpSendSettings = { validateCertificates: boolean, followRedirects: boolean,
|
||||
/**
|
||||
* Milliseconds. Zero or negative means no timeout.
|
||||
*/
|
||||
timeoutMs: number, sendCookies: boolean, storeCookies: boolean, httpVersion: HttpVersion, };
|
||||
|
||||
export type HttpUrlParameter = {
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
|
||||
* Other entries are appended as query parameters
|
||||
*/
|
||||
name: string;
|
||||
value: string;
|
||||
id?: string;
|
||||
};
|
||||
export type HttpUrlParameter = { enabled?: boolean,
|
||||
/**
|
||||
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
|
||||
* Other entries are appended as query parameters
|
||||
*/
|
||||
name: string, value: string, id?: string, };
|
||||
|
||||
export type HttpVersion = "auto" | "http1" | "http2";
|
||||
|
||||
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
|
||||
export type InheritedBoolSetting = { enabled?: boolean, value: boolean, };
|
||||
|
||||
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
|
||||
export type InheritedHttpVersionSetting = { enabled?: boolean, value: HttpVersion, };
|
||||
|
||||
export type InheritedIntSetting = { enabled?: boolean; value: number };
|
||||
export type InheritedIntSetting = { enabled?: boolean, value: number, };
|
||||
@@ -1,5 +1,5 @@
|
||||
use clap::Parser;
|
||||
use std::net::SocketAddr;
|
||||
use std::net::{SocketAddr, ToSocketAddrs};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// The server behind Yaak running in a browser.
|
||||
@@ -10,9 +10,25 @@ use std::path::PathBuf;
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
#[command(name = "yaak-web", version, about, long_about = None)]
|
||||
pub struct Config {
|
||||
/// Address to listen on. 127.0.0.1 for a local instance; 0.0.0.0 inside a container.
|
||||
#[arg(long, env = "YAAK_WEB_BIND", default_value = "127.0.0.1:9227")]
|
||||
pub bind: SocketAddr,
|
||||
/// Interface to listen on. 127.0.0.1 for a local instance; 0.0.0.0 inside a container,
|
||||
/// which is what makes the port reachable from outside it.
|
||||
#[arg(long, env = "HOST", default_value = "127.0.0.1")]
|
||||
pub host: String,
|
||||
|
||||
/// Port to listen on. Named `PORT` because that is the variable a platform-as-a-service
|
||||
/// assigns and expects to be obeyed, so the same image runs there and under `docker run`
|
||||
/// with nothing changed.
|
||||
#[arg(long, env = "PORT", default_value_t = 9227)]
|
||||
pub port: u16,
|
||||
|
||||
/// Port the app is served on elsewhere. A browser that opens this server directly is
|
||||
/// redirected there, keeping whatever hostname it used — so `home:9227` becomes
|
||||
/// `home:1424` without this server knowing what "home" is.
|
||||
///
|
||||
/// Set by `vp run web:dev`, where the app is on the dev server and this is only the
|
||||
/// executor behind it. Irrelevant with `--serve`, which puts both on one port.
|
||||
#[arg(long, env = "YAAK_WEB_APP_PORT")]
|
||||
pub app_port: Option<u16>,
|
||||
|
||||
/// Also serve a built web client from this directory, on the same origin as the API.
|
||||
/// Unknown paths fall back to `index.html` so the app's own routes work on a refresh.
|
||||
@@ -65,3 +81,18 @@ pub struct Config {
|
||||
#[arg(long, env = "YAAK_WEB_TRUST_FORWARDED_FOR", default_value_t = false)]
|
||||
pub trust_forwarded_for: bool,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// The address to listen on.
|
||||
///
|
||||
/// Resolved rather than parsed, so `HOST` may be a name — `localhost` is the one people
|
||||
/// actually type, and it is not an `IpAddr`. The first address wins; a host resolving to
|
||||
/// several is a machine with several interfaces, and any of them is a listen address.
|
||||
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))
|
||||
}
|
||||
}
|
||||
Loaded 100 of 301 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user