Merge main into feat/request-versioning

Conflicts were all at registration points; both sides are kept.

Main now routes every write through one connection (#642): ClientDb only
reads and writes go through `with_tx` on a WriteDb. Versioning follows:
snapshot, restore, prune and the cascade delete move to WriteDb, while the
lookups and the "has it changed" check stay on ClientDb. Callers take a
write transaction the way their neighbours do:

- The HTTP send snapshots in its own transaction from
  `resolve_send_inputs`, and a failure there (including not getting the
  writer) is still logged and swallowed, so the response just has no
  version.
- gRPC and WebSocket connect snapshot inside the transaction that upserts
  the connection.
- The snapshot and restore RPCs, on the desktop and in the browser host,
  each run in a transaction.
- The request and workspace cascades live on main's WriteDb deletes.

The Request Changed dropdown moves into the response pane's
`statusLineRight` slot, since main moved the status line into the shared
pane view.

The plugin runtime's copy of the model bindings picks up `versionId`, and
`gen_rpc.ts` is regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Gregory SchierandClaude Opus 5.5 authored and Stetta task tk_mw563e1dcqtc4sqh committed 2026-11-06 09:23:32 -07:00
commit 1b0a196c50
794 files changed
+76953 -18061

No files matched your search

+20
View File
@@ -0,0 +1,20 @@
{
"name": "yaak",
"interface": {
"displayName": "Yaak"
},
"plugins": [
{
"name": "yaak",
"source": {
"source": "local",
"path": "./crates-cli/yaak-cli"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "Developer Tools"
}
]
}
+18
View File
@@ -0,0 +1,18 @@
{
"$schema": "https://json.schemastore.org/claude-code-marketplace.json",
"name": "yaak",
"description": "Plugins for Yaak, the desktop API client",
"owner": {
"name": "Yaak",
"url": "https://yaak.app"
},
"plugins": [
{
"name": "yaak",
"source": "./crates-cli/yaak-cli",
"description": "Build, save, and send API requests with the yaak CLI, with its MCP server for agents",
"category": "development",
"tags": ["api", "http", "rest", "graphql", "grpc", "openapi", "api-client"]
}
]
}
+1
View File
@@ -5,3 +5,4 @@ crates/yaak-templates/pkg/* linguist-generated=true
# Ensure consistent line endings for test files that check exact content
crates/yaak-http/tests/test.txt text eol=lf
crates-proxy/yaak-proxy-lib/tests/fixtures/export.har text eol=lf
+55
View File
@@ -0,0 +1,55 @@
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 5
ignore:
# Build tooling majors are opted into, not queued. These never reach a
# user, so the cost of being a release behind is low and the cost of a
# surprise is a broken build. Note this also suppresses security PRs for
# these, so Dependabot alerts are the backstop.
- dependency-name: typescript
update-types: ["version-update:semver-major"]
- dependency-name: vitest
update-types: ["version-update:semver-major"]
- dependency-name: "@types/*"
update-types: ["version-update:semver-major"]
- dependency-name: "vite-plugin-*"
update-types: ["version-update:semver-major"]
# 0.8 dropped stateExtensions and updateSchema from the package root and
# exposes no subpath to reach them, which GrpcEditor needs. Unpin once
# that editor moves to the jsonSchema helper.
- dependency-name: codemirror-json-schema
groups:
npm-production:
dependency-type: production
update-types:
- minor
- patch
npm-development:
dependency-type: development
update-types:
- minor
- patch
- package-ecosystem: cargo
directory: /
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 5
ignore:
# Held at 11.10.0 to pin what rolldown_resolver ("^11") resolves to; it
# does not build against 11.11. See crates-cli/yaak-cli/Cargo.toml.
- dependency-name: oxc_resolver
groups:
cargo:
patterns:
- "*"
update-types:
- minor
- patch
@@ -0,0 +1,79 @@
// Retain only distributable bundle files, never the target tree or signing material.
import { createHash } from "node:crypto";
import {
copyFileSync,
createReadStream,
existsSync,
mkdirSync,
readdirSync,
writeFileSync,
} from "node:fs";
import { join, relative } from "node:path";
const env = process.env;
const bundle = join("target", env.RELEASE_TARGET || "", "release", "bundle");
const destination = join(env.RUNNER_TEMP, "yaak-release-artifacts");
const extensions =
/\.(dmg|deb|rpm|AppImage|exe|msi|app\.tar\.gz|AppImage\.tar\.gz|nsis\.zip|msi\.zip)(\.sig)?$/;
const files = [];
function snapshot(directory) {
if (!existsSync(directory)) return;
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const source = join(directory, entry.name);
// Only descend through the bundler's immediate output folders. This excludes
// .app trees and the extracted CEF deb contents (which contain executables).
if (entry.isDirectory() && directory === bundle) {
snapshot(source);
} else if (
entry.isFile() &&
(extensions.test(entry.name) || /^yaak-cef_.*_linux_.*\.tar\.gz$/.test(entry.name))
) {
// Match tauri-action's default macOS updater asset naming exactly.
const arch = env.RELEASE_ARCH === "arm64" ? "aarch64" : "x64";
const assetName = entry.name.replace(/\.app\.tar\.gz(\.sig)?$/, `_${arch}.app.tar.gz$1`);
mkdirSync(destination, { recursive: true });
const retained = join(destination, assetName);
// The second snapshot must not overwrite the original user installer/signature.
if (!existsSync(retained)) copyFileSync(source, retained);
files.push({ source: relative(".", source), assetName });
}
}
}
snapshot(bundle);
if (existsSync(destination)) {
const assets = readdirSync(destination)
.filter((name) => name !== "provenance.json")
.sort();
const checksums = [];
for (const assetName of assets) {
const hash = createHash("sha256");
for await (const chunk of createReadStream(join(destination, assetName))) hash.update(chunk);
checksums.push({ assetName, sha256: hash.digest("hex") });
}
writeFileSync(
join(destination, "provenance.json"),
JSON.stringify(
{
repository: env.GITHUB_REPOSITORY,
tag: env.GITHUB_REF_NAME,
sha: env.GITHUB_SHA,
runId: env.GITHUB_RUN_ID,
runAttempt: env.GITHUB_RUN_ATTEMPT,
workflowRef: env.GITHUB_WORKFLOW_REF,
workflowSha: env.GITHUB_WORKFLOW_SHA,
tauriAction: "tauri-apps/tauri-action@v0",
platform: env.RELEASE_PLATFORM,
runtime: env.RELEASE_RUNTIME,
arch: env.RELEASE_ARCH,
target: env.RELEASE_TARGET,
args: env.RELEASE_ARGS,
assets: checksums,
},
null,
2,
) + "\n",
);
}
console.log(`Found ${files.length} distributable bundle files in ${bundle}`);
+26
View File
@@ -35,3 +35,29 @@ jobs:
run: |
cargo build -p yaak-cli
node plugins/importer-openapi/tests/roundtrip.mjs
test-windows:
name: Rust Tests (Windows)
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: voidzero-dev/setup-vp@v1
with:
node-version: "24"
cache: true
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
shared-key: ci
cache-on-failure: true
- name: Install Protoc for plugin-runtime
uses: arduino/setup-protoc@v3
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
- run: vp install
- run: npm run bootstrap
env:
YAAK_TARGET_ARCH: x64
- name: Run Rust Tests
run: cargo test --all --exclude yaak-cli --features yaak-app-client/wry
+51 -5
View File
@@ -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
View File
@@ -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
+1677 -1428
View File
File diff suppressed because it is too large. Load diff
+16 -2
View File
@@ -11,10 +11,12 @@ members = [
"crates/yaak-core",
"crates/yaak-common",
"crates/yaak-crypto",
"crates/yaak-filter",
"crates/yaak-git",
"crates/yaak-grpc",
"crates/yaak-http",
"crates/yaak-lifecycle",
"crates/yaak-mcp",
"crates/yaak-models",
"crates/yaak-plugins",
"crates/yaak-sse",
@@ -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
@@ -37,6 +40,7 @@ members = [
"crates-tauri/yaak-fonts",
"crates-tauri/yaak-license",
"crates-tauri/yaak-mac-window",
"crates-tauri/yaak-proxy-helper",
"crates-tauri/yaak-system-appearance",
"crates-tauri/yaak-tauri-utils",
"crates-tauri/yaak-window",
@@ -47,9 +51,16 @@ 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"
rustls-platform-verifier = "0.7.1"
schemars = { version = "0.8.22", features = ["chrono"] }
serde = "1.0.228"
serde_json = "1.0.145"
@@ -77,10 +88,12 @@ yaak = { path = "crates/yaak" }
yaak-commands = { path = "crates/yaak-commands" }
yaak-common = { path = "crates/yaak-common" }
yaak-crypto = { path = "crates/yaak-crypto" }
yaak-filter = { path = "crates/yaak-filter" }
yaak-git = { path = "crates/yaak-git" }
yaak-grpc = { path = "crates/yaak-grpc" }
yaak-http = { path = "crates/yaak-http" }
yaak-lifecycle = { path = "crates/yaak-lifecycle" }
yaak-mcp = { path = "crates/yaak-mcp" }
yaak-models = { path = "crates/yaak-models" }
yaak-plugins = { path = "crates/yaak-plugins" }
yaak-sse = { path = "crates/yaak-sse" }
@@ -98,6 +111,7 @@ yaak-proxy-lib = { path = "crates-proxy/yaak-proxy-lib" }
yaak-fonts = { path = "crates-tauri/yaak-fonts" }
yaak-license = { path = "crates-tauri/yaak-license" }
yaak-mac-window = { path = "crates-tauri/yaak-mac-window" }
yaak-proxy-helper = { path = "crates-tauri/yaak-proxy-helper" }
yaak-system-appearance = { path = "crates-tauri/yaak-system-appearance" }
yaak-tauri-utils = { path = "crates-tauri/yaak-tauri-utils" }
yaak-window = { path = "crates-tauri/yaak-window" }
+15
View File
@@ -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
View File
@@ -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:
+11 -1
View File
@@ -17,7 +17,7 @@
<br>
<p align="center">
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https:&#x2F;&#x2F;github.com&#x2F;MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a>&nbsp;&nbsp;<a href="https://github.com/dharsanb"><img src="https:&#x2F;&#x2F;github.com&#x2F;dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a>&nbsp;&nbsp;<a href="https://github.com/railwayapp"><img src="https:&#x2F;&#x2F;github.com&#x2F;railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a>&nbsp;&nbsp;<a href="https://github.com/caseyamcl"><img src="https:&#x2F;&#x2F;github.com&#x2F;caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a>&nbsp;&nbsp;<a href="https://github.com/bytebase"><img src="https:&#x2F;&#x2F;github.com&#x2F;bytebase.png" width="80px" alt="User avatar: bytebase" /></a>&nbsp;&nbsp;<a href="https://github.com/"><img src="https:&#x2F;&#x2F;raw.githubusercontent.com&#x2F;JamesIves&#x2F;github-sponsors-readme-action&#x2F;dev&#x2F;.github&#x2F;assets&#x2F;placeholder.png" width="80px" alt="User avatar: " /></a>&nbsp;&nbsp;<!-- sponsors-premium -->
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https:&#x2F;&#x2F;github.com&#x2F;MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a>&nbsp;&nbsp;<a href="https://github.com/dharsanb"><img src="https:&#x2F;&#x2F;github.com&#x2F;dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a>&nbsp;&nbsp;<a href="https://github.com/railwayapp"><img src="https:&#x2F;&#x2F;github.com&#x2F;railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a>&nbsp;&nbsp;<a href="https://github.com/caseyamcl"><img src="https:&#x2F;&#x2F;github.com&#x2F;caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a>&nbsp;&nbsp;<a href="https://github.com/bytebase"><img src="https:&#x2F;&#x2F;github.com&#x2F;bytebase.png" width="80px" alt="User avatar: bytebase" /></a>&nbsp;&nbsp;<a href="https://github.com/rauchg"><img src="https:&#x2F;&#x2F;github.com&#x2F;rauchg.png" width="80px" alt="User avatar: rauchg" /></a>&nbsp;&nbsp;<a href="https://github.com/"><img src="https:&#x2F;&#x2F;raw.githubusercontent.com&#x2F;JamesIves&#x2F;github-sponsors-readme-action&#x2F;dev&#x2F;.github&#x2F;assets&#x2F;placeholder.png" width="80px" alt="User avatar: " /></a>&nbsp;&nbsp;<!-- sponsors-premium -->
</p>
<p align="center">
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https:&#x2F;&#x2F;github.com&#x2F;seanwash.png" width="50px" alt="User avatar: seanwash" /></a>&nbsp;&nbsp;<a href="https://github.com/jerath"><img src="https:&#x2F;&#x2F;github.com&#x2F;jerath.png" width="50px" alt="User avatar: jerath" /></a>&nbsp;&nbsp;<a href="https://github.com/itsa-sh"><img src="https:&#x2F;&#x2F;github.com&#x2F;itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a>&nbsp;&nbsp;<a href="https://github.com/dmmulroy"><img src="https:&#x2F;&#x2F;github.com&#x2F;dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a>&nbsp;&nbsp;<a href="https://github.com/timcole"><img src="https:&#x2F;&#x2F;github.com&#x2F;timcole.png" width="50px" alt="User avatar: timcole" /></a>&nbsp;&nbsp;<a href="https://github.com/VLZH"><img src="https:&#x2F;&#x2F;github.com&#x2F;VLZH.png" width="50px" alt="User avatar: VLZH" /></a>&nbsp;&nbsp;<a href="https://github.com/terasaka2k"><img src="https:&#x2F;&#x2F;github.com&#x2F;terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a>&nbsp;&nbsp;<a href="https://github.com/andriyor"><img src="https:&#x2F;&#x2F;github.com&#x2F;andriyor.png" width="50px" alt="User avatar: andriyor" /></a>&nbsp;&nbsp;<a href="https://github.com/majudhu"><img src="https:&#x2F;&#x2F;github.com&#x2F;majudhu.png" width="50px" alt="User avatar: majudhu" /></a>&nbsp;&nbsp;<a href="https://github.com/axelrindle"><img src="https:&#x2F;&#x2F;github.com&#x2F;axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a>&nbsp;&nbsp;<a href="https://github.com/jirizverina"><img src="https:&#x2F;&#x2F;github.com&#x2F;jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a>&nbsp;&nbsp;<a href="https://github.com/chip-well"><img src="https:&#x2F;&#x2F;github.com&#x2F;chip-well.png" width="50px" alt="User avatar: chip-well" /></a>&nbsp;&nbsp;<a href="https://github.com/GRAYAH"><img src="https:&#x2F;&#x2F;github.com&#x2F;GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a>&nbsp;&nbsp;<a href="https://github.com/flashblaze"><img src="https:&#x2F;&#x2F;github.com&#x2F;flashblaze.png" width="50px" alt="User avatar: flashblaze" /></a>&nbsp;&nbsp;<a href="https://github.com/Frostist"><img src="https:&#x2F;&#x2F;github.com&#x2F;Frostist.png" width="50px" alt="User avatar: Frostist" /></a>&nbsp;&nbsp;<a href="https://github.com/PurplProto"><img src="https:&#x2F;&#x2F;github.com&#x2F;PurplProto.png" width="50px" alt="User avatar: PurplProto" /></a>&nbsp;&nbsp;<!-- sponsors-base -->
@@ -25,6 +25,10 @@
![Yaak API Client](https://yaak.app/static/screenshot.png)
## Install
Download Yaak for macOS, Windows, or Linux from [yaak.app/download](https://yaak.app/download). Package managers and other install methods are in the [installation docs](https://yaak.app/docs/getting-started/installation).
## Features
Yaak is an offline-first API client designed to stay out of your way while giving you everything you need when you need it.
@@ -68,3 +72,9 @@ Built with [Tauri](https://tauri.app), Rust, and React, it’s fast, lightweight
- [Yaak vs Postman](https://yaak.app/alternatives/postman)
- [Yaak vs Bruno](https://yaak.app/alternatives/bruno)
- [Yaak vs Insomnia](https://yaak.app/alternatives/insomnia)
## License
The source is [MIT licensed](LICENSE) and can be built and run for free, for both personal and commercial use.
The prebuilt binaries follow Yaak's [pricing terms](https://yaak.app/pricing): free for personal use, license required for commercial use.
+148
View File
@@ -0,0 +1,148 @@
import type { FunctionComponent, ReactElement } from "react";
import { beforeEach, expect, test, vi } from "vite-plus/test";
import type { SyncOp } from "@yaakapp-internal/sync/bindings/gen_sync";
import type { DialogInstance } from "../components/Dialogs";
import { syncWorkspace } from "./commands";
const mocks = vi.hoisted(() => ({
calculateSync: vi.fn(),
applySync: vi.fn(),
showDialog: vi.fn<(dialog: DialogInstance) => void>(),
hideDialog: vi.fn(),
showToast: vi.fn(),
}));
vi.mock("@yaakapp-internal/sync", () => mocks);
vi.mock("@yaakapp-internal/models", () => ({
createWorkspaceModel: vi.fn(),
modelTypeLabel: () => "Request",
}));
vi.mock("@yaakapp-internal/ui", () => ({
Banner: "div",
InlineCode: "code",
Table: "table",
TableBody: "tbody",
TableCell: "td",
TableHead: "thead",
TableHeaderCell: "th",
TableRow: "tr",
TruncatedWideTableCell: "td",
}));
vi.mock("../components/core/Button", () => ({ Button: "button" }));
vi.mock("../hooks/useActiveWorkspace", () => ({ activeWorkspaceIdAtom: null }));
vi.mock("../lib/dialog", () => mocks);
vi.mock("../lib/jotai", () => ({ jotaiStore: { get: vi.fn() } }));
vi.mock("../lib/prompt", () => ({ showPrompt: vi.fn() }));
vi.mock("../lib/resolvedModelName", () => ({ resolvedModelNameWithFolders: () => "Request" }));
vi.mock("../lib/toast", () => mocks);
const staleToast = expect.objectContaining({
message: "Files changed while syncing. Review the changes again.",
});
const params = { workspaceId: "wk_test", syncDir: "/sync" };
const deletion = {
type: "dbDelete",
model: { id: "rq_test", model: "http_request" },
state: {},
} as Extract<SyncOp, { type: "dbDelete" }>;
const update = {
type: "dbUpdate",
model: { id: "rq_test", model: "http_request" },
fs: { model: { id: "rq_test", model: "http_request", name: "Updated" } },
state: {},
} as Extract<SyncOp, { type: "dbUpdate" }>;
async function submit(dialog: DialogInstance) {
const render = dialog.render as FunctionComponent<{ hide: () => void }>;
const form = render({ hide: () => mocks.hideDialog(dialog.id) }) as ReactElement<{
onSubmit: (event: { preventDefault: () => void }) => Promise<void>;
}>;
await form.props.onSubmit({ preventDefault: vi.fn() });
}
function latestDialog() {
const dialog = mocks.showDialog.mock.calls.at(-1)?.[0];
if (dialog == null) throw new Error("Expected a sync dialog");
return dialog;
}
beforeEach(() => {
vi.resetAllMocks();
mocks.applySync.mockResolvedValue(true);
});
test("closes an obsolete dialog when the restored file leaves no changes", async () => {
mocks.calculateSync.mockResolvedValueOnce([deletion]).mockResolvedValueOnce([]);
await syncWorkspace.mutateAsync(params);
mocks.applySync.mockResolvedValueOnce(false);
await submit(latestDialog());
expect(mocks.applySync).toHaveBeenCalledTimes(1);
expect(mocks.applySync).toHaveBeenCalledWith(params.workspaceId, params.syncDir, [deletion]);
expect(mocks.hideDialog).toHaveBeenCalledWith("commit-sync");
expect(mocks.showDialog).toHaveBeenCalledTimes(1);
});
test("requires another confirmation for a changed plan", async () => {
mocks.calculateSync.mockResolvedValueOnce([deletion]).mockResolvedValueOnce([update]);
await syncWorkspace.mutateAsync(params);
const original = latestDialog();
mocks.applySync.mockResolvedValueOnce(false);
await submit(original);
expect(mocks.applySync).toHaveBeenCalledTimes(1);
expect(mocks.showDialog).toHaveBeenCalledTimes(2);
expect(mocks.showToast).toHaveBeenCalledWith(staleToast);
expect(mocks.hideDialog).not.toHaveBeenCalled();
await submit(latestDialog());
expect(mocks.applySync).toHaveBeenLastCalledWith(params.workspaceId, params.syncDir, [update]);
expect(mocks.hideDialog).toHaveBeenCalledWith("commit-sync");
});
test("applies an unchanged reviewed plan and closes the dialog", async () => {
mocks.calculateSync.mockResolvedValue([update]);
await syncWorkspace.mutateAsync(params);
await submit(latestDialog());
expect(mocks.calculateSync).toHaveBeenCalledTimes(1);
expect(mocks.applySync).toHaveBeenCalledTimes(1);
expect(mocks.hideDialog).toHaveBeenCalledWith("commit-sync");
});
test("forced sync retries the current plan without requesting another confirmation", async () => {
mocks.calculateSync.mockResolvedValueOnce([deletion]).mockResolvedValueOnce([update]);
mocks.applySync.mockResolvedValueOnce(false);
await syncWorkspace.mutateAsync({ ...params, force: true });
expect(mocks.applySync).toHaveBeenCalledTimes(2);
expect(mocks.applySync).toHaveBeenLastCalledWith(params.workspaceId, params.syncDir, [update]);
expect(mocks.showDialog).not.toHaveBeenCalled();
});
test("outbound-only changes clear the old dialog and sync automatically", async () => {
const outbound = { type: "fsUpdate", model: update.model, state: {} } as SyncOp;
mocks.calculateSync.mockResolvedValueOnce([deletion]).mockResolvedValueOnce([outbound]);
await syncWorkspace.mutateAsync(params);
await syncWorkspace.mutateAsync(params);
expect(mocks.applySync).toHaveBeenCalledWith(params.workspaceId, params.syncDir, [outbound]);
expect(mocks.hideDialog).toHaveBeenCalledWith("commit-sync");
expect(mocks.showDialog).toHaveBeenCalledTimes(1);
});
test("stops retrying a plan that keeps going stale", async () => {
mocks.calculateSync.mockResolvedValue([update]);
mocks.applySync.mockResolvedValue(false);
await syncWorkspace.mutateAsync({ ...params, force: true });
expect(mocks.calculateSync).toHaveBeenCalledTimes(3);
expect(mocks.applySync).toHaveBeenCalledTimes(3);
expect(mocks.showToast).toHaveBeenCalledTimes(1);
expect(mocks.showToast).toHaveBeenCalledWith(staleToast);
expect(mocks.showDialog).not.toHaveBeenCalled();
});
+34 -11
View File
@@ -14,11 +14,12 @@ import {
} from "@yaakapp-internal/ui";
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
import { createFastMutation } from "../hooks/useFastMutation";
import { showDialog } from "../lib/dialog";
import { hideDialog, showDialog } from "../lib/dialog";
import { jotaiStore } from "../lib/jotai";
import { pluralizeCount } from "../lib/pluralize";
import { showPrompt } from "../lib/prompt";
import { resolvedModelNameWithFolders } from "../lib/resolvedModelName";
import { showToast } from "../lib/toast";
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
export const createFolder = createFastMutation<
string | null,
@@ -52,24 +53,48 @@ export const createFolder = createFastMutation<
},
});
// Files can keep changing under a plan, so stop recalculating after a few stale applies.
const MAX_SYNC_ATTEMPTS = 3;
function showStaleSyncToast() {
showToast({
id: "sync-stale",
color: "warning",
message: "Files changed while syncing. Review the changes again.",
});
}
export const syncWorkspace = createFastMutation<
void,
void,
{ workspaceId: string; syncDir: string; force?: boolean }
{ workspaceId: string; syncDir: string; force?: boolean; attempt?: number }
>({
mutationKey: [],
mutationFn: async ({ workspaceId, syncDir, force }) => {
mutationFn: async ({ workspaceId, syncDir, force, attempt = 1 }) => {
const ops = (await calculateSync(workspaceId, syncDir)) ?? [];
if (ops.length === 0) {
console.log("Nothing to sync", workspaceId, syncDir);
hideDialog("commit-sync");
return;
}
console.log("Syncing workspace", workspaceId, syncDir, ops);
const dbOps = ops.filter((o) => o.type.startsWith("db"));
if (dbOps.length === 0) {
await applySync(workspaceId, syncDir, ops);
const apply = async () => {
if (await applySync(workspaceId, syncDir, ops)) {
hideDialog("commit-sync");
} else if (attempt < MAX_SYNC_ATTEMPTS) {
await syncWorkspace.mutateAsync({ workspaceId, syncDir, force, attempt: attempt + 1 });
} else {
hideDialog("commit-sync");
showStaleSyncToast();
}
};
if (dbOps.length === 0 || force) {
hideDialog("commit-sync");
await apply();
return;
}
@@ -79,9 +104,8 @@ export const syncWorkspace = createFastMutation<
console.log("Directory changes detected", { dbOps, ops });
if (force) {
await applySync(workspaceId, syncDir, ops);
return;
if (attempt > 1) {
showStaleSyncToast();
}
showDialog({
@@ -93,8 +117,7 @@ export const syncWorkspace = createFastMutation<
className="h-full grid grid-rows-[auto_auto_minmax(0,1fr)_auto] gap-3"
onSubmit={async (e) => {
e.preventDefault();
await applySync(workspaceId, syncDir, ops);
hide();
await apply();
}}
>
{isDeletingWorkspace ? (
@@ -6,11 +6,12 @@ import { showDialog } from "../lib/dialog";
import { jotaiStore } from "../lib/jotai";
import { setWorkspaceSearchParams } from "../lib/setWorkspaceSearchParams";
export const createSubEnvironmentAndActivate = createFastMutation<
string | null,
unknown,
Environment | null
>({
/**
* Prompt for and create a sub-environment of the given base environment, resolving with the new
* environment's ID (or null if the dialog was dismissed). This does not change the workspace's
* active environment.
*/
export const createSubEnvironment = createFastMutation<string | null, unknown, Environment | null>({
mutationKey: ["create_environment"],
mutationFn: async (baseEnvironment) => {
if (baseEnvironment == null) {
@@ -41,6 +42,17 @@ export const createSubEnvironmentAndActivate = createFastMutation<
});
});
},
});
/** Same as {@link createSubEnvironment}, but also makes the new environment the active one. */
export const createSubEnvironmentAndActivate = createFastMutation<
string | null,
unknown,
Environment | null
>({
mutationKey: ["create_environment_and_activate"],
mutationFn: (baseEnvironment) => createSubEnvironment.mutateAsync(baseEnvironment),
disableToastError: true, // Already shown by createSubEnvironment
onSuccess: async (environmentId) => {
if (environmentId == null) {
return; // Was not created
@@ -3,9 +3,9 @@ import type { GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-intern
import { MoveToWorkspaceDialog } from "../components/MoveToWorkspaceDialog";
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
import { createFastMutation } from "../hooks/useFastMutation";
import { pluralizeCount } from "../lib/pluralize";
import { showDialog } from "../lib/dialog";
import { jotaiStore } from "../lib/jotai";
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
export const moveToWorkspace = createFastMutation({
mutationKey: ["move_workspace"],
@@ -17,7 +17,7 @@ export const openWorkspaceFromSyncDir = createFastMutation<void, void, string>({
return;
}
await applySync(workspace.id, dir, ops);
await applySync(workspace.id, dir, ops, { fsOnly: true });
await router.navigate({
to: "/workspaces/$workspaceId",
@@ -0,0 +1,99 @@
import { platform } from "@yaakapp-internal/platform";
import {
Banner,
type EditorHost,
EditorHostProvider,
EmptyStateText,
type HostBodyViewerArgs,
type HttpBodyLoader,
LoadingIcon,
type StoredValue,
type StoredValueNamespace,
} from "@yaakapp-internal/ui";
import { lazy, type ReactNode, Suspense } from "react";
import { useKeyValue } from "../hooks/useKeyValue";
import { copyToClipboard } from "../lib/copy";
import { showDialog } from "../lib/dialog";
import { showPrompt } from "../lib/prompt";
import { useDetailsBannerStoredValue } from "./core/DetailsBanner";
import { Editor } from "./core/Editor/LazyEditor";
import { Input } from "./core/Input";
import { ErrorBoundary } from "./ErrorBoundary";
import { MultipartViewer } from "./responseViewers/MultipartViewer";
import { TextViewer } from "./responseViewers/TextViewer";
import { SelectFile } from "./SelectFile";
const PdfViewer = lazy(() =>
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
);
function useClientStoredValue<T extends StoredValue>(
key: string | string[] | null,
fallback: T,
namespace: StoredValueNamespace = "global",
) {
// A null key is never written by the shared components, so any stable key will do
const { value, set } = useKeyValue<T>({ namespace, key: key ?? ["tabs", "default"], fallback });
return [value ?? fallback, set] as const;
}
function renderBodyViewer({ source, mimeType, body, complete }: HostBodyViewerArgs) {
if (!mimeType?.match(/pdf/i)) return null;
return source === "response" ? (
<PdfResponseBody body={body} complete={complete} />
) : (
<PdfRequestBody body={body} />
);
}
/** Waits for the whole response, then loads the PDF from its file */
function PdfResponseBody({ body, complete }: { body: HttpBodyLoader; complete: boolean }) {
const bodyUrl = body.useUrl(complete);
if (!complete || bodyUrl.isLoading) {
return (
<EmptyStateText>
<LoadingIcon />
</EmptyStateText>
);
}
if (bodyUrl.error) {
return <Banner color="danger">{String(bodyUrl.error)}</Banner>;
}
if (bodyUrl.data == null) {
return <div>Empty response body</div>;
}
return <PdfViewer bodyUrl={bodyUrl.data} />;
}
function PdfRequestBody({ body }: { body: HttpBodyLoader }) {
const bytes = body.useBytes();
if (bytes.data == null) return null;
return (
<Suspense fallback={<LoadingIcon />}>
<PdfViewer data={bytes.data} />
</Suspense>
);
}
const clientEditorHost: EditorHost = {
Editor,
Input,
SelectFile,
showDialog,
showPrompt,
basename: (path) => platform.files.basename(path),
useStoredValue: useClientStoredValue,
useDetailsBannerStoredValue,
ErrorBoundary,
TextViewer,
MultipartViewer,
renderBodyViewer,
openUrl: (url) => platform.openUrl(url),
copy: (text) => copyToClipboard(text, { disableToast: true }),
};
/** Gives the shared editing components the client's templated editor, dialogs and storage */
export function ClientEditorHost({ children }: { children: ReactNode }) {
return <EditorHostProvider value={clientEditorHost}>{children}</EditorHostProvider>;
}
@@ -1,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,12 +1,11 @@
import type { HttpRequest } from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models";
import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui";
import { Banner, HStack, InlineCode, SizeTag } from "@yaakapp-internal/ui";
import type { ReactNode } from "react";
import { useToggle } from "../hooks/useToggle";
import { showConfirm } from "../lib/confirm";
import { Button } from "./core/Button";
import { Link } from "./core/Link";
import { SizeTag } from "./core/SizeTag";
interface Props {
children: ReactNode;
@@ -1,61 +0,0 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui";
import { type ReactNode, useMemo } from "react";
import { useSaveResponse } from "../hooks/useSaveResponse";
import { useToggle } from "../hooks/useToggle";
import { isProbablyTextContentType } from "../lib/contentType";
import { getContentTypeFromHeaders } from "../lib/model_util";
import { getResponseBodyText } from "../lib/responseBody";
import { CopyButton } from "./CopyButton";
import { Button } from "./core/Button";
import { SizeTag } from "./core/SizeTag";
interface Props {
children: ReactNode;
response: HttpResponse;
}
const LARGE_BYTES = 2 * 1000 * 1000;
export function ConfirmLargeResponse({ children, response }: Props) {
const { mutate: saveResponse } = useSaveResponse(response);
const [showLargeResponse, toggleShowLargeResponse] = useToggle();
const isProbablyText = useMemo(() => {
const contentType = getContentTypeFromHeaders(response.headers);
return isProbablyTextContentType(contentType);
}, [response.headers]);
const contentLength = response.contentLength ?? 0;
const isLarge = contentLength > LARGE_BYTES;
if (!showLargeResponse && isLarge) {
return (
<Banner color="primary" className="flex flex-col gap-3">
<p>
Showing responses over{" "}
<InlineCode>
<SizeTag contentLength={LARGE_BYTES} />
</InlineCode>{" "}
may impact performance
</p>
<HStack wrap space={2}>
<Button color="primary" size="xs" onClick={toggleShowLargeResponse}>
Reveal Response
</Button>
<Button color="secondary" variant="border" size="xs" onClick={() => saveResponse()}>
Save to File
</Button>
{isProbablyText && (
<CopyButton
color="secondary"
variant="border"
size="xs"
text={() => getResponseBodyText({ response, filter: null })}
/>
)}
</HStack>
</Banner>
);
}
return <>{children}</>;
}
@@ -1,56 +0,0 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui";
import { type ReactNode, useMemo } from "react";
import { getRequestBodyText as getHttpResponseRequestBodyText } from "../hooks/useHttpRequestBody";
import { useToggle } from "../hooks/useToggle";
import { isProbablyTextContentType } from "../lib/contentType";
import { getContentTypeFromHeaders } from "../lib/model_util";
import { CopyButton } from "./CopyButton";
import { Button } from "./core/Button";
import { SizeTag } from "./core/SizeTag";
interface Props {
children: ReactNode;
response: HttpResponse;
}
const LARGE_BYTES = 2 * 1000 * 1000;
export function ConfirmLargeResponseRequest({ children, response }: Props) {
const [showLargeResponse, toggleShowLargeResponse] = useToggle();
const isProbablyText = useMemo(() => {
const contentType = getContentTypeFromHeaders(response.headers);
return isProbablyTextContentType(contentType);
}, [response.headers]);
const contentLength = response.requestContentLength ?? 0;
const isLarge = contentLength > LARGE_BYTES;
if (!showLargeResponse && isLarge) {
return (
<Banner color="primary" className="flex flex-col gap-3">
<p>
Showing content over{" "}
<InlineCode>
<SizeTag contentLength={LARGE_BYTES} />
</InlineCode>{" "}
may impact performance
</p>
<HStack wrap space={2}>
<Button color="primary" size="xs" onClick={toggleShowLargeResponse}>
Reveal Request Body
</Button>
{isProbablyText && (
<CopyButton
color="secondary"
variant="border"
size="xs"
text={() => getHttpResponseRequestBodyText(response).then((d) => d?.bodyText ?? "")}
/>
)}
</HStack>
</Banner>
);
}
return <>{children}</>;
}
+2 -1
View File
@@ -25,12 +25,13 @@ import {
TableHeaderCell,
TableRow,
TruncatedWideTableCell,
KeyValueRows,
} from "@yaakapp-internal/ui";
import { IconButton } from "./core/IconButton";
import { Checkbox } from "./core/Checkbox";
import classNames from "classnames";
import { EventDetailHeader } from "./core/EventViewer";
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
import { KeyValueRow } from "./core/KeyValueRow";
import { EmptyStateText } from "./EmptyStateText";
import { PlainInput } from "./core/PlainInput";
import { Select } from "./core/Select";
+16 -25
View File
@@ -1,31 +1,22 @@
import { IconButton, type IconButtonProps, useTimedBoolean } from "@yaakapp-internal/ui";
import {
CopyIconButton as BaseCopyIconButton,
type CopyIconButtonProps,
} from "@yaakapp-internal/ui";
import { copyToClipboard } from "../lib/copy";
import { showToast } from "../lib/toast";
interface Props extends Omit<IconButtonProps, "onClick" | "icon"> {
text: string | (() => Promise<string | null>);
export function CopyIconButton(props: Omit<CopyIconButtonProps, "copy" | "onCopyFailed">) {
return <BaseCopyIconButton {...props} copy={copy} onCopyFailed={showCopyFailed} />;
}
export function CopyIconButton({ text, ...props }: Props) {
const [copied, setCopied] = useTimedBoolean();
return (
<IconButton
{...props}
icon={copied ? "check" : "copy"}
showConfirm
onClick={async () => {
const content = typeof text === "function" ? await text() : text;
if (content == null) {
showToast({
id: "failed-to-copy",
color: "danger",
message: "Failed to copy",
});
} else {
copyToClipboard(content, { disableToast: true });
setCopied();
}
}}
/>
);
function copy(text: string) {
copyToClipboard(text, { disableToast: true });
}
function showCopyFailed() {
showToast({
id: "failed-to-copy",
color: "danger",
message: "Failed to copy",
});
}
@@ -1,12 +1,12 @@
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 { Label } from "./core/Label";
import { DialogFooter } from "./core/Dialog";
import { PlainInput } from "./core/PlainInput";
import { Label } from "@yaakapp-internal/ui";
interface Props {
onCreate: (id: string) => void;
@@ -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>
);
}
@@ -1,15 +1,14 @@
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 { VStack, Label } from "@yaakapp-internal/ui";
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 { Label } from "./core/Label";
import { DialogFooter } from "./core/Dialog";
import { PlainInput } from "./core/PlainInput";
import { EncryptionHelp } from "./EncryptionHelp";
import { gitCallbacks } from "./git/callbacks";
@@ -26,12 +25,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 +89,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 -2
View File
@@ -11,7 +11,7 @@ import type {
FormInputText,
JsonPrimitive,
} from "@yaakapp-internal/plugins";
import { Banner, VStack } from "@yaakapp-internal/ui";
import { Banner, VStack, Label } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo } from "react";
@@ -26,7 +26,6 @@ import { Editor } from "./core/Editor/LazyEditor";
import { IconButton } from "./core/IconButton";
import type { InputProps } from "./core/Input";
import { Input } from "./core/Input";
import { Label } from "./core/Label";
import type { Pair } from "./core/PairEditor";
import { PairEditor } from "./core/PairEditor";
import { PlainInput } from "./core/PlainInput";
+1 -24
View File
@@ -1,24 +1 @@
import classNames from "classnames";
import type { ReactNode } from "react";
interface Props {
children: ReactNode;
className?: string;
wrapperClassName?: string;
}
export function EmptyStateText({ children, className, wrapperClassName }: Props) {
return (
<div className={classNames("w-full h-full pb-2", wrapperClassName)}>
<div
className={classNames(
className,
"rounded-lg border border-dashed border-border-subtle",
"h-full py-2 text-text-subtlest flex items-center justify-center italic",
)}
>
{children}
</div>
</div>
);
}
export { EmptyStateText } from "@yaakapp-internal/ui";
@@ -1,11 +1,19 @@
import type { Environment, Workspace } from "@yaakapp-internal/models";
import { duplicateModel, patchModel } from "@yaakapp-internal/models";
import type { TreeHandle, TreeNode, TreeProps } from "@yaakapp-internal/ui";
import { Banner, Icon, InlineCode, SplitLayout, Tree } from "@yaakapp-internal/ui";
import { Banner, Icon, InlineCode, SplitLayout, Tree, IconTooltip } from "@yaakapp-internal/ui";
import { atom, useAtomValue } from "jotai";
import { atomFamily } from "jotai-family";
import { useCallback, useLayoutEffect, useRef, useState } from "react";
import { createSubEnvironmentAndActivate } from "../commands/createEnvironment";
import {
createContext,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { createSubEnvironment } from "../commands/createEnvironment";
import { activeWorkspaceAtom, activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
import {
environmentsBreakdownAtom,
@@ -22,7 +30,6 @@ import { showColorPicker } from "../lib/showColorPicker";
import type { ContextMenuProps, DropdownItem } from "./core/Dropdown";
import { ContextMenu } from "./core/Dropdown";
import { IconButton } from "./core/IconButton";
import { IconTooltip } from "./core/IconTooltip";
import type { PairEditorHandle } from "./core/PairEditor";
import { EnvironmentColorIndicator } from "./EnvironmentColorIndicator";
import { EnvironmentEditor } from "./EnvironmentEditor";
@@ -40,6 +47,9 @@ interface Props {
type TreeModel = Environment | Workspace;
/** Lets tree row slots create a sub-environment and select it in the editor. */
const CreateSubEnvironmentContext = createContext<() => Promise<void>>(async () => {});
export function EnvironmentEditDialog({ initialEnvironmentId, setRef }: Props) {
const { allEnvironments, baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown();
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<string | null>(
@@ -115,7 +125,7 @@ function EnvironmentEditDialogSidebar({
const activeWorkspaceId = useAtomValue(activeWorkspaceIdAtom) ?? "";
const treeId = `environment.${activeWorkspaceId}.sidebar`;
const treeRef = useRef<TreeHandle>(null);
const { baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown();
const { allEnvironments, baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown();
// oxlint-disable-next-line react-hooks/exhaustive-deps -- none
useLayoutEffect(() => {
@@ -124,6 +134,31 @@ function EnvironmentEditDialogSidebar({
treeRef.current?.focus();
}, []);
// A just-created environment to select once it reaches the model store. Selecting it before
// then would show a "failed to find" error in the editor, and the tree would drop the
// selection of an item it doesn't have yet.
const [pendingSelectionId, setPendingSelectionId] = useState<string | null>(null);
const tree = useAtomValue(treeAtom);
useEffect(() => {
if (pendingSelectionId == null) return;
if (!allEnvironments.some((e) => e.id === pendingSelectionId)) return;
setPendingSelectionId(null);
setSelectedEnvironmentId(pendingSelectionId);
// The tree leaves out sub-environments while there are multiple base environments
const node = tree == null ? null : findTreeNode(tree, pendingSelectionId);
if (node == null) return;
if (node.parent != null) {
// Expand the parent so the new item is visible to be selected
const parentId = node.parent.item.id;
jotaiStore.set(collapsedFamily(treeId), (prev) => ({ ...prev, [parentId]: false }));
}
// Wait a frame so the tree's own pending selection fixups, which may have been scheduled
// before the new item arrived, don't reset this selection
requestAnimationFrame(() => treeRef.current?.selectItem(pendingSelectionId));
}, [allEnvironments, pendingSelectionId, setSelectedEnvironmentId, tree, treeId]);
const handleDeleteEnvironment = useCallback(
async (environment: Environment) => {
await deleteModelWithConfirm(environment);
@@ -134,6 +169,14 @@ function EnvironmentEditDialogSidebar({
[baseEnvironment?.id, selectedEnvironmentId, setSelectedEnvironmentId],
);
const handleCreateSubEnvironment = useCallback(async () => {
const { baseEnvironment } = jotaiStore.get(environmentsBreakdownAtom);
if (baseEnvironment == null) return;
const id = await createSubEnvironment.mutateAsync(baseEnvironment);
// Show the new environment in the editor without changing the workspace's active environment
if (id != null) setPendingSelectionId(id);
}, []);
const treeHasFocus = useCallback(() => treeRef.current?.hasFocus() ?? false, []);
const getSelectedTreeModels = useCallback(
@@ -195,13 +238,14 @@ function EnvironmentEditDialogSidebar({
const addEnvironmentItem: DropdownItem = {
label: "Create Sub Environment",
leftSlot: <Icon icon="plus" />,
onSelect: async () => {
await createSubEnvironment();
},
onSelect: handleCreateSubEnvironment,
};
// Sub-environments aren't shown while there are multiple base environments, same as the
// plus button on the base environment row
const canCreateSubEnvironment = baseEnvironments.length <= 1;
if (environment == null || environment.model !== "environment") {
return [addEnvironmentItem];
return canCreateSubEnvironment ? [addEnvironmentItem] : [];
}
const singleEnvironment = items.length === 1;
@@ -257,7 +301,7 @@ function EnvironmentEditDialogSidebar({
];
// Add sub environment to base environment
if (isBaseEnvironment(environment) && singleEnvironment) {
if (isBaseEnvironment(environment) && singleEnvironment && canCreateSubEnvironment) {
menuItems.push({ type: "separator" });
menuItems.push(addEnvironmentItem);
}
@@ -266,6 +310,7 @@ function EnvironmentEditDialogSidebar({
},
[
baseEnvironments.length,
handleCreateSubEnvironment,
handleDeleteEnvironment,
handleDuplicateSelected,
handleRenameSelected,
@@ -317,34 +362,37 @@ function EnvironmentEditDialogSidebar({
);
const renderContextMenuFn = useCallback<NonNullable<TreeProps<TreeModel>["renderContextMenu"]>>(
({ items, position, onClose }) => (
<ContextMenu items={items as DropdownItem[]} triggerPosition={position} onClose={onClose} />
),
({ items, position, onClose }) =>
// Right-clicking empty space has nothing to offer while there are multiple base environments
items.length === 0 ? null : (
<ContextMenu items={items as DropdownItem[]} triggerPosition={position} onClose={onClose} />
),
[],
);
const tree = useAtomValue(treeAtom);
return (
<aside className="x-theme-sidebar h-full w-full min-w-0 grid overflow-y-auto border-r border-border-subtle ">
{tree != null && (
<div className="pt-2">
<Tree
ref={treeRef}
treeId={treeId}
collapsedAtom={collapsedFamily(treeId)}
className="px-2 pb-10"
root={tree}
getContextMenu={getContextMenu}
renderContextMenu={renderContextMenuFn}
onDragEnd={handleDragEnd}
getItemKey={(i) => `${i.id}::${i.name}`}
ItemLeftSlotInner={ItemLeftSlotInner}
ItemRightSlot={ItemRightSlot}
ItemInner={ItemInner}
onActivate={handleActivate}
getEditOptions={getEditOptions}
/>
</div>
<CreateSubEnvironmentContext.Provider value={handleCreateSubEnvironment}>
<div className="pt-2">
<Tree
ref={treeRef}
treeId={treeId}
collapsedAtom={collapsedFamily(treeId)}
className="px-2 pb-10"
root={tree}
getContextMenu={getContextMenu}
renderContextMenu={renderContextMenuFn}
onDragEnd={handleDragEnd}
getItemKey={(i) => `${i.id}::${i.name}`}
ItemLeftSlotInner={ItemLeftSlotInner}
ItemRightSlot={ItemRightSlot}
ItemInner={ItemInner}
onActivate={handleActivate}
getEditOptions={getEditOptions}
/>
</div>
</CreateSubEnvironmentContext.Provider>
)}
</aside>
);
@@ -384,6 +432,15 @@ const treeAtom = atom<TreeNode<TreeModel> | null>((get) => {
return root;
});
function findTreeNode(node: TreeNode<TreeModel>, id: string): TreeNode<TreeModel> | null {
if (node.item.id === id) return node;
for (const child of node.children ?? []) {
const found = findTreeNode(child, id);
if (found != null) return found;
}
return null;
}
function ItemLeftSlotInner({ item }: { item: TreeModel }) {
const { baseEnvironments } = useEnvironmentsBreakdown();
return baseEnvironments.length > 1 ? (
@@ -395,6 +452,7 @@ function ItemLeftSlotInner({ item }: { item: TreeModel }) {
function ItemRightSlot({ item }: { item: TreeModel }) {
const { baseEnvironments } = useEnvironmentsBreakdown();
const handleCreateSubEnvironment = useContext(CreateSubEnvironmentContext);
return (
<>
{item.model === "environment" && baseEnvironments.length <= 1 && isBaseEnvironment(item) && (
@@ -405,7 +463,7 @@ function ItemRightSlot({ item }: { item: TreeModel }) {
icon="plus_circle"
className="opacity-50 hover:opacity-100"
title="Add Sub-Environment"
onClick={createSubEnvironment}
onClick={handleCreateSubEnvironment}
/>
)}
</>
@@ -425,13 +483,6 @@ function ItemInner({ item }: { item: TreeModel }) {
);
}
async function createSubEnvironment() {
const { baseEnvironment } = jotaiStore.get(environmentsBreakdownAtom);
if (baseEnvironment == null) return;
const id = await createSubEnvironmentAndActivate.mutateAsync(baseEnvironment);
return id;
}
function getEditOptions(item: TreeModel) {
const options: ReturnType<NonNullable<TreeProps<TreeModel>["getEditOptions"]>> = {
defaultValue: item.name,
@@ -1,4 +1,4 @@
import { IconTooltip } from "./core/IconTooltip";
import { IconTooltip } from "@yaakapp-internal/ui";
export function EnvironmentSharableTooltip() {
return (
+11 -52
View File
@@ -1,60 +1,19 @@
import { Banner, Button, InlineCode } from "@yaakapp-internal/ui";
import type { ErrorInfo, ReactNode } from "react";
import { Component, useEffect } from "react";
import { ErrorBoundary as BaseErrorBoundary } from "@yaakapp-internal/ui";
import type { ReactNode } from "react";
import { useEffect } from "react";
import { showDialog } from "../lib/dialog";
import RouteError from "./RouteError";
interface ErrorBoundaryProps {
name: string;
children: ReactNode;
export function ErrorBoundary({ name, children }: { name: string; children: ReactNode }) {
return (
<BaseErrorBoundary name={name} onShowError={showErrorDialog}>
{children}
</BaseErrorBoundary>
);
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.warn("Error caught by ErrorBoundary:", error, info);
}
render() {
if (this.state.hasError) {
return (
<Banner color="danger" className="flex items-center gap-2 overflow-auto">
<div>
Error rendering <InlineCode>{this.props.name}</InlineCode> component
</div>
<Button
className="inline-flex"
variant="border"
color="danger"
size="2xs"
onClick={() => {
showDialog({
id: "error-boundary",
render: () => <RouteError error={this.state.error} />,
});
}}
>
Show
</Button>
</Banner>
);
}
return this.props.children;
}
export function showErrorDialog(error: unknown) {
showDialog({ id: "error-boundary", render: () => <RouteError error={error} /> });
}
export function ErrorBoundaryTestThrow() {
@@ -1,18 +1,18 @@
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";
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";
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
interface Props {
onHide: () => void;
@@ -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(),
},
]}
/>
</>
);
}
+9 -4
View File
@@ -1,6 +1,14 @@
import type { Folder, GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-internal/models";
import { foldersAtom } from "@yaakapp-internal/models";
import { Heading, HStack, Icon, LoadingIcon } from "@yaakapp-internal/ui";
import {
Heading,
HStack,
Icon,
LoadingIcon,
HttpResponseDurationTag,
HttpStatusTag,
SizeTag,
} from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import type { CSSProperties, ReactNode } from "react";
@@ -13,11 +21,8 @@ import { showDialog } from "../lib/dialog";
import { resolvedModelName } from "../lib/resolvedModelName";
import { router } from "../lib/router";
import { Button } from "./core/Button";
import { HttpResponseDurationTag } from "./core/HttpResponseDurationTag";
import { HttpStatusTag } from "./core/HttpStatusTag";
import { IconButton } from "./core/IconButton";
import { Separator } from "./core/Separator";
import { SizeTag } from "./core/SizeTag";
import { HttpResponsePane } from "./HttpResponsePane";
interface Props {
@@ -1,5 +1,5 @@
import { createWorkspaceModel, foldersAtom, patchModel } from "@yaakapp-internal/models";
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import { HStack, Icon, InlineCode, VStack, CountBadge } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { Fragment, useMemo } from "react";
import { useAuthTab } from "../hooks/useAuthTab";
@@ -11,7 +11,6 @@ import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
import { hideDialog } from "../lib/dialog";
import { CopyIconButton } from "./CopyIconButton";
import { Button } from "./core/Button";
import { CountBadge } from "./core/CountBadge";
import { Input } from "./core/Input";
import { Link } from "./core/Link";
import type { TabItem } from "./core/Tabs/Tabs";
@@ -1,46 +0,0 @@
import type { HttpRequest } from "@yaakapp-internal/models";
import { useCallback, useMemo } from "react";
import type { Pair, PairEditorProps } from "./core/PairEditor";
import { PairOrBulkEditor } from "./core/PairOrBulkEditor";
type Props = {
forceUpdateKey: string;
request: HttpRequest;
onChange: (headers: HttpRequest["body"]) => void;
};
export function FormUrlencodedEditor({ request, forceUpdateKey, onChange }: Props) {
const pairs = useMemo<Pair[]>(
() =>
(Array.isArray(request.body.form) ? request.body.form : []).map((p) => ({
enabled: !!p.enabled,
name: p.name || "",
value: p.value || "",
id: p.id,
})),
[request.body.form],
);
const handleChange = useCallback<PairEditorProps["onChange"]>(
(pairs) =>
onChange({ form: pairs.map((p) => ({ enabled: p.enabled, name: p.name, value: p.value })) }),
[onChange],
);
return (
<PairOrBulkEditor
allowMultilineValues
preferenceName="form_urlencoded"
valueAutocompleteFunctions
valueAutocompleteVariables
nameAutocompleteFunctions
nameAutocompleteVariables
namePlaceholder="entry_name"
valuePlaceholder="Value"
pairs={pairs}
onChange={handleChange}
forceUpdateKey={forceUpdateKey}
stateKey={`urlencoded.${request.id}`}
/>
);
}
@@ -1,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 () => {
+2 -1
View File
@@ -20,13 +20,13 @@ import { showConfirm } from "../lib/confirm";
import { showDialog } from "../lib/dialog";
import type { JsonSchema } from "../lib/jsonSchemaExample";
import { buildExampleFromSchema } from "../lib/jsonSchemaExample";
import { pluralizeCount } from "../lib/pluralize";
import { queryClient } from "../lib/queryClient";
import { Button } from "./core/Button";
import { Dropdown } from "./core/Dropdown";
import type { EditorProps } from "./core/Editor/Editor";
import { Editor } from "./core/Editor/LazyEditor";
import { GrpcProtoSelectionDialog } from "./GrpcProtoSelectionDialog";
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
type Props = Pick<EditorProps, "heightMode" | "onChange" | "className" | "forceUpdateKey"> & {
services: ReflectResponseService[] | null;
@@ -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,13 +1,13 @@
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";
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
interface Props {
onDone: () => void;
@@ -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&apos;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 },
]}
/>
</>
);
}
@@ -4,7 +4,7 @@ import {
patchModel,
patchModelDebounced,
} from "@yaakapp-internal/models";
import { HStack, Icon, useContainerSize, VStack } from "@yaakapp-internal/ui";
import { HStack, Icon, useContainerSize, VStack, CountBadge } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { CSSProperties } from "react";
import { useCallback, useMemo, useRef } from "react";
@@ -15,7 +15,6 @@ import { useInheritedHeaders } from "../hooks/useInheritedHeaders";
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
import { resolvedModelName } from "../lib/resolvedModelName";
import { Button } from "./core/Button";
import { CountBadge } from "./core/CountBadge";
import { IconButton } from "./core/IconButton";
import { PlainInput } from "./core/PlainInput";
import { RadioDropdown } from "./core/RadioDropdown";
@@ -133,6 +132,8 @@ export function GrpcRequestPane({
onSend({ message: activeRequest.message });
}, [activeRequest, onSend]);
const hasDescription = activeRequest.description.trim().length > 0;
const tabs: TabItem[] = useMemo(
() => [
{ value: TAB_MESSAGE, label: "Message" },
@@ -146,10 +147,10 @@ export function GrpcRequestPane({
{
value: TAB_DESCRIPTION,
label: "Info",
rightSlot: activeRequest.description && <CountBadge count={true} />,
rightSlot: hasDescription && <CountBadge count={true} />,
},
],
[activeRequest.description, authTab, metadataTab, numSettingsOverrides],
[authTab, hasDescription, metadataTab, numSettingsOverrides],
);
const handleMetadataChange = useCallback(
@@ -1,5 +1,13 @@
import type { GrpcEvent, GrpcRequest } from "@yaakapp-internal/models";
import { HStack, Icon, type IconProps, LoadingIcon, VStack } from "@yaakapp-internal/ui";
import {
HStack,
Icon,
type IconProps,
LoadingIcon,
VStack,
useStateWithDeps,
KeyValueRows,
} from "@yaakapp-internal/ui";
import { useAtomValue, useSetAtom } from "jotai";
import type { CSSProperties } from "react";
import { useEffect, useMemo, useState } from "react";
@@ -9,13 +17,12 @@ import {
pinnedGrpcConnectionIdAtom,
useGrpcEvents,
} from "../hooks/usePinnedGrpcConnection";
import { useStateWithDeps } from "../hooks/useStateWithDeps";
import { Button } from "./core/Button";
import { Editor } from "./core/Editor/LazyEditor";
import { EventDetailHeader, EventViewer } from "./core/EventViewer";
import { EventViewerRow } from "./core/EventViewerRow";
import { HotkeyList } from "./core/HotkeyList";
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
import { KeyValueRow } from "./core/KeyValueRow";
import { EmptyStateText } from "./EmptyStateText";
import { ErrorBoundary } from "./ErrorBoundary";
import { RecentGrpcConnectionsDropdown } from "./RecentGrpcConnectionsDropdown";
+1 -190
View File
@@ -1,190 +1 @@
import type { HttpRequestHeader } from "@yaakapp-internal/models";
import { HStack } from "@yaakapp-internal/ui";
import { acceptLanguages } from "../lib/data/acceptLanguages";
import { cacheControlDirectives } from "../lib/data/cacheControl";
import { charsets } from "../lib/data/charsets";
import { connections } from "../lib/data/connections";
import { encodings } from "../lib/data/encodings";
import type { HeaderValuePreset } from "../lib/data/headerValuePresets";
import { headerNames } from "../lib/data/headerNames";
import { mimeTypes } from "../lib/data/mimetypes";
import { userAgents } from "../lib/data/userAgents";
import { CountBadge } from "./core/CountBadge";
import { DetailsBanner } from "./core/DetailsBanner";
import type { GenericCompletion, GenericCompletionConfig } from "./core/Editor/genericCompletion";
import type { InputProps } from "./core/Input";
import type { Pair, PairEditorProps } from "./core/PairEditor";
import { PairEditorRow } from "./core/PairEditor";
import { ensurePairId } from "./core/PairEditor.util";
import { PairOrBulkEditor } from "./core/PairOrBulkEditor";
type Props = {
forceUpdateKey: string;
headers: HttpRequestHeader[];
inheritedHeaders?: HttpRequestHeader[];
inheritedHeadersLabel?: string;
stateKey: string;
onChange: (headers: HttpRequestHeader[]) => void;
label?: string;
};
export function HeadersEditor({
stateKey,
headers,
inheritedHeaders,
inheritedHeadersLabel = "Inherited",
onChange,
forceUpdateKey,
}: Props) {
// Get header names defined at current level (case-insensitive)
const currentHeaderNames = new Set(
headers.filter((h) => h.name).map((h) => h.name.toLowerCase()),
);
// Filter inherited headers: must be enabled, have content, and not be overridden by current level
const validInheritedHeaders =
inheritedHeaders?.filter(
(pair) =>
pair.enabled &&
(pair.name || pair.value) &&
!currentHeaderNames.has(pair.name.toLowerCase()),
) ?? [];
const hasInheritedHeaders = validInheritedHeaders.length > 0;
return (
<div
className={
hasInheritedHeaders
? "@container w-full h-full grid grid-rows-[auto_minmax(0,1fr)] gap-y-1.5"
: "@container w-full h-full"
}
>
{hasInheritedHeaders && (
<DetailsBanner
color="secondary"
className="text-sm"
summary={
<HStack>
{inheritedHeadersLabel} <CountBadge count={validInheritedHeaders.length} />
</HStack>
}
>
<div className="pb-2">
{validInheritedHeaders?.map((pair, i) => (
<PairEditorRow
key={`${pair.id}.${i}`}
index={i}
disabled
disableDrag
className="py-1"
pair={ensurePairId(pair)}
stateKey={null}
nameAutocompleteFunctions
nameAutocompleteVariables
valueAutocompleteFunctions
valueAutocompleteVariables
/>
))}
</div>
</DetailsBanner>
)}
<PairOrBulkEditor
forceUpdateKey={forceUpdateKey}
nameAutocomplete={nameAutocomplete}
nameAutocompleteFunctions
nameAutocompleteVariables
namePlaceholder="Header-Name"
nameValidate={validateHttpHeader}
onChange={onChange}
pairs={headers}
preferenceName="headers"
stateKey={stateKey}
valueType={valueType}
valueAutocomplete={valueAutocomplete}
valueAutocompleteFunctions
valueAutocompleteVariables
/>
</div>
);
}
const MIN_MATCH = 3;
const headerOptionsMap: Record<string, HeaderValuePreset[]> = {
"content-type": mimeTypes,
accept: ["*/*", ...mimeTypes],
"accept-encoding": encodings,
"content-encoding": encodings,
connection: connections,
"accept-charset": charsets,
"accept-language": acceptLanguages,
"cache-control": cacheControlDirectives,
"user-agent": userAgents,
pragma: ["no-cache"],
te: ["trailers", "compress", "deflate", "gzip"],
dnt: ["1", "0"],
"upgrade-insecure-requests": ["1"],
"x-requested-with": ["XMLHttpRequest"],
};
const valueType = (pair: Pair): InputProps["type"] => {
const name = pair.name.toLowerCase().trim();
if (
name.includes("authorization") ||
name.includes("api-key") ||
name.includes("access-token") ||
name.includes("auth") ||
name.includes("secret") ||
name.includes("token") ||
name === "cookie" ||
name === "set-cookie"
) {
return "password";
}
return "text";
};
const valueAutocomplete = (headerName: string): GenericCompletionConfig | undefined => {
const name = headerName.toLowerCase().trim();
const options: GenericCompletion[] =
headerOptionsMap[name]?.map((o) =>
typeof o === "string"
? {
label: o,
type: "constant",
boost: 1, // Put above other completions
}
: {
// Show the short label but insert the full value (e.g. User-Agent)
label: o.label,
apply: o.value,
type: "constant",
boost: 1,
},
) ?? [];
return { minMatch: MIN_MATCH, options };
};
const nameAutocomplete: PairEditorProps["nameAutocomplete"] = {
minMatch: MIN_MATCH,
options: headerNames.map((t) =>
typeof t === "string"
? {
label: t,
type: "constant",
boost: 1, // Put above other completions
}
: {
...t,
boost: 1, // Put above other completions
},
),
};
const validateHttpHeader = (v: string) => {
if (v === "") {
return true;
}
// Template strings are not allowed so we replace them with a valid example string
const withoutTemplateStrings = v.replace(/\$\{\[\s*[^\]\s]+\s*]}/gi, "123");
return withoutTemplateStrings.match(/^[a-zA-Z0-9-_]+$/) !== null;
};
export { HeadersEditor } from "@yaakapp-internal/ui";
+168 -195
View File
@@ -1,9 +1,16 @@
import type { HttpRequest } from "@yaakapp-internal/models";
import { getModel, patchModel, patchModelDebounced } from "@yaakapp-internal/models";
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
import classNames from "classnames";
import {
CountBadge,
type EditablePair,
type ExtraPaneTab,
HttpRequestPaneView,
InlineCode,
languageFromContentType,
} from "@yaakapp-internal/ui";
import { atom, useAtomValue } from "jotai";
import type { CSSProperties } from "react";
import type { CSSProperties, ReactNode } from "react";
import { lazy, Suspense, useCallback, useMemo, useRef, useState } from "react";
import { importCurl, looksLikeCurl } from "../commands/importCurl";
import { allRequestUrlsAtom } from "../hooks/useAllRequests";
@@ -15,10 +22,8 @@ import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
import { languageFromContentType } from "../lib/contentType";
import { generateId } from "../lib/generateId";
import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
import { convertRequestBody } from "../lib/requestBodyConversion";
import { fireAndForget } from "../lib/fireAndForget";
import { httpRequestToView } from "../lib/httpViews";
import {
BODY_TYPE_BINARY,
BODY_TYPE_FORM_MULTIPART,
@@ -30,30 +35,24 @@ import {
BODY_TYPE_XML,
getContentTypeFromHeaders,
} from "../lib/model_util";
import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
import { prepareImportQuerystring } from "../lib/prepareImportQuerystring";
import { convertRequestBody } from "../lib/requestBodyConversion";
import { resolvedModelName } from "../lib/resolvedModelName";
import { showToast } from "../lib/toast";
import { BinaryFileEditor } from "./BinaryFileEditor";
import { ConfirmLargeRequestBody } from "./ConfirmLargeRequestBody";
import { CountBadge } from "./core/CountBadge";
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
import { getUrlCompletionConfig } from "./core/Editor/url/completion";
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 { EmptyStateText } from "./EmptyStateText";
import { FormMultipartEditor } from "./FormMultipartEditor";
import { FormUrlencodedEditor } from "./FormUrlencodedEditor";
import { HeadersEditor } from "./HeadersEditor";
import { setActiveTab } from "./core/Tabs/Tabs";
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
import { JsonBodyEditor } from "./JsonBodyEditor";
import { MarkdownEditor } from "./MarkdownEditor";
import { RequestMethodDropdown } from "./RequestMethodDropdown";
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
import { UrlBar } from "./UrlBar";
import { RequestMethodDropdown } from "./RequestMethodDropdown";
import { UrlParametersEditor } from "./UrlParameterEditor";
import { generateId } from "@yaakapp-internal/lib/generateId";
const GraphQLEditor = lazy(() =>
import("./graphql/GraphQLEditor").then((m) => ({ default: m.GraphQLEditor })),
@@ -165,9 +164,11 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
: 0;
}
const tabs = useMemo<TabItem[]>(
() => [
{
const hasDescription = activeRequest.description.trim().length > 0;
const bodyTab = useMemo<TabItem>(
() =>
({
value: TAB_BODY,
rightSlot: numParams > 0 ? <CountBadge count={numParams} /> : null,
options: {
@@ -244,34 +245,8 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
}
},
},
},
{
value: TAB_PARAMS,
rightSlot: <CountBadge count={urlParameterPairs.length} />,
label: "Params",
},
...headersTab,
...authTab,
{
value: TAB_SETTINGS,
label: "Settings",
rightSlot: <CountBadge count={numSettingsOverrides} />,
},
{
value: TAB_DESCRIPTION,
label: "Info",
},
],
[
activeRequest,
authTab,
contentType,
handleContentTypeChange,
headersTab,
numParams,
numSettingsOverrides,
urlParameterPairs.length,
],
}) satisfies TabItem,
[activeRequest, contentType, handleContentTypeChange, numParams],
);
const { mutate: sendRequest } = useSendAnyHttpRequest();
@@ -333,154 +308,152 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
[activeRequest],
);
return (
<div
style={style}
className={classNames(className, "h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1")}
>
{activeRequest && (
<>
<UrlBar
stateKey={`url.${activeRequest.id}`}
key={forceUpdateKey + urlKey}
url={activeRequest.url}
placeholder="https://example.com"
onPasteOverwrite={handlePaste}
autocomplete={autocomplete}
onSend={handleSend}
onCancel={cancelResponse}
onUrlChange={handleUrlChange}
leftSlot={
<div className="py-0.5">
<RequestMethodDropdown request={activeRequest} className="ml-0.5 h-full!" />
</div>
}
forceUpdateKey={updateKey}
isLoading={activeResponse != null && activeResponse.state !== "closed"}
const handleJsonOrBodyTextChange = useCallback(
(text: string, bodyType: string | null) => {
// JSON writes at once, as its editor always has; other text debounces
if (bodyType === BODY_TYPE_JSON) {
return patchModel(activeRequest, { body: { ...activeRequest.body, text } });
}
return handleBodyTextChange(text);
},
[activeRequest, handleBodyTextChange],
);
const handleSendJsonCommentsChange = useCallback(
(sendJsonComments: boolean) => {
const newBody = { ...activeRequest.body };
if (sendJsonComments) {
newBody.sendJsonComments = true;
} else {
delete newBody.sendJsonComments;
}
fireAndForget(patchModel(activeRequest, { body: newBody }));
},
[activeRequest],
);
const requestView = useMemo(() => httpRequestToView(activeRequest), [activeRequest]);
const extraTabs = useMemo<ExtraPaneTab[]>(
() => [
{
tab: {
value: TAB_SETTINGS,
label: "Settings",
rightSlot: <CountBadge count={numSettingsOverrides} />,
},
content: <ModelSettingsEditor model={activeRequest} />,
},
{
tab: {
value: TAB_DESCRIPTION,
label: "Info",
rightSlot: hasDescription && <CountBadge count={true} />,
},
content: (
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full">
<PlainInput
label="Request Name"
hideLabel
forceUpdateKey={updateKey}
defaultValue={activeRequest.name}
className="font-sans text-xl! px-0!"
containerClassName="border-0"
placeholder={resolvedModelName(activeRequest)}
onChange={(name) => patchModel(activeRequest, { name })}
/>
<MarkdownEditor
name="request-description"
placeholder="Request description"
defaultValue={activeRequest.description}
stateKey={`description.${activeRequest.id}`}
forceUpdateKey={updateKey}
onChange={(description) => patchModel(activeRequest, { description })}
/>
</div>
),
},
],
[activeRequest, hasDescription, numSettingsOverrides, updateKey],
);
const renderBody = useCallback(
(sharedEditor: ReactNode) => (
<ConfirmLargeRequestBody request={activeRequest}>
{activeRequest.bodyType === BODY_TYPE_GRAPHQL ? (
<Suspense>
<GraphQLEditor
forceUpdateKey={forceUpdateKey}
baseRequest={activeRequest}
request={activeRequest}
onChange={handleBodyChange}
/>
</Suspense>
) : activeRequest.bodyType === BODY_TYPE_BINARY ? (
<BinaryFileEditor
requestId={activeRequest.id}
contentType={contentType}
body={activeRequest.body}
onChange={(body) => patchModelDebounced(activeRequest, { body })}
onChangeContentType={handleContentTypeChange}
/>
<Tabs
ref={tabsRef}
label="Request"
tabs={tabs}
tabListClassName="mt-1 -mb-1.5"
storageKey={TABS_STORAGE_KEY}
activeTabKey={activeRequestId}
>
<TabContent value={TAB_AUTH}>
<HttpAuthenticationEditor model={activeRequest} />
</TabContent>
<TabContent value={TAB_HEADERS}>
<HeadersEditor
inheritedHeaders={inheritedHeaders}
forceUpdateKey={`${forceUpdateHeaderEditorKey}::${forceUpdateKey}`}
headers={activeRequest.headers}
stateKey={`headers.${activeRequest.id}`}
onChange={(headers) => patchModelDebounced(activeRequest, { headers })}
/>
</TabContent>
<TabContent value={TAB_PARAMS}>
<UrlParametersEditor
stateKey={`params.${activeRequest.id}`}
forceUpdateKey={forceUpdateKey + urlParametersKey}
pairs={urlParameterPairs}
onChange={(urlParameters) => patchModelDebounced(activeRequest, { urlParameters })}
/>
</TabContent>
<TabContent value={TAB_SETTINGS}>
<ModelSettingsEditor model={activeRequest} />
</TabContent>
<TabContent value={TAB_BODY}>
<ConfirmLargeRequestBody request={activeRequest}>
{activeRequest.bodyType === BODY_TYPE_JSON ? (
<JsonBodyEditor
forceUpdateKey={forceUpdateKey}
heightMode={fullHeight ? "full" : "auto"}
request={activeRequest}
/>
) : activeRequest.bodyType === BODY_TYPE_XML ? (
<Editor
forceUpdateKey={forceUpdateKey}
autocompleteFunctions
autocompleteVariables
placeholder="..."
heightMode={fullHeight ? "full" : "auto"}
defaultValue={`${activeRequest.body?.text ?? ""}`}
language="xml"
onChange={handleBodyTextChange}
stateKey={`xml.${activeRequest.id}`}
/>
) : activeRequest.bodyType === BODY_TYPE_GRAPHQL ? (
<Suspense>
<GraphQLEditor
forceUpdateKey={forceUpdateKey}
baseRequest={activeRequest}
request={activeRequest}
onChange={handleBodyChange}
/>
</Suspense>
) : activeRequest.bodyType === BODY_TYPE_FORM_URLENCODED ? (
<FormUrlencodedEditor
forceUpdateKey={forceUpdateKey}
request={activeRequest}
onChange={handleBodyChange}
/>
) : activeRequest.bodyType === BODY_TYPE_FORM_MULTIPART ? (
<FormMultipartEditor
forceUpdateKey={forceUpdateKey}
request={activeRequest}
onChange={handleBodyChange}
/>
) : activeRequest.bodyType === BODY_TYPE_BINARY ? (
<BinaryFileEditor
requestId={activeRequest.id}
contentType={contentType}
body={activeRequest.body}
onChange={(body) => patchModelDebounced(activeRequest, { body })}
onChangeContentType={handleContentTypeChange}
/>
) : typeof activeRequest.bodyType === "string" ? (
<Editor
forceUpdateKey={forceUpdateKey}
autocompleteFunctions
autocompleteVariables
language={languageFromContentType(contentType)}
placeholder="..."
heightMode={fullHeight ? "full" : "auto"}
defaultValue={`${activeRequest.body?.text ?? ""}`}
onChange={handleBodyTextChange}
stateKey={`other.${activeRequest.id}`}
/>
) : (
<EmptyStateText>No Body</EmptyStateText>
)}
</ConfirmLargeRequestBody>
</TabContent>
<TabContent value={TAB_DESCRIPTION}>
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full">
<PlainInput
label="Request Name"
hideLabel
forceUpdateKey={updateKey}
defaultValue={activeRequest.name}
className="font-sans text-xl! px-0!"
containerClassName="border-0"
placeholder={resolvedModelName(activeRequest)}
onChange={(name) => patchModel(activeRequest, { name })}
/>
<MarkdownEditor
name="request-description"
placeholder="Request description"
defaultValue={activeRequest.description}
stateKey={`description.${activeRequest.id}`}
forceUpdateKey={updateKey}
onChange={(description) => patchModel(activeRequest, { description })}
/>
</div>
</TabContent>
</Tabs>
</>
)}
</div>
) : (
sharedEditor
)}
</ConfirmLargeRequestBody>
),
[activeRequest, contentType, forceUpdateKey, handleBodyChange, handleContentTypeChange],
);
const renderUrlParameters = useCallback(
(pairs: EditablePair[]) => (
<UrlParametersEditor
stateKey={`params.${activeRequest.id}`}
forceUpdateKey={forceUpdateKey + urlParametersKey}
pairs={pairs}
onChange={(urlParameters) => patchModelDebounced(activeRequest, { urlParameters })}
/>
),
[activeRequest, forceUpdateKey, urlParametersKey],
);
return (
<HttpRequestPaneView
editable
style={style}
className={className}
fullHeight={fullHeight}
request={requestView}
tabsRef={tabsRef}
tabsStorageKey={TABS_STORAGE_KEY}
forceUpdateKey={forceUpdateKey}
urlBarKey={forceUpdateKey + urlKey}
urlForceUpdateKey={updateKey}
urlAutocomplete={autocomplete}
onUrlChange={handleUrlChange}
onUrlPasteOverwrite={handlePaste}
onSend={handleSend}
onCancel={cancelResponse}
isSending={activeResponse != null && activeResponse.state !== "closed"}
sendHotkeyAction="request.send"
focusHotkeyAction="url_bar.focus"
methodSlot={<RequestMethodDropdown request={activeRequest} className="ml-0.5 h-full!" />}
bodyTab={bodyTab}
renderBody={renderBody}
onBodyChange={handleBodyChange}
onBodyTextChange={handleJsonOrBodyTextChange}
onSendJsonCommentsChange={handleSendJsonCommentsChange}
jsonNoticeStorageKey={activeRequest.workspaceId}
urlParameterPairs={urlParameterPairs}
renderUrlParameters={renderUrlParameters}
headersTabs={headersTab}
inheritedHeaders={inheritedHeaders}
headersForceUpdateKey={`${forceUpdateHeaderEditorKey}::${forceUpdateKey}`}
onHeadersChange={(headers) => patchModelDebounced(activeRequest, { headers })}
authTabs={authTab}
authContent={<HttpAuthenticationEditor model={activeRequest} />}
extraTabs={extraTabs}
/>
);
}
+186 -356
View File
@@ -1,51 +1,38 @@
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 {
CountBadge,
type ExtraPaneTab,
type HostBodyViewerArgs,
HStack,
HttpResponsePaneView,
Icon,
type ResponseTextViewerExtras,
type TextFilterApi,
Tooltip,
VStack,
} from "@yaakapp-internal/ui";
import type { CSSProperties } from "react";
import { useCallback, useMemo } from "react";
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
import { useCopyHttpResponse } from "../hooks/useCopyHttpResponse";
import { useRequestBodyLoader, useResponseBodyLoader } from "../hooks/useHttpBodyLoaders";
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
import { useResponseBodyBytes, useResponseBodyText } from "../hooks/useResponseBodyText";
import { useResponseBodyUrl } from "../hooks/useResponseBodyUrl";
import { useRecentFilters } from "../hooks/useRecentFilters";
import { useResponseViewMode } from "../hooks/useResponseViewMode";
import { useSaveResponse } from "../hooks/useSaveResponse";
import { useTimelineViewMode } from "../hooks/useTimelineViewMode";
import { getMimeTypeFromContentType } from "../lib/contentType";
import { getContentTypeFromHeaders, getCookieCounts } from "../lib/model_util";
import { ConfirmLargeResponse } from "./ConfirmLargeResponse";
import { ConfirmLargeResponseRequest } from "./ConfirmLargeResponseRequest";
import { Button } from "./core/Button";
import { CountBadge } from "./core/CountBadge";
import { httpResponseToView } from "../lib/httpViews";
import { getCookieCounts } from "../lib/model_util";
import { CopyButton } from "./CopyButton";
import { HotkeyList } from "./core/HotkeyList";
import { HttpResponseDurationTag } from "./core/HttpResponseDurationTag";
import { HttpStatusTag } from "./core/HttpStatusTag";
import { IconButton } from "./core/IconButton";
import { PillButton } from "./core/PillButton";
import { SizeTag } from "./core/SizeTag";
import type { TabItem } from "./core/Tabs/Tabs";
import { TabContent, Tabs } from "./core/Tabs/Tabs";
import { Tooltip } from "./core/Tooltip";
import { EmptyStateText } from "./EmptyStateText";
import { ErrorBoundary } from "./ErrorBoundary";
import { HttpResponseTimeline } from "./HttpResponseTimeline";
import { RecentHttpResponsesDropdown } from "./RecentHttpResponsesDropdown";
import { RequestVersionDropdown } from "./RequestVersionDropdown";
import { RequestBodyViewer } from "./RequestBodyViewer";
import { ResponseCookies } from "./ResponseCookies";
import { ResponseHeaders } from "./ResponseHeaders";
import { AudioViewer } from "./responseViewers/AudioViewer";
import { CsvViewer } from "./responseViewers/CsvViewer";
import { EventStreamViewer } from "./responseViewers/EventStreamViewer";
import { HTMLOrTextViewer } from "./responseViewers/HTMLOrTextViewer";
import { ImageViewer } from "./responseViewers/ImageViewer";
import { MultipartViewer } from "./responseViewers/MultipartViewer";
import { SvgViewer } from "./responseViewers/SvgViewer";
import { VideoViewer } from "./responseViewers/VideoViewer";
const PdfViewer = lazy(() =>
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
);
import { RecentFiltersDropdown } from "./responseViewers/RecentFiltersDropdown";
interface Props {
style?: CSSProperties;
@@ -53,10 +40,6 @@ interface Props {
activeRequestId: string;
}
const TAB_BODY = "body";
const TAB_REQUEST = "request";
const TAB_HEADERS = "headers";
const TAB_COOKIES = "cookies";
const TAB_TIMELINE = "timeline";
export type TimelineViewMode = "timeline" | "text";
@@ -66,12 +49,11 @@ interface RedirectDropWarning {
droppedHeaders: string[];
}
/** The shared response pane, bound to the pinned response of the active request */
export function HttpResponsePane({ style, className, activeRequestId }: Props) {
const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId);
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
const [timelineViewMode, setTimelineViewMode] = useTimelineViewMode();
const contentType = getContentTypeFromHeaders(activeResponse?.headers ?? null);
const mimeType = contentType == null ? null : getMimeTypeFromContentType(contentType).essence;
const responseEvents = useHttpResponseEvents(activeResponse);
const redirectDropWarning = useMemo(
@@ -84,281 +66,184 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
const cookieCounts = useMemo(() => getCookieCounts(responseEvents.data), [responseEvents.data]);
const saveResponse = useSaveResponse(activeResponse ?? null);
const copyResponse = useCopyHttpResponse(activeResponse ?? null);
const cancel = useCancelHttpResponse(activeResponse?.id ?? null);
const body = useResponseBodyLoader(activeResponse ?? null);
const requestBody = useRequestBodyLoader(activeResponse ?? null);
const textViewer = useResponseTextViewerExtras(activeResponse ?? null);
const tabs = useMemo<TabItem[]>(
() => [
{
value: TAB_BODY,
label: "Response",
options: {
value: viewMode,
onChange: setViewMode,
items: [
{ label: "Response", value: "pretty" },
...(mimeType?.startsWith("image")
? []
: [{ label: "Response (Raw)", shortLabel: "Raw", value: "raw" }]),
],
itemsAfter: [
{
label: "Save to File",
onSelect: saveResponse.mutate,
leftSlot: <Icon icon="save" />,
hidden: activeResponse == null || !!activeResponse.error,
disabled: activeResponse?.state !== "closed" && (activeResponse?.status ?? 0) >= 100,
},
{
label: "Copy Body",
onSelect: copyResponse.mutate,
leftSlot: <Icon icon="copy" />,
hidden: activeResponse == null || !!activeResponse.error,
disabled: activeResponse?.state !== "closed" && (activeResponse?.status ?? 0) >= 100,
},
],
},
},
{
value: TAB_REQUEST,
label: "Request",
rightSlot:
(activeResponse?.requestContentLength ?? 0) > 0 ? <CountBadge count={true} /> : null,
},
{
value: TAB_HEADERS,
label: "Headers",
rightSlot: (
<CountBadge
count={activeResponse?.requestHeaders.length ?? 0}
count2={activeResponse?.headers.length ?? 0}
showZero
/>
),
},
{
value: TAB_COOKIES,
label: "Cookies",
rightSlot:
cookieCounts.sent > 0 || cookieCounts.received > 0 ? (
<CountBadge count={cookieCounts.sent} count2={cookieCounts.received} showZero />
) : null,
},
{
value: TAB_TIMELINE,
rightSlot: <CountBadge count={responseEvents.data?.length ?? 0} />,
options: {
value: timelineViewMode,
onChange: (v) => setTimelineViewMode((v as TimelineViewMode) ?? "timeline"),
items: [
{ label: "Timeline", value: "timeline" },
{ label: "Timeline (Text)", shortLabel: "Timeline", value: "text" },
],
},
},
],
[
activeResponse?.headers,
activeResponse,
activeResponse?.error,
activeResponse?.requestContentLength,
activeResponse?.requestHeaders.length,
activeResponse?.state,
activeResponse?.status,
cookieCounts.sent,
cookieCounts.received,
copyResponse.mutate,
mimeType,
responseEvents.data?.length,
saveResponse.mutate,
setViewMode,
viewMode,
timelineViewMode,
setTimelineViewMode,
],
const responseView = useMemo(
() => (activeResponse == null ? null : httpResponseToView(activeResponse, responseEvents.data)),
[activeResponse, responseEvents.data],
);
const cancel = useCancelHttpResponse(activeResponse?.id ?? null);
const extraTabs = useMemo<ExtraPaneTab[]>(
() =>
activeResponse == null
? []
: [
{
tab: {
value: TAB_TIMELINE,
rightSlot: <CountBadge count={responseEvents.data?.length ?? 0} />,
options: {
value: timelineViewMode,
onChange: (v) => setTimelineViewMode((v as TimelineViewMode) ?? "timeline"),
items: [
{ label: "Timeline", value: "timeline" },
{ label: "Timeline (Text)", shortLabel: "Timeline", value: "text" },
],
},
},
content: (
<HttpResponseTimeline response={activeResponse} viewMode={timelineViewMode} />
),
},
],
[activeResponse, responseEvents.data?.length, setTimelineViewMode, timelineViewMode],
);
const renderBodyViewer = useCallback(
({ mimeType, viewMode }: HostBodyViewerArgs) =>
activeResponse != null && mimeType?.match(/^text\/event-stream/i) && viewMode === "pretty" ? (
<EventStreamViewer response={activeResponse} />
) : null,
[activeResponse],
);
return (
<div
<HttpResponsePaneView
style={style}
className={classNames(
className,
"x-theme-responsePane",
"max-h-full h-full",
"bg-surface rounded-md border border-border-subtle overflow-hidden",
"relative",
)}
>
{activeResponse == null ? (
className={className}
response={responseView}
body={body}
requestBody={requestBody}
emptyState={
<HotkeyList hotkeys={["request.send", "model.create", "sidebar.focus", "url_bar.focus"]} />
) : (
<div className="h-full w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1">
<HStack
className={classNames(
"text-text-subtle w-full shrink-0",
// Remove a bit of space because the tabs have lots too
"-mb-1.5",
)}
>
{activeResponse && (
<div
className={classNames(
"grid grid-cols-[auto_minmax(4rem,1fr)_auto]",
"cursor-default select-none",
"whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars",
}
viewMode={viewMode === "raw" ? "raw" : "pretty"}
onViewModeChange={setViewMode}
activeTabKey={activeRequestId}
onSaveBody={saveResponse.mutate}
onCopyBody={copyResponse.mutate}
onCancel={cancel.mutate}
cookieCounts={cookieCounts}
extraTabs={extraTabs}
textViewer={textViewer}
renderBodyViewer={renderBodyViewer}
renderCopyButton={renderCopyButton}
statusLineCenter={
shouldShowRedirectDropWarning ? (
<Tooltip
tabIndex={0}
className="my-auto pl-3 shrink-0 max-w-full justify-self-end overflow-hidden"
content={
<VStack alignItems="start" space={1} className="text-xs">
<span className="font-medium text-warning">Redirect changed this request</span>
{redirectDropWarning.droppedBodyCount > 0 && (
<span>
Body dropped on {redirectDropWarning.droppedBodyCount}{" "}
{redirectDropWarning.droppedBodyCount === 1 ? "redirect hop" : "redirect hops"}
</span>
)}
>
<HStack space={2} className="w-full shrink-0">
{activeResponse.state !== "closed" && <LoadingIcon size="sm" />}
<HttpStatusTag showReason response={activeResponse} />
<span>&bull;</span>
<HttpResponseDurationTag response={activeResponse} />
<span>&bull;</span>
<SizeTag
contentLength={activeResponse.contentLength ?? 0}
contentLengthCompressed={activeResponse.contentLengthCompressed}
/>
</HStack>
{shouldShowRedirectDropWarning ? (
<Tooltip
tabIndex={0}
className="my-auto pl-3 shrink-0 max-w-full justify-self-end overflow-hidden"
content={
<VStack alignItems="start" space={1} className="text-xs">
<span className="font-medium text-warning">
Redirect changed this request
</span>
{redirectDropWarning.droppedBodyCount > 0 && (
<span>
Body dropped on {redirectDropWarning.droppedBodyCount}{" "}
{redirectDropWarning.droppedBodyCount === 1
? "redirect hop"
: "redirect hops"}
</span>
)}
{redirectDropWarning.droppedHeaders.length > 0 && (
<span>
Headers dropped:{" "}
<span className="font-mono">
{redirectDropWarning.droppedHeaders.join(", ")}
</span>
</span>
)}
<span className="text-text-subtle">See Timeline for details.</span>
</VStack>
}
>
<span className="inline-flex min-w-0">
<PillButton
color="warning"
className="font-sans text-sm shrink! max-w-full"
innerClassName="flex items-center"
leftSlot={<Icon icon="alert_triangle" size="xs" color="warning" />}
>
<span className="truncate">
{getRedirectWarningLabel(redirectDropWarning)}
</span>
</PillButton>
{redirectDropWarning.droppedHeaders.length > 0 && (
<span>
Headers dropped:{" "}
<span className="font-mono">
{redirectDropWarning.droppedHeaders.join(", ")}
</span>
</Tooltip>
) : (
<span />
</span>
)}
<HStack space={1} className="justify-self-end shrink-0">
<RequestVersionDropdown response={activeResponse} />
<RecentHttpResponsesDropdown
responses={responses}
activeResponse={activeResponse}
onPinnedResponseId={setPinnedResponseId}
/>
</HStack>
</div>
)}
<span className="text-text-subtle">See Timeline for details.</span>
</VStack>
}
>
<span className="inline-flex min-w-0">
<PillButton
color="warning"
className="font-sans text-sm shrink! max-w-full"
innerClassName="flex items-center"
leftSlot={<Icon icon="alert_triangle" size="xs" color="warning" />}
>
<span className="truncate">{getRedirectWarningLabel(redirectDropWarning)}</span>
</PillButton>
</span>
</Tooltip>
) : null
}
statusLineRight={
activeResponse != null && (
<HStack space={1}>
<RequestVersionDropdown response={activeResponse} />
<RecentHttpResponsesDropdown
responses={responses}
activeResponse={activeResponse}
onPinnedResponseId={setPinnedResponseId}
/>
</HStack>
<div className="overflow-hidden flex flex-col min-h-0">
{activeResponse?.error && (
<Banner color="danger" className="mx-3 mt-1 shrink-0">
{activeResponse.error}
</Banner>
)}
{/* Show tabs if we have any data (headers, body, etc.) even if there's an error */}
<Tabs
tabs={tabs}
label="Response"
className="ml-3 mr-3 mb-3 min-h-0 flex-1"
tabListClassName="mt-0.5 -mb-1.5"
storageKey="http_response_tabs"
activeTabKey={activeRequestId}
>
<TabContent value={TAB_BODY}>
<ErrorBoundary name="Http Response Viewer">
<Suspense>
<ConfirmLargeResponse response={activeResponse}>
{activeResponse.state === "initialized" ? (
<EmptyStateText>
<VStack space={3}>
<HStack space={3}>
<LoadingIcon className="text-text-subtlest" />
Sending Request
</HStack>
<Button size="sm" variant="border" onClick={() => cancel.mutate()}>
Cancel
</Button>
</VStack>
</EmptyStateText>
) : activeResponse.state === "closed" &&
(activeResponse.contentLength ?? 0) === 0 ? (
<EmptyStateText>Empty</EmptyStateText>
) : mimeType?.match(/^text\/event-stream/i) && viewMode === "pretty" ? (
<EventStreamViewer response={activeResponse} />
) : mimeType?.match(/^image\/svg/) ? (
<HttpSvgViewer response={activeResponse} />
) : mimeType?.match(/^image/i) ? (
<EnsureCompleteResponse response={activeResponse} Component={ImageViewer} />
) : mimeType?.match(/^audio/i) ? (
<EnsureCompleteResponse response={activeResponse} Component={AudioViewer} />
) : mimeType?.match(/^video/i) ? (
<EnsureCompleteResponse response={activeResponse} Component={VideoViewer} />
) : mimeType?.match(/^multipart/i) && viewMode === "pretty" ? (
<HttpMultipartViewer response={activeResponse} />
) : mimeType?.match(/pdf/i) ? (
<EnsureCompleteResponse response={activeResponse} Component={PdfViewer} />
) : mimeType?.match(/csv|tab-separated/i) && viewMode === "pretty" ? (
<HttpCsvViewer className="pb-2" response={activeResponse} />
) : (
<HTMLOrTextViewer
textViewerClassName="-mr-2 bg-surface" // Pull to the right
response={activeResponse}
pretty={viewMode === "pretty"}
/>
)}
</ConfirmLargeResponse>
</Suspense>
</ErrorBoundary>
</TabContent>
<TabContent value={TAB_REQUEST}>
<ConfirmLargeResponseRequest response={activeResponse}>
<RequestBodyViewer response={activeResponse} />
</ConfirmLargeResponseRequest>
</TabContent>
<TabContent value={TAB_HEADERS}>
<ResponseHeaders response={activeResponse} />
</TabContent>
<TabContent value={TAB_COOKIES}>
<ResponseCookies response={activeResponse} />
</TabContent>
<TabContent value={TAB_TIMELINE}>
<HttpResponseTimeline response={activeResponse} viewMode={timelineViewMode} />
</TabContent>
</Tabs>
</div>
</div>
)}
</div>
)
}
/>
);
}
/** The client's additions to the response text viewer: filter history, save and copy */
function useResponseTextViewerExtras(
response: HttpResponse | null,
): ResponseTextViewerExtras | undefined {
const filterStateKey = `response.body.${response?.requestId ?? ""}`;
const { recentFilters, addFilter, removeFilter, togglePin, clearFilters } =
useRecentFilters(filterStateKey);
const saveResponse = useSaveResponse(response);
const copyResponse = useCopyHttpResponse(response);
const filterLeftSlot = useCallback(
(filter: TextFilterApi) => (
<div className="py-0.5 flex">
<RecentFiltersDropdown
recentFilters={recentFilters}
activeFilter={filter.appliedFilter}
onSelect={filter.replaceFilter}
onRemove={removeFilter}
onTogglePin={togglePin}
onClear={clearFilters}
/>
</div>
),
[recentFilters, removeFilter, togglePin, clearFilters],
);
if (response == null) return undefined;
const actionsDisabled = response.state !== "closed" && response.status >= 100;
return {
filterStateKey,
onFilterApplied: addFilter,
filterLeftSlot,
footerActions: [
<IconButton
key="save"
size="sm"
icon="save"
title="Save response to file"
disabled={actionsDisabled}
onClick={() => saveResponse.mutate()}
className="border !border-border-subtle"
/>,
<IconButton
key="copy"
size="sm"
icon="copy"
title="Copy response body"
disabled={actionsDisabled}
onClick={() => copyResponse.mutate()}
className="border !border-border-subtle"
/>,
],
};
}
function renderCopyButton(getText: () => Promise<string | null>) {
return <CopyButton color="secondary" variant="border" size="xs" text={getText} />;
}
function getRedirectDropWarning(
events: HttpResponseEvent[] | undefined,
): RedirectDropWarning | null {
@@ -406,58 +291,3 @@ function getRedirectWarningLabel(warning: RedirectDropWarning): string {
}
return "Dropped headers";
}
function EnsureCompleteResponse({
response,
Component,
}: {
response: HttpResponse;
Component: ComponentType<{ bodyUrl: string }>;
}) {
// Wait until the response has been fully-downloaded before asking for it
const complete = response.state === "closed";
const bodyUrl = useResponseBodyUrl(complete ? response : null);
if (!complete || bodyUrl.isPending) {
return (
<EmptyStateText>
<LoadingIcon />
</EmptyStateText>
);
}
if (bodyUrl.error) {
return <Banner color="danger">{String(bodyUrl.error)}</Banner>;
}
if (bodyUrl.data == null) {
return <div>Empty response body</div>;
}
return <Component bodyUrl={bodyUrl.data} />;
}
function HttpSvgViewer({ response }: { response: HttpResponse }) {
const body = useResponseBodyText({ response, filter: null });
if (!body.data) return null;
return <SvgViewer text={body.data} />;
}
function HttpCsvViewer({ response, className }: { response: HttpResponse; className?: string }) {
const body = useResponseBodyText({ response, filter: null });
return <CsvViewer text={body.data ?? null} className={className} />;
}
function HttpMultipartViewer({ response }: { response: HttpResponse }) {
const body = useResponseBodyBytes({ response });
if (body.data == null) return null;
const contentTypeHeader = getContentTypeFromHeaders(response.headers);
const boundary = contentTypeHeader?.split("boundary=")[1] ?? "unknown";
return <MultipartViewer data={body.data} boundary={boundary} idPrefix={response.id} />;
}
@@ -13,9 +13,8 @@ import { resolvedModelName } from "../lib/resolvedModelName";
import { Editor } from "./core/Editor/LazyEditor";
import { type EventDetailAction, EventDetailHeader, EventViewer } from "./core/EventViewer";
import { EventViewerRow } from "./core/EventViewerRow";
import { HttpStatusTagRaw } from "./core/HttpStatusTag";
import { Icon, type IconProps } from "@yaakapp-internal/ui";
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
import { Icon, type IconProps, HttpStatusTagRaw, KeyValueRows } from "@yaakapp-internal/ui";
import { KeyValueRow } from "./core/KeyValueRow";
import type { TimelineViewMode } from "./HttpResponsePane";
interface Props {
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,271 @@
import { platform } from "@yaakapp-internal/platform";
import { Icon, LoadingIcon } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { type KeyboardEvent, type ReactNode, useEffect, useRef, useState } from "react";
import { Button } from "./core/Button";
import { IconButton } from "./core/IconButton";
import { PathList, splitPath } from "./core/PathList";
import { PlainInput } from "./core/PlainInput";
export interface ImportSourcePath {
path: string;
kind?: "file" | "folder" | "url";
}
export type ImportSourceDetection =
| { status: "loading" }
| { status: "ok"; importer: string }
| { status: "error"; message: string };
interface Props {
sources: ImportSourcePath[];
detections: Record<string, ImportSourceDetection>;
disabled?: boolean;
onAdd: (sources: ImportSourcePath[]) => void;
onRemove: (path: string) => void;
onError: (err: unknown) => void;
}
/** A bare hostname is a URL; local paths need an absolute or explicit relative prefix. */
export function isImportFilePath(value: string): boolean {
return (
value.startsWith("/") ||
value.startsWith("./") ||
value.startsWith("../") ||
value.startsWith("~/") ||
value.startsWith("\\\\") ||
/^[a-zA-Z]:[\\/]/.test(value)
);
}
/** A typed or prefilled path is a URL unless it looks like a local path; kind is left for the host */
export function toSourcePath(path: string): ImportSourcePath {
return { path, kind: isImportFilePath(path) ? undefined : "url" };
}
export function ImportSourceList({
sources,
detections,
disabled,
onAdd,
onRemove,
onError,
}: Props) {
const ref = useRef<HTMLDivElement>(null);
const [isHovering, setIsHovering] = useState(false);
const [pathInput, setPathInput] = useState("");
const [showPathInput, setShowPathInput] = useState(false);
const pathInputRef = useRef<{ focus: () => void }>(null);
const trimmedPath = pathInput.trim();
const openPathInput = () => {
setShowPathInput(true);
// The add-row menu hands focus back to its trigger as it closes, on a timer, so focus after it
setTimeout(() => pathInputRef.current?.focus(), 50);
};
const closePathInput = () => {
setPathInput("");
setShowPathInput(false);
};
const addPath = () => {
if (!trimmedPath || disabled) return;
onAdd([toSourcePath(trimmedPath)]);
closePathInput();
};
useEffect(() => {
if (disabled) return;
return platform.window.onDragDrop((event) => {
if (event.type === "leave") {
setIsHovering(false);
return;
}
const rect = ref.current?.getBoundingClientRect();
const { x, y } = event.position;
const isOver =
rect != null && x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
if (event.type === "drop") {
if (isOver) onAdd(event.paths.map((path) => ({ path })));
setIsHovering(false);
} else {
setIsHovering(isOver);
}
});
}, [disabled, onAdd]);
const pick = async (directory: boolean) => {
try {
const selected = await platform.dialog.open({
title: directory ? "Select Import Folder" : "Select Import Files",
multiple: true,
directory,
});
if (selected == null) return;
onAdd(selected.map((path) => ({ path, kind: directory ? "folder" : "file" })));
} catch (err) {
onError(err);
}
};
const handlePathKeyDown = (event: KeyboardEvent) => {
if (event.key === "Enter") {
event.preventDefault();
addPath();
} else if (event.key === "Escape") {
event.stopPropagation();
closePathInput();
}
};
const pathInputEl = (
<PlainInput
ref={pathInputRef}
label="URL or file path"
hideLabel
size="sm"
autoFocus
disabled={disabled}
placeholder="Paste a URL or a file path"
onChange={setPathInput}
onKeyDownCapture={handlePathKeyDown}
rightSlot={
<div className="flex items-center gap-1 my-1 mr-1">
{trimmedPath !== "" && (
<Button size="xs" variant="border" onClick={addPath}>
Add
</Button>
)}
<IconButton size="xs" icon="x" title="Cancel" onClick={closePathInput} />
</div>
}
/>
);
if (sources.length === 0) {
return (
<div className="flex-1 flex flex-col gap-2">
<div
ref={ref}
className={classNames(
"flex-1 rounded-lg border min-h-28 flex flex-col items-center justify-center gap-2 px-4 py-5 text-center text-text-subtle",
isHovering ? "border-notice bg-surface-highlight" : "border-dashed border-border",
)}
>
<Icon icon="folder_input" className="text-text-subtlest w-10! h-10! mb-1" />
{isHovering ? (
<span>Drop to add</span>
) : (
<span>
Choose{" "}
<InlineButton disabled={disabled} onClick={() => pick(false)}>
files
</InlineButton>
, a{" "}
<InlineButton disabled={disabled} onClick={() => pick(true)}>
folder
</InlineButton>
, or a{" "}
<InlineButton disabled={disabled} onClick={openPathInput}>
URL
</InlineButton>
, or drag them here
</span>
)}
<span className="text-xs text-text-subtlest">
Supports OpenAPI, Swagger, Postman, Insomnia, Bruno, curl, and Yaak exports
</span>
</div>
{showPathInput && pathInputEl}
</div>
);
}
return (
<PathList
ref={ref}
hovering={isHovering}
disabled={disabled}
onRemove={onRemove}
addLabel="Add files, a folder, or a URL"
addHint="or drop them here"
addItems={[
{ label: "Files", icon: "file", onSelect: () => pick(false) },
{ label: "Folder", icon: "folder", onSelect: () => pick(true) },
{ label: "URL or file path", icon: "globe", onSelect: openPathInput },
]}
items={sources.map((source) => {
const detection = detections[source.path];
return {
path: source.path,
icon: sourceIcon(source),
failed: detection?.status === "error",
subtitle: <DetectionSubtitle path={source.path} detection={detection} />,
};
})}
>
{showPathInput && pathInputEl}
</PathList>
);
}
function DetectionSubtitle({
path,
detection,
}: {
path: string;
detection: ImportSourceDetection | undefined;
}) {
const { parent } = splitPath(path);
if (detection?.status === "loading") {
return (
<>
<LoadingIcon size="xs" />
Detecting format
</>
);
}
if (detection?.status === "error") return detection.message;
return (
<>
{detection?.status === "ok" && <span>{detection.importer}</span>}
{detection?.status === "ok" && parent !== "" && <span>·</span>}
{parent !== "" && <span className="truncate font-mono">{parent}</span>}
</>
);
}
function sourceIcon({ kind }: ImportSourcePath) {
switch (kind) {
case "url":
return "globe";
case "folder":
return "folder";
case "file":
return "file";
case undefined:
return "import";
}
}
export function InlineButton({
children,
disabled,
onClick,
}: {
children: ReactNode;
disabled?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
disabled={disabled}
className="underline decoration-current/40 underline-offset-3 hocus:decoration-current disabled:opacity-disabled"
onClick={onClick}
>
{children}
</button>
);
}
@@ -1,122 +0,0 @@
import { linter } from "@codemirror/lint";
import type { HttpRequest } from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models";
import { Banner, Icon } from "@yaakapp-internal/ui";
import { useCallback, useMemo } from "react";
import { useKeyValue } from "../hooks/useKeyValue";
import { fireAndForget } from "../lib/fireAndForget";
import { textLikelyContainsJsonComments } from "../lib/jsonComments";
import type { DropdownItem } from "./core/Dropdown";
import { Dropdown } from "./core/Dropdown";
import type { EditorProps } from "./core/Editor/Editor";
import { jsonParseLinter } from "./core/Editor/json-lint";
import { Editor } from "./core/Editor/LazyEditor";
import { IconButton } from "./core/IconButton";
import { IconTooltip } from "./core/IconTooltip";
interface Props {
forceUpdateKey: string;
heightMode: EditorProps["heightMode"];
request: HttpRequest;
}
export function JsonBodyEditor({ forceUpdateKey, heightMode, request }: Props) {
const handleChange = useCallback(
(text: string) => patchModel(request, { body: { ...request.body, text } }),
[request],
);
const autoFix = request.body?.sendJsonComments !== true;
const lintExtension = useMemo(
() =>
linter(
jsonParseLinter(
autoFix
? { allowComments: true, allowTrailingCommas: true }
: { allowComments: false, allowTrailingCommas: false },
),
),
[autoFix],
);
const hasComments = useMemo(
() => textLikelyContainsJsonComments(request.body?.text ?? ""),
[request.body?.text],
);
const { value: bannerDismissed, set: setBannerDismissed } = useKeyValue<boolean>({
namespace: "no_sync",
key: ["json-fix-3", request.workspaceId],
fallback: false,
});
const handleToggleAutoFix = useCallback(() => {
const newBody = { ...request.body };
if (autoFix) {
newBody.sendJsonComments = true;
} else {
delete newBody.sendJsonComments;
}
fireAndForget(patchModel(request, { body: newBody }));
}, [request, autoFix]);
const handleDropdownOpen = useCallback(() => {
if (!bannerDismissed) {
fireAndForget(setBannerDismissed(true));
}
}, [bannerDismissed, setBannerDismissed]);
const showBanner = hasComments && autoFix && !bannerDismissed;
const stripMessage = "Automatically strip comments and trailing commas before sending";
const actions = useMemo<EditorProps["actions"]>(
() => [
showBanner && (
<Banner color="notice" className="opacity-100! h-sm py-0! px-2! flex items-center text-xs">
<p className="inline-flex items-center gap-1 min-w-0">
<span className="truncate">Auto-fix enabled</span>
<Icon icon="arrow_right" size="sm" className="opacity-disabled" />
</p>
</Banner>
),
<div key="settings" className="opacity-100! shadow!">
<Dropdown
onOpen={handleDropdownOpen}
items={
[
{
label: "Automatically Fix JSON",
keepOpenOnSelect: true,
onSelect: handleToggleAutoFix,
rightSlot: <IconTooltip content={stripMessage} />,
leftSlot: (
<Icon icon={autoFix ? "check_square_checked" : "check_square_unchecked"} />
),
},
] satisfies DropdownItem[]
}
>
<IconButton size="sm" variant="border" icon="settings" title="JSON Settings" />
</Dropdown>
</div>,
],
[handleDropdownOpen, handleToggleAutoFix, autoFix, showBanner],
);
return (
<Editor
forceUpdateKey={forceUpdateKey}
autocompleteFunctions
autocompleteVariables
placeholder="..."
heightMode={heightMode}
defaultValue={`${request.body?.text ?? ""}`}
language="json"
onChange={handleChange}
stateKey={`json.${request.id}`}
actions={actions}
lintExtension={lintExtension}
/>
);
}
@@ -1,15 +1,20 @@
import { formatSize } from "@yaakapp-internal/lib/formatSize";
import { Banner, Button, HStack, LoadingIcon } from "@yaakapp-internal/ui";
import {
Banner,
Button,
HStack,
LoadingIcon,
AudioViewer,
ImageViewer,
SvgViewer,
VideoViewer,
} from "@yaakapp-internal/ui";
import type { ReactNode } from "react";
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import type { SniffedValue } from "./core/Editor/sniffValue";
import { showDialog } from "../lib/dialog";
import { decodeValue, largeValueActions } from "../lib/largeValue";
import { Dropdown } from "./core/Dropdown";
import { AudioViewer } from "./responseViewers/AudioViewer";
import { ImageViewer } from "./responseViewers/ImageViewer";
import { SvgViewer } from "./responseViewers/SvgViewer";
import { VideoViewer } from "./responseViewers/VideoViewer";
const PdfViewer = lazy(() =>
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
@@ -3,12 +3,12 @@ import { patchModel, workspacesAtom } from "@yaakapp-internal/models";
import { InlineCode, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useState } from "react";
import { pluralizeCount } from "../lib/pluralize";
import { resolvedModelName } from "../lib/resolvedModelName";
import { router } from "../lib/router";
import { showToast } from "../lib/toast";
import { Button } from "./core/Button";
import { Select } from "./core/Select";
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
interface Props {
activeWorkspaceId: string;
+212
View File
@@ -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>
);
}
@@ -1,6 +1,6 @@
import type { GrpcConnection } from "@yaakapp-internal/models";
import { deleteModel } from "@yaakapp-internal/models";
import { HStack, Icon } from "@yaakapp-internal/ui";
import { HStack, Icon, formatMillis } from "@yaakapp-internal/ui";
import {
differenceInHours,
differenceInMinutes,
@@ -9,10 +9,9 @@ import {
isYesterday,
} from "date-fns";
import { useDeleteGrpcConnections } from "../hooks/useDeleteGrpcConnections";
import { pluralizeCount } from "../lib/pluralize";
import { Dropdown, type DropdownItem } from "./core/Dropdown";
import { formatMillis } from "./core/HttpResponseDurationTag";
import { IconButton } from "./core/IconButton";
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
interface Props {
connections: GrpcConnection[];
@@ -1,6 +1,6 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { deleteModel } from "@yaakapp-internal/models";
import { HStack, Icon } from "@yaakapp-internal/ui";
import { HStack, Icon, formatMillis, HttpStatusTag, SizeTag } from "@yaakapp-internal/ui";
import {
differenceInHours,
differenceInMinutes,
@@ -12,10 +12,7 @@ import { useDeleteHttpResponses } from "../hooks/useDeleteHttpResponses";
import { useKeyValue } from "../hooks/useKeyValue";
import { DismissibleBanner } from "./core/DismissibleBanner";
import { Dropdown, type DropdownItem } from "./core/Dropdown";
import { formatMillis } from "./core/HttpResponseDurationTag";
import { HttpStatusTag } from "./core/HttpStatusTag";
import { IconButton } from "./core/IconButton";
import { SizeTag } from "./core/SizeTag";
interface Props {
responses: HttpResponse[];
@@ -1,6 +1,6 @@
import type { WebsocketConnection } from "@yaakapp-internal/models";
import { deleteModel, getModel } from "@yaakapp-internal/models";
import { HStack, Icon } from "@yaakapp-internal/ui";
import { HStack, Icon, formatMillis } from "@yaakapp-internal/ui";
import {
differenceInHours,
differenceInMinutes,
@@ -9,10 +9,9 @@ import {
isYesterday,
} from "date-fns";
import { deleteWebsocketConnections } from "../commands/deleteWebsocketConnections";
import { pluralizeCount } from "../lib/pluralize";
import { Dropdown, type DropdownItem } from "./core/Dropdown";
import { formatMillis } from "./core/HttpResponseDurationTag";
import { IconButton } from "./core/IconButton";
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
interface Props {
connections: WebsocketConnection[];
@@ -7,6 +7,7 @@ import { getRecentRequests } from "../hooks/useRecentRequests";
import { useRecentWorkspaces } from "../hooks/useRecentWorkspaces";
import { fireAndForget } from "../lib/fireAndForget";
import { router } from "../lib/router";
import { Onboarding } from "./Onboarding";
export function RedirectToLatestWorkspace() {
const workspaces = useAtomValue(workspacesAtom);
@@ -14,10 +15,6 @@ export function RedirectToLatestWorkspace() {
useEffect(() => {
if (workspaces.length === 0 || recentWorkspaces == null) {
console.log("No workspaces found to redirect to. Skipping.", {
workspaces,
recentWorkspaces,
});
return;
}
@@ -34,11 +31,21 @@ export function RedirectToLatestWorkspace() {
request_id: requestId,
};
// Whatever created the first workspace may have already navigated into it
const currentId = router.history.location.pathname.match(/^\/workspaces\/([^/]+)/)?.[1];
if (workspaces.some((w) => w.id === currentId)) return;
console.log("Redirecting to workspace", params, search);
await router.navigate({ to: "/workspaces/$workspaceId", params, search });
})(),
);
}, [recentWorkspaces, workspaces, workspaces.length]);
// The global models are loaded before the router mounts, so an empty list is a real
// first launch (or the last workspace was just deleted), not a store still loading
if (workspaces.length === 0) {
return <Onboarding />;
}
return null;
}
@@ -1,102 +0,0 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { lazy, Suspense } from "react";
import { useHttpRequestBody } from "../hooks/useHttpRequestBody";
import { getMimeTypeFromContentType, languageFromContentType } from "../lib/contentType";
import { LoadingIcon } from "@yaakapp-internal/ui";
import { EmptyStateText } from "./EmptyStateText";
import { AudioViewer } from "./responseViewers/AudioViewer";
import { CsvViewer } from "./responseViewers/CsvViewer";
import { ImageViewer } from "./responseViewers/ImageViewer";
import { MultipartViewer } from "./responseViewers/MultipartViewer";
import { SvgViewer } from "./responseViewers/SvgViewer";
import { TextViewer } from "./responseViewers/TextViewer";
import { VideoViewer } from "./responseViewers/VideoViewer";
import { WebPageViewer } from "./responseViewers/WebPageViewer";
const PdfViewer = lazy(() =>
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
);
interface Props {
response: HttpResponse;
}
export function RequestBodyViewer({ response }: Props) {
return <RequestBodyViewerInner key={response.id} response={response} />;
}
function RequestBodyViewerInner({ response }: Props) {
const { data, isLoading, error } = useHttpRequestBody(response);
if (isLoading) {
return (
<EmptyStateText>
<LoadingIcon />
</EmptyStateText>
);
}
if (error) {
return <EmptyStateText>Error loading request body: {error.message}</EmptyStateText>;
}
if (data?.bodyText == null || data.bodyText.length === 0) {
return <EmptyStateText>No request body</EmptyStateText>;
}
const { bodyText, body } = data;
// Try to detect language from content-type header that was sent
const contentTypeHeader = response.requestHeaders.find(
(h) => h.name.toLowerCase() === "content-type",
);
const contentType = contentTypeHeader?.value ?? null;
const mimeType = contentType ? getMimeTypeFromContentType(contentType).essence : null;
const language = languageFromContentType(contentType, bodyText);
// Route to appropriate viewer based on content type
if (mimeType?.match(/^multipart/i)) {
const boundary = contentType?.split("boundary=")[1] ?? "unknown";
// Create a copy because parseMultipart may detach the buffer
const bodyCopy = new Uint8Array(body);
return (
<MultipartViewer data={bodyCopy} boundary={boundary} idPrefix={`request.${response.id}`} />
);
}
if (mimeType?.match(/^image\/svg/i)) {
return <SvgViewer text={bodyText} />;
}
if (mimeType?.match(/^image/i)) {
return <ImageViewer data={body.buffer} />;
}
if (mimeType?.match(/^audio/i)) {
return <AudioViewer data={body} />;
}
if (mimeType?.match(/^video/i)) {
return <VideoViewer data={body} />;
}
if (mimeType?.match(/csv|tab-separated/i)) {
return <CsvViewer text={bodyText} />;
}
if (mimeType?.match(/^text\/html/i)) {
return <WebPageViewer html={bodyText} />;
}
if (mimeType?.match(/pdf/i)) {
return (
<Suspense fallback={<LoadingIcon />}>
<PdfViewer data={body} />
</Suspense>
);
}
return (
<TextViewer text={bodyText} language={language} stateKey={`request.body.${response.id}`} />
);
}
@@ -1,38 +1,20 @@
import type { HttpRequest } from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models";
import classNames from "classnames";
import { memo, useCallback, useMemo } from "react";
import { showPrompt } from "../lib/prompt";
import { Button } from "./core/Button";
import type { DropdownItem } from "./core/Dropdown";
import { HttpMethodTag, HttpMethodTagRaw } from "./core/HttpMethodTag";
import { Icon } from "@yaakapp-internal/ui";
import type { RadioDropdownItem } from "./core/RadioDropdown";
import { RadioDropdown } from "./core/RadioDropdown";
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import { RequestMethodDropdown as BaseRequestMethodDropdown } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { memo, useCallback } from "react";
type Props = {
request: HttpRequest;
className?: string;
};
const radioItems: RadioDropdownItem<string>[] = [
"GET",
"PUT",
"POST",
"PATCH",
"DELETE",
"OPTIONS",
"QUERY",
"HEAD",
].map((m) => ({
value: m,
label: <HttpMethodTagRaw method={m} />,
}));
/** The shared method menu, writing to the request and coloured by the user's setting */
export const RequestMethodDropdown = memo(function RequestMethodDropdown({
request,
className,
}: Props) {
const settings = useAtomValue(settingsAtom);
const handleChange = useCallback(
async (method: string) => {
await patchModel(request, { method });
@@ -40,39 +22,12 @@ export const RequestMethodDropdown = memo(function RequestMethodDropdown({
[request],
);
const itemsAfter = useMemo<DropdownItem[]>(
() => [
{
key: "custom",
label: "CUSTOM",
leftSlot: <Icon icon="sparkles" />,
onSelect: async () => {
const newMethod = await showPrompt({
id: "custom-method",
label: "Http Method",
title: "Custom Method",
confirmText: "Save",
description: "Enter a custom method name",
placeholder: "CUSTOM",
});
if (newMethod == null) return;
await handleChange(newMethod);
},
},
],
[handleChange],
);
return (
<RadioDropdown
value={request.method}
items={radioItems}
itemsAfter={itemsAfter}
<BaseRequestMethodDropdown
method={request.method}
colored={settings.coloredMethods}
onChange={handleChange}
>
<Button size="xs" className={classNames(className, "text-text-subtle hover:text")}>
<HttpMethodTag request={request} noAlias />
</Button>
</RadioDropdown>
className={className}
/>
);
});
@@ -1,110 +0,0 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { format, formatDistanceToNowStrict } from "date-fns";
import { useMemo } from "react";
import { CountBadge } from "./core/CountBadge";
import { DetailsBanner } from "./core/DetailsBanner";
import { IconButton } from "./core/IconButton";
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
import { platform } from "@yaakapp-internal/platform";
interface Props {
response: HttpResponse;
}
export function ResponseHeaders({ response }: Props) {
const responseHeaders = useMemo(
() =>
[...response.headers].sort((a, b) =>
a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()),
),
[response.headers],
);
const requestHeaders = useMemo(
() =>
[...response.requestHeaders].sort((a, b) =>
a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()),
),
[response.requestHeaders],
);
return (
<div className="overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5">
<DetailsBanner storageKey={`${response.requestId}.general`} summary={<h2>Info</h2>}>
<KeyValueRows>
<KeyValueRow labelColor="secondary" label="Sent">
<time
dateTime={new Date(`${response.createdAt}Z`).toISOString()}
title={formatDistanceToNowStrict(`${response.createdAt}Z`, { addSuffix: true })}
>
{format(`${response.createdAt}Z`, "MMM d, yyyy, h:mm:ss a O")}
</time>
</KeyValueRow>
<KeyValueRow labelColor="secondary" label="Request URL">
<div className="flex items-center gap-1">
<span className="select-text cursor-text">{response.url}</span>
<IconButton
iconSize="sm"
className="inline-block w-auto h-auto! opacity-50 hover:opacity-100"
icon="external_link"
onClick={() => platform.openUrl(response.url)}
title="Open in browser"
/>
</div>
</KeyValueRow>
<KeyValueRow labelColor="secondary" label="Remote Address">
{response.remoteAddr ?? <span className="text-text-subtlest">--</span>}
</KeyValueRow>
<KeyValueRow labelColor="secondary" label="Version">
{response.version ?? <span className="text-text-subtlest">--</span>}
</KeyValueRow>
</KeyValueRows>
</DetailsBanner>
<DetailsBanner
storageKey={`${response.requestId}.request_headers`}
summary={
<h2 className="flex items-center">
Request Headers <CountBadge showZero count={requestHeaders.length} />
</h2>
}
>
{requestHeaders.length === 0 ? (
<NoHeaders />
) : (
<KeyValueRows>
{requestHeaders.map((h, i) => (
// oxlint-disable-next-line react/no-array-index-key
<KeyValueRow labelColor="primary" key={i} label={h.name}>
{h.value}
</KeyValueRow>
))}
</KeyValueRows>
)}
</DetailsBanner>
<DetailsBanner
defaultOpen
storageKey={`${response.requestId}.response_headers`}
summary={
<h2 className="flex items-center">
Response Headers <CountBadge showZero count={responseHeaders.length} />
</h2>
}
>
{responseHeaders.length === 0 ? (
<NoHeaders />
) : (
<KeyValueRows>
{responseHeaders.map((h, i) => (
// oxlint-disable-next-line react/no-array-index-key
<KeyValueRow labelColor="info" key={i} label={h.name}>
{h.value}
</KeyValueRow>
))}
</KeyValueRows>
)}
</DetailsBanner>
</div>
);
}
function NoHeaders() {
return <span className="text-text-subtlest text-sm italic">No Headers</span>;
}
+2 -1
View File
@@ -1,7 +1,8 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { IconButton } from "./core/IconButton";
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
import { KeyValueRow } from "./core/KeyValueRow";
import { platform } from "@yaakapp-internal/platform";
import { KeyValueRows } from "@yaakapp-internal/ui";
interface Props {
response: HttpResponse;
+1 -3
View File
@@ -1,5 +1,5 @@
import { platform } from "@yaakapp-internal/platform";
import { HStack } from "@yaakapp-internal/ui";
import { HStack, IconTooltip, Label } from "@yaakapp-internal/ui";
import classNames from "classnames";
import mime from "mime";
import type { ReactNode } from "react";
@@ -7,8 +7,6 @@ import { useEffect, useRef, useState } from "react";
import type { ButtonProps } from "./core/Button";
import { Button } from "./core/Button";
import { IconButton } from "./core/IconButton";
import { IconTooltip } from "./core/IconTooltip";
import { Label } from "./core/Label";
type Props = Omit<ButtonProps, "type"> & {
onChange: (value: { filePath: string | null; contentType: string | null }) => void;
@@ -1,14 +1,14 @@
import { platform } from "@yaakapp-internal/platform";
import { useLicense } from "@yaakapp-internal/license";
import { pluginsAtom, settingsAtom } from "@yaakapp-internal/models";
import { HeaderSize, HStack, Icon } from "@yaakapp-internal/ui";
import { HeaderSize, HStack, Icon, CountBadge } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import { useKeyPressEvent } from "react-use";
import { appInfo } from "../../lib/appInfo";
import { capitalize } from "../../lib/capitalize";
import { CountBadge } from "../core/CountBadge";
import { TabContent, type TabItem, Tabs } from "../core/Tabs/Tabs";
import { SettingsAgents } from "./SettingsAgents";
import { SettingsCertificates } from "./SettingsCertificates";
import { SettingsGeneral } from "./SettingsGeneral";
import { SettingsHotkeys } from "./SettingsHotkeys";
@@ -31,6 +31,7 @@ const TAB_SHORTCUTS = "shortcuts";
const TAB_PROXY = "proxy";
const TAB_CERTIFICATES = "certificates";
const TAB_PLUGINS = "plugins";
const TAB_AGENTS = "agents";
const TAB_LICENSE = "license";
const tabs = [
TAB_GENERAL,
@@ -38,6 +39,7 @@ const tabs = [
TAB_INTERFACE,
TAB_SHORTCUTS,
TAB_PLUGINS,
TAB_AGENTS,
TAB_CERTIFICATES,
TAB_PROXY,
TAB_LICENSE,
@@ -93,7 +95,9 @@ export default function Settings({ tab, hide }: Props) {
(value): TabItem => ({
value,
label: capitalize(value),
hidden: !appInfo.featureLicense && value === TAB_LICENSE,
hidden:
(!appInfo.featureLicense && value === TAB_LICENSE) ||
(!platform.capabilities.agents && value === TAB_AGENTS),
leftSlot:
value === TAB_GENERAL ? (
<Icon icon="settings" className="text-secondary" />
@@ -109,6 +113,8 @@ export default function Settings({ tab, hide }: Props) {
<Icon icon="wifi" className="text-secondary" />
) : value === TAB_PLUGINS ? (
<Icon icon="puzzle" className="text-secondary" />
) : value === TAB_AGENTS ? (
<Icon icon="sparkles" className="text-secondary" />
) : value === TAB_LICENSE ? (
<Icon icon="key_round" className="text-secondary" />
) : null,
@@ -140,6 +146,9 @@ export default function Settings({ tab, hide }: Props) {
<TabContent value={TAB_PLUGINS} className="h-full grid grid-rows-1">
<SettingsPlugins defaultSubtab={mainTab === TAB_PLUGINS ? subtab : undefined} />
</TabContent>
<TabContent value={TAB_AGENTS} className="overflow-y-auto h-full px-6 py-4!">
<SettingsAgents />
</TabContent>
<TabContent value={TAB_PROXY} className="overflow-y-auto h-full px-6 py-4!">
<SettingsProxy />
</TabContent>
@@ -0,0 +1,140 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { platform } from "@yaakapp-internal/platform";
import type { AgentSetupStatus, AppMetaData } from "@yaakapp-internal/rpc-schema";
import { Banner, Heading, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import { useEffect } from "react";
import { useKeyValue } from "../../hooks/useKeyValue";
import { usePluginInfos } from "../../hooks/usePluginInfo";
import {
AGENT_CLIENTS,
type AgentClient,
cliSetup,
skillInstallCommand,
} from "../../lib/agentSetup";
import { appInfo } from "../../lib/appInfo";
import { rpc } from "../../lib/rpc";
import { Link } from "../core/Link";
import { SegmentedControl } from "../core/SegmentedControl";
import {
SettingCommand,
SettingSnippet,
SettingsList,
SettingsSection,
} from "../core/SettingRow";
import { DeprecatedPluginBanner } from "./SettingsPlugins";
const CLI_INSTALL_COMMAND = "npm install -g @yaakapp/cli";
const MCP_SERVER_PLUGIN = "@yaak/mcp-server";
export function SettingsAgents() {
const queryClient = useQueryClient();
const { value: storedClient, set: setClient } = useKeyValue<AgentClient>({
key: "agents_setup_client",
fallback: "claude_code",
});
const client = AGENT_CLIENTS.find((c) => c.value === storedClient)?.value ?? "claude_code";
const clientLabel = AGENT_CLIENTS.find((c) => c.value === client)?.label ?? "your agent";
const cliVersion = useQuery({
queryKey: ["cli_version"],
queryFn: async () => (await rpc<AppMetaData>("cmd_metadata")).cliVersion,
initialData: appInfo.cliVersion,
});
const setupStatus = useQuery({
queryKey: ["agent_setup_status", client],
queryFn: () => rpc<AgentSetupStatus>("cmd_agent_setup_status", { agent: client }),
});
const mcpServerPlugin = usePluginInfos().find(({ info }) => info.name === MCP_SERVER_PLUGIN);
useEffect(() => {
return platform.window.onFocusChanged((focused) => {
if (!focused) return;
void queryClient.invalidateQueries({ queryKey: ["cli_version"] });
void queryClient.invalidateQueries({ queryKey: ["agent_setup_status"] });
});
}, [queryClient]);
const cliInstalled = cliVersion.data != null;
const cli = cliSetup(client);
return (
<VStack space={1.5} className="mb-4">
<div className="mb-3">
<Heading>Agents</Heading>
<p className="text-text-subtle">
Connect AI coding agents like Claude Code and Cursor to Yaak.
</p>
</div>
<SettingsList>
<SettingsSection title={null}>
<div className="pb-4 border-b border-border-subtle">
<SegmentedControl
name="agents_setup_client"
label="Agent"
hideLabel
value={client}
onChange={setClient}
options={AGENT_CLIENTS}
/>
</div>
{cliInstalled ? (
<div className="flex items-center gap-2 py-3 text-sm text-text-subtle border-b border-border-subtle">
<Icon icon="check" size="sm" color="success" />
<span>
Yaak CLI <span className="text-text">{cliVersion.data}</span> is installed.
</span>
</div>
) : (
<div className="py-4 border-b border-border-subtle">
<Banner color="info">
<div className="flex items-center gap-2 text text-text">
<Icon icon="square_terminal" color="info" />
Install the Yaak CLI first
</div>
<p className="mt-1 text-sm text-text-subtle">
Agents launch it to reach Yaak, even with the app closed.
</p>
<SettingSnippet className="mt-3" language="shell" snippet={CLI_INSTALL_COMMAND} />
<div className="mt-3 mb-1 flex items-center gap-4 text-sm">
<Link href="https://yaak.app/docs/cli">Docs</Link>
<span className="text-text-subtlest">
Checks again when you come back to Yaak.
</span>
</div>
</Banner>
</div>
)}
{mcpServerPlugin != null && (
<div className="py-4 border-b border-border-subtle">
<DeprecatedPluginBanner
plugin={mcpServerPlugin.plugin}
name={mcpServerPlugin.info.displayName}
>
The two steps below replace it. Uninstall it once your agent is connected.
</DeprecatedPluginBanner>
</div>
)}
<SettingCommand
title="Add the MCP server"
description={cli.where}
snippet={cli.snippet}
language={cli.language}
disabled={!cliInstalled}
done={setupStatus.data?.mcpServer ? "Added" : null}
/>
<SettingCommand
title="Install the skill"
description={
<>
Lets {clientLabel} run the <InlineCode>yaak</InlineCode> command directly.
</>
}
snippet={skillInstallCommand(client)}
language="shell"
disabled={!cliInstalled}
done={setupStatus.data?.skill ? "Installed" : null}
/>
</SettingsSection>
</SettingsList>
</VStack>
);
}
@@ -4,7 +4,6 @@ import { differenceInDays } from "date-fns";
import { formatDate } from "date-fns/format";
import { useState } from "react";
import { useToggle } from "../../hooks/useToggle";
import { pluralizeCount } from "../../lib/pluralize";
import { pricingUrl } from "../../lib/pricingUrl";
import { CargoFeature } from "../CargoFeature";
import { Button } from "../core/Button";
@@ -12,6 +11,7 @@ import { Link } from "../core/Link";
import { PlainInput } from "../core/PlainInput";
import { Separator } from "../core/Separator";
import { platform } from "@yaakapp-internal/platform";
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
export function SettingsLicense() {
return (
@@ -2,13 +2,9 @@ import { useMutation, useQuery } from "@tanstack/react-query";
import type { Plugin } from "@yaakapp-internal/models";
import { patchModel, pluginsAtom } from "@yaakapp-internal/models";
import type { PluginVersion } from "@yaakapp-internal/plugins";
import { checkPluginUpdates, installPlugin, searchPlugins } from "@yaakapp-internal/plugins";
import {
checkPluginUpdates,
installPlugin,
searchPlugins,
uninstallPlugin,
} from "@yaakapp-internal/plugins";
import {
Banner,
HStack,
Icon,
InlineCode,
@@ -20,18 +16,17 @@ import {
TableHeaderCell,
TableRow,
useDebouncedValue,
CountBadge,
} from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import { useState } from "react";
import { type ReactNode, useState } from "react";
import { useInstallPlugin } from "../../hooks/useInstallPlugin";
import { usePluginInfo } from "../../hooks/usePluginInfo";
import { usePluginInfo, usePluginInfos } from "../../hooks/usePluginInfo";
import { usePluginsKey, useRefreshPlugins } from "../../hooks/usePlugins";
import { showConfirmDelete } from "../../lib/confirm";
import { minPromiseMillis } from "../../lib/minPromiseMillis";
import { usePromptUninstallPlugin } from "../../hooks/useUninstallPlugin";
import { Button } from "../core/Button";
import { Checkbox } from "../core/Checkbox";
import { CountBadge } from "../core/CountBadge";
import { IconButton } from "../core/IconButton";
import { Link } from "../core/Link";
import { PlainInput } from "../core/PlainInput";
@@ -205,7 +200,7 @@ function PluginTableRow({
mutationKey: ["install_plugin", name],
mutationFn: (name: string) => installPlugin(name, null),
});
const uninstall = usePromptUninstall(plugin?.id ?? null, displayName);
const uninstall = usePromptUninstallPlugin(plugin?.id ?? null, displayName);
const refreshPlugins = useRefreshPlugins();
return (
@@ -296,7 +291,10 @@ function PluginSearch() {
const debouncedQuery = useDebouncedValue(query);
const results = useQuery({
queryKey: ["plugins", debouncedQuery],
queryFn: () => searchPlugins(query),
queryFn: async () => {
const { plugins } = await searchPlugins(query);
return { plugins: plugins.filter((p) => p.deprecated == null) };
},
});
return (
@@ -349,22 +347,77 @@ function InstalledPlugins({ plugins, className }: { plugins: Plugin[]; className
</EmptyStateText>
</div>
) : (
<Table scrollable className={className}>
<TableHead>
<TableRow>
<TableHeaderCell className="w-0" />
<TableHeaderCell>Display Name</TableHeaderCell>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Version</TableHeaderCell>
<TableHeaderCell />
</TableRow>
</TableHead>
<tbody className="divide-y divide-surface-highlight">
{plugins.map((p) => (
<PluginTableRowForInstalledPlugin key={p.id} plugin={p} />
))}
</tbody>
</Table>
<div className={classNames(className, "h-full grid grid-rows-[auto_minmax(0,1fr)]")}>
<DeprecatedPluginBanners />
<Table scrollable>
<TableHead>
<TableRow>
<TableHeaderCell className="w-0" />
<TableHeaderCell>Display Name</TableHeaderCell>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Version</TableHeaderCell>
<TableHeaderCell />
</TableRow>
</TableHead>
<tbody className="divide-y divide-surface-highlight">
{plugins.map((p) => (
<PluginTableRowForInstalledPlugin key={p.id} plugin={p} />
))}
</tbody>
</Table>
</div>
);
}
function DeprecatedPluginBanners() {
const deprecated = usePluginInfos().filter(
({ plugin, info }) => plugin.source !== "bundled" && info.deprecated != null,
);
return (
<div className={classNames("flex flex-col gap-2", deprecated.length > 0 && "pb-3")}>
{deprecated.map(({ plugin, info }) => (
<DeprecatedPluginBanner key={plugin.id} plugin={plugin} name={info.displayName}>
{withInlineCode(info.deprecated ?? "")}
</DeprecatedPluginBanner>
))}
</div>
);
}
/** Backtick spans in a package.json string, set as code. */
function withInlineCode(text: string): ReactNode {
return text
.split("`")
.map((part, i) => (i % 2 === 1 ? <InlineCode key={i}>{part}</InlineCode> : part));
}
export function DeprecatedPluginBanner({
children,
name,
plugin,
}: {
children: ReactNode;
name: string;
plugin: Plugin;
}) {
const uninstall = usePromptUninstallPlugin(plugin.id, name);
return (
<Banner color="info" className="flex items-center gap-4">
<div className="min-w-0 flex-1">
<div className="text text-text">The {name} plugin is deprecated.</div>
<p className="mt-1 text-sm text-text-subtle">{children}</p>
</div>
<Button
size="xs"
variant="border"
title="Uninstall plugin"
className="shrink-0"
isLoading={uninstall?.isPending}
onClick={() => uninstall?.mutate()}
>
Uninstall
</Button>
</Banner>
);
}
@@ -393,31 +446,6 @@ function BundledPlugins({ plugins }: { plugins: Plugin[] }) {
);
}
function usePromptUninstall(pluginId: string | null, name: string) {
const mut = useMutation({
mutationKey: ["uninstall_plugin", pluginId],
mutationFn: async () => {
if (pluginId == null) return;
const confirmed = await showConfirmDelete({
id: `uninstall-plugin-${pluginId}`,
title: "Uninstall Plugin",
confirmText: "Uninstall",
description: (
<>
Permanently uninstall <InlineCode>{name}</InlineCode>?
</>
),
});
if (confirmed) {
await minPromiseMillis(uninstallPlugin(pluginId), 700);
}
},
});
return pluginId == null ? null : mut;
}
function usePluginUpdates() {
return useQuery({
queryKey: ["plugin_updates", usePluginsKey()],
@@ -82,10 +82,9 @@ export function SettingsDropdown() {
},
{
label: "Install CLI",
hidden: appInfo.cliVersion != null,
hidden: appInfo.cliVersion != null || !platform.capabilities.agents,
leftSlot: <Icon icon="square_terminal" />,
rightSlot: <Icon icon="external_link" color="secondary" />,
onSelect: () => platform.openUrl("https://yaak.app/docs/cli"),
onSelect: () => openSettings.mutate("agents"),
},
{
label: "Feedback",
+8 -4
View File
@@ -69,7 +69,6 @@ import type { Ast } from "./core/Editor/filter/query";
import { evaluate, parseQuery } from "./core/Editor/filter/query";
import { formatFieldFilter } from "./core/Editor/filter/format";
import { HttpMethodTag } from "./core/HttpMethodTag";
import { HttpStatusTag } from "./core/HttpStatusTag";
import {
Icon,
LoadingIcon,
@@ -77,6 +76,7 @@ import {
isSelectedFamily,
selectedIdsFamily,
InlineCode,
HttpStatusTag,
} from "@yaakapp-internal/ui";
import type { TreeNode, TreeHandle, TreeProps, TreeItemProps } from "@yaakapp-internal/ui";
import { IconButton } from "./core/IconButton";
@@ -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>
);
}
@@ -0,0 +1,152 @@
// @vitest-environment happy-dom
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
import type { Workspace } from "@yaakapp-internal/models";
import type { TemplateFunction } from "@yaakapp-internal/plugins";
import type { Tokens } from "@yaakapp-internal/templates";
import { act, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeAll, beforeEach, expect, test, vi } from "vite-plus/test";
import { TemplateFunctionDialog } from "./TemplateFunctionDialog";
interface FormProps {
data: Record<string, string | boolean>;
onChange: (values: Record<string, string | boolean>) => void;
}
const mocks = vi.hoisted(() => ({
parse: vi.fn<(text: string) => Tokens>(),
print: vi.fn<(tokens: Tokens) => Promise<string>>(),
serialize: vi.fn<(tokens: Tokens) => { data: string }>(),
preview: vi.fn<({ template }: { template: string }) => { data: string }>(),
form: null as FormProps | null,
config: {
name: "timestamp.format",
args: [
{ type: "text", name: "date" },
{ type: "text", name: "format" },
],
} as TemplateFunction,
}));
vi.mock("@yaakapp-internal/templates", () => ({ parseTemplate: mocks.parse }));
vi.mock("@yaakapp-internal/ui", () => ({
HStack: ({ children }: { children: ReactNode }) => <div>{children}</div>,
InlineCode: "code",
LoadingIcon: () => null,
useDebouncedValue: (value: string) => value,
}));
vi.mock("../hooks/useActiveWorkspace", () => ({ activeWorkspaceAtom: null }));
vi.mock("../hooks/useRenderTemplate", () => ({ useRenderTemplate: mocks.preview }));
vi.mock("../hooks/useTemplateFunctionConfig", () => ({
useTemplateFunctionConfig: () => ({ data: mocks.config }),
}));
vi.mock("../hooks/useTemplateTokensToString", () => ({
templateTokensToString: mocks.print,
useTemplateTokensToString: mocks.serialize,
}));
vi.mock("../lib/dialog", () => ({ showDialog: vi.fn() }));
vi.mock("../lib/encryption", () => ({ convertTemplateToInsecure: vi.fn() }));
vi.mock("../lib/jotai", () => ({ jotaiStore: { get: vi.fn() } }));
vi.mock("../lib/setupOrConfigureEncryption", () => ({ setupOrConfigureEncryption: vi.fn() }));
vi.mock("./core/Dialog", () => ({ DialogFooter: () => <button type="submit">Save</button> }));
vi.mock("./core/IconButton", () => ({ IconButton: () => <button type="button" /> }));
vi.mock("./core/PlainInput", () => ({ PlainInput: () => null }));
vi.mock("./DynamicForm", () => ({
DYNAMIC_FORM_NULL_ARG: "__NULL__",
DynamicForm: (props: FormProps) => {
mocks.form = props;
return <input aria-label="Timestamp" readOnly value={String(props.data.date ?? "")} />;
},
}));
beforeAll(async () => {
const base = pathToFileURL(
resolve(import.meta.dirname, "../../../crates/yaak-templates/pkg") + "/",
);
const bindings = await import(new URL("yaak_templates_bg.js", base).href);
const { instance } = await WebAssembly.instantiate(
await readFile(new URL("yaak_templates_bg.wasm", base)),
{ "./yaak_templates_bg.js": bindings },
);
bindings.__wbg_set_wasm(instance.exports);
(instance.exports.__wbindgen_start as () => void)();
mocks.parse.mockImplementation(bindings.parse_template);
});
let root: Root;
let host: HTMLDivElement;
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
mocks.form = null;
// The transport returns an opaque string. Carry the AST as JSON so the assertions can
// inspect what Preview and Save receive, without duplicating the backend's printer.
mocks.serialize.mockImplementation((tokens) => ({ data: JSON.stringify(tokens) }));
mocks.preview.mockImplementation(({ template }) => ({ data: template }));
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
vi.unstubAllGlobals();
});
function functionValue(tokens: Tokens) {
const first = tokens.tokens[0];
if (first?.type !== "tag" || first.val.type !== "fn") throw new Error("Expected function");
return first.val;
}
test.each([
["timestamp.offset(expression='-1h')", "${[ timestamp.offset(expression='-1h') ]}"],
["date_var", "${[ date_var ]}"],
["dates.previous", "${[ dates.previous ]}"],
["'${[ date_var ]}'", "${[ date_var ]}"],
])("Preview and Save preserve %s until that input changes", async (expression, display) => {
const initialTokens = mocks.parse(`\${[ timestamp.format(date=${expression}, format='HH:mm') ]}`);
const originalDate = functionValue(initialTokens).args[0];
const saved = vi.fn<(value: string) => void>();
mocks.print.mockResolvedValue(display);
await act(async () =>
root.render(
<TemplateFunctionDialog
templateFunction={mocks.config}
initialTokens={initialTokens}
model={{ id: "wk_test" } as Workspace}
onChange={saved}
hide={() => {}}
/>,
),
);
expect(host.querySelector<HTMLInputElement>("input")?.value).toBe(display);
async function submit() {
const form = host.querySelector("form");
if (form == null) throw new Error("Dialog did not initialize");
await act(async () => {
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
});
const value = saved.mock.calls.at(-1)?.[0];
expect(value).toBe(mocks.preview.mock.calls.at(-1)?.[0].template);
return functionValue(JSON.parse(value ?? "null") as Tokens).args;
}
expect((await submit())[0]).toEqual(originalDate);
await act(async () => mocks.form?.onChange({ ...mocks.form.data, format: "yyyy" }));
expect(await submit()).toEqual([
originalDate,
{ name: "format", value: { type: "str", text: "yyyy" } },
]);
const edited = "${[ timestamp.offset(expression='-2h') ]}";
await act(async () => mocks.form?.onChange({ ...mocks.form.data, date: edited }));
expect((await submit())[0]).toEqual({ name: "date", value: { type: "str", text: edited } });
await act(async () => mocks.form?.onChange({ ...mocks.form.data, date: "" }));
expect((await submit())[0]).toEqual({ name: "date", value: { type: "str", text: "" } });
});
@@ -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,8 +24,11 @@ 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 { collectArgumentValues } from "./core/Editor/twig/util";
import {
collectEditableArgumentValues,
preserveUnchangedArgumentExpressions,
} from "../lib/templateFunctionArguments";
import { DialogFooter } from "./core/Dialog";
import { IconButton } from "./core/IconButton";
import { PlainInput } from "./core/PlainInput";
import { DYNAMIC_FORM_NULL_ARG, DynamicForm } from "./DynamicForm";
@@ -48,7 +51,11 @@ export function TemplateFunctionDialog({ initialTokens, templateFunction, ...pro
}
(async () => {
const initial = collectArgumentValues(initialTokens, templateFunction);
const initial = await collectEditableArgumentValues(
initialTokens,
templateFunction,
templateTokensToString,
);
// HACK: Replace the secure() function's encrypted `value` arg with the decrypted version so
// we can display it in the editor input.
@@ -74,6 +81,7 @@ export function TemplateFunctionDialog({ initialTokens, templateFunction, ...pro
<InitializedTemplateFunctionDialog
{...props}
templateFunction={templateFunction}
initialTokens={initialTokens}
initialArgValues={initialArgValues}
/>
);
@@ -82,15 +90,17 @@ export function TemplateFunctionDialog({ initialTokens, templateFunction, ...pro
function InitializedTemplateFunctionDialog({
templateFunction: { name, previewType: ogPreviewType },
initialArgValues,
initialTokens,
hide,
onChange,
model,
}: Omit<Props, "initialTokens"> & {
}: Props & {
initialArgValues: Record<string, string | boolean>;
}) {
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) => ({
@@ -110,12 +120,17 @@ function InitializedTemplateFunctionDialog({
val: {
type: "fn",
name,
args: argTokens,
args: preserveUnchangedArgumentExpressions(
argTokens,
initialTokens,
initialArgValues,
argValues,
),
},
},
],
};
}, [argValues, name]);
}, [argValues, initialArgValues, initialTokens, name]);
const tagText = useTemplateTokensToString(tokens);
const templateFunction = useTemplateFunctionConfig(name, argValues, model);
@@ -160,13 +175,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 +204,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 || <>&nbsp;</>
)}
</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 || <>&nbsp;</>
)}
</InlineCode>
<div className="absolute right-0.5 top-0 bottom-0 flex items-center">
<IconButton
size="xs"
icon="refresh"
className="text-text-subtle"
title="Refresh preview"
spin={rendered.isPending}
onClick={() => {
setRenderKey(new Date().toISOString());
}}
/>
</div>
</div>
</div>
) : (
<span />
)}
<div className="flex justify-stretch w-full grow gap-2 *:flex-1">
{templateFunction.data.name === "secure" && (
<Button variant="border" color="secondary" onClick={setupOrConfigureEncryption}>
Reveal Encryption Key
</Button>
)}
<Button type="submit" color="primary">
Save
</Button>
</div>
</div>
) : (
<span />
)}
<DialogFooter
actions={[
...(templateFunction.data.name === "secure"
? [{ label: "Reveal Encryption Key", onClick: setupOrConfigureEncryption }]
: []),
{ label: "Save", color: "primary", form: formId },
]}
/>
</form>
);
}
@@ -272,7 +284,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 }) => {
+4 -41
View File
@@ -1,43 +1,6 @@
import { useAtomValue } from "jotai";
import { AnimatePresence } from "motion/react";
import type { ReactNode } from "react";
import { hideToast, toastsAtom } from "../lib/toast";
import { Toast, type ToastProps } from "./core/Toast";
import { ErrorBoundary } from "./ErrorBoundary";
import { Portal } from "@yaakapp-internal/ui";
import { Toasts as BaseToasts } from "@yaakapp-internal/ui";
import { showErrorDialog } from "./ErrorBoundary";
export type ToastInstance = {
id: string;
uniqueKey: string;
message: ReactNode;
timeout: 3000 | 5000 | 8000 | (number & {}) | null;
onClose?: ToastProps["onClose"];
} & Omit<ToastProps, "onClose" | "open" | "children" | "timeout">;
export type { ToastInstance } from "@yaakapp-internal/ui";
export const Toasts = () => {
const toasts = useAtomValue(toastsAtom);
return (
<Portal name="toasts">
<div className="absolute right-0 bottom-0 z-50">
<AnimatePresence>
{toasts.map((toast: ToastInstance) => {
const { message, uniqueKey, ...props } = toast;
return (
<ErrorBoundary key={uniqueKey} name={`Toast ${uniqueKey}`}>
<Toast
open
{...props}
// We call onClose inside actions.hide instead of passing to toast so that
// it gets called from external close calls as well
onClose={() => hideToast(toast)}
>
{message}
</Toast>
</ErrorBoundary>
);
})}
</AnimatePresence>
</div>
</Portal>
);
};
export const Toasts = () => <BaseToasts onShowError={showErrorDialog} />;
+6 -105
View File
@@ -1,113 +1,14 @@
import type { HttpRequest } from "@yaakapp-internal/models";
import type { IconProps } from "@yaakapp-internal/ui";
import { HStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { FormEvent, ReactNode } from "react";
import { memo, useCallback, useRef, useState } from "react";
import { useHotKey } from "../hooks/useHotKey";
import { IconButton } from "./core/IconButton";
import type { InputHandle, InputProps } from "./core/Input";
import { Input } from "./core/Input";
import { UrlBar as BaseUrlBar, type UrlBarProps } from "@yaakapp-internal/ui";
import { memo } from "react";
type Props = Pick<HttpRequest, "url"> & {
className?: string;
placeholder: string;
type Props = Omit<UrlBarProps, "sendHotkeyAction" | "focusHotkeyAction" | "readOnly"> & {
onSend: () => void;
onUrlChange: (url: string) => void;
onPaste?: (v: string) => void;
onPasteOverwrite?: InputProps["onPasteOverwrite"];
onCancel: () => void;
submitIcon?: IconProps["icon"] | null;
isLoading: boolean;
forceUpdateKey: string;
rightSlot?: ReactNode;
leftSlot?: ReactNode;
autocomplete?: InputProps["autocomplete"];
stateKey: InputProps["stateKey"];
};
export const UrlBar = memo(function UrlBar({
forceUpdateKey,
onUrlChange,
url,
placeholder,
className,
onSend,
onCancel,
onPaste,
onPasteOverwrite,
submitIcon = "send_horizontal",
autocomplete,
leftSlot,
rightSlot,
isLoading,
stateKey,
}: Props) {
const inputRef = useRef<InputHandle>(null);
const [isFocused, setIsFocused] = useState<boolean>(false);
const handleInitInputRef = useCallback((h: InputHandle | null) => {
inputRef.current = h;
}, []);
useHotKey("url_bar.focus", () => {
inputRef.current?.selectAll();
});
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (isLoading) onCancel();
else onSend();
};
return (
<form onSubmit={handleSubmit} className={classNames("x-theme-urlBar", className)}>
<Input
setRef={handleInitInputRef}
autocompleteFunctions
autocompleteVariables
stateKey={stateKey}
size="sm"
wrapLines={isFocused}
hideLabel
language="url"
className="px-1.5 py-0.5"
label="Enter URL"
name="url"
autocomplete={autocomplete}
forceUpdateKey={forceUpdateKey}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onPaste={onPaste}
onPasteOverwrite={onPasteOverwrite}
onChange={onUrlChange}
defaultValue={url}
placeholder={placeholder}
leftSlot={leftSlot}
rightSlot={
<HStack space={0.5}>
{rightSlot && <div className="py-0.5 h-full">{rightSlot}</div>}
{submitIcon !== null && (
<div className="py-0.5 h-full">
<IconButton
size="xs"
iconSize="md"
title="Send Request"
type="submit"
className="w-8 mr-0.5 h-full!"
iconColor="secondary"
icon={isLoading ? "x" : submitIcon}
hotkeyAction="request.send"
onMouseDown={(e) => {
// Prevent the button from taking focus
e.preventDefault();
}}
/>
</div>
)}
</HStack>
}
/>
</form>
);
/** The shared URL bar, sending and focusing on the client's hotkeys */
export const UrlBar = memo(function UrlBar(props: Props) {
return <BaseUrlBar {...props} sendHotkeyAction="request.send" focusHotkeyAction="url_bar.focus" />;
});
@@ -1,8 +1,11 @@
import { VStack } from "@yaakapp-internal/ui";
import {
type EditablePair,
type PairEditorHandle,
type PairEditorProps,
UrlParametersEditor as BaseUrlParametersEditor,
} from "@yaakapp-internal/ui";
import { useCallback, useRef } from "react";
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
import type { EditablePair, PairEditorHandle, PairEditorProps } from "./core/PairEditor";
import { PairOrBulkEditor } from "./core/PairOrBulkEditor";
type Props = {
forceUpdateKey: string;
@@ -11,6 +14,7 @@ type Props = {
onChange: PairEditorProps["onChange"];
};
/** The shared parameters editor, wired to the request editor's focus events */
export function UrlParametersEditor({ pairs, forceUpdateKey, onChange, stateKey }: Props) {
const pairEditorRef = useRef<PairEditorHandle>(null);
const handleInitPairEditorRef = useCallback((ref: PairEditorHandle) => {
@@ -33,22 +37,12 @@ export function UrlParametersEditor({ pairs, forceUpdateKey, onChange, stateKey
);
return (
<VStack className="h-full">
<PairOrBulkEditor
setRef={handleInitPairEditorRef}
allowMultilineValues
forceUpdateKey={forceUpdateKey + urlParametersKey}
nameAutocompleteFunctions
nameAutocompleteVariables
namePlaceholder="param_name"
onChange={onChange}
pairs={pairs}
preferenceName="url_parameters"
stateKey={stateKey}
valueAutocompleteFunctions
valueAutocompleteVariables
valuePlaceholder="Value"
/>
</VStack>
<BaseUrlParametersEditor
setRef={handleInitPairEditorRef}
forceUpdateKey={forceUpdateKey + urlParametersKey}
onChange={onChange}
pairs={pairs}
stateKey={stateKey}
/>
);
}
@@ -22,11 +22,9 @@ import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
import { activeWebsocketConnectionAtom } from "../hooks/usePinnedWebsocketConnection";
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
import { languageFromContentType } from "../lib/contentType";
import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
import { prepareImportQuerystring } from "../lib/prepareImportQuerystring";
import { resolvedModelName } from "../lib/resolvedModelName";
import { CountBadge } from "./core/CountBadge";
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
import { getUrlCompletionConfig } from "./core/Editor/url/completion";
import { Editor } from "./core/Editor/LazyEditor";
@@ -40,6 +38,7 @@ import { MarkdownEditor } from "./MarkdownEditor";
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
import { UrlBar } from "./UrlBar";
import { UrlParametersEditor } from "./UrlParameterEditor";
import { languageFromContentType, CountBadge } from "@yaakapp-internal/ui";
interface Props {
style: CSSProperties;
@@ -110,6 +109,8 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
[activeRequest.url, activeRequest.urlParameters, handleRenamePathPlaceholder],
);
const hasDescription = activeRequest.description.trim().length > 0;
const tabs = useMemo<TabItem[]>(() => {
return [
{
@@ -131,9 +132,10 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
{
value: TAB_DESCRIPTION,
label: "Info",
rightSlot: hasDescription && <CountBadge count={true} />,
},
];
}, [authTab, headersTab, numSettingsOverrides, urlParameterPairs.length]);
}, [authTab, hasDescription, headersTab, numSettingsOverrides, urlParameterPairs.length]);
const { activeResponse } = usePinnedHttpResponse(activeRequestId);
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
@@ -1,6 +1,14 @@
import type { WebsocketEvent, WebsocketRequest } from "@yaakapp-internal/models";
import { HStack, Icon, LoadingIcon, VStack } from "@yaakapp-internal/ui";
import { hexy } from "hexy";
import {
HStack,
hexdump,
HexdumpViewer,
Icon,
LoadingIcon,
VStack,
useStateWithDeps,
languageFromContentType,
} from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useMemo, useState } from "react";
import { useFormatText } from "../hooks/useFormatText";
@@ -10,10 +18,9 @@ import {
setPinnedWebsocketConnectionId,
useWebsocketEvents,
} from "../hooks/usePinnedWebsocketConnection";
import { useStateWithDeps } from "../hooks/useStateWithDeps";
import { languageFromContentType } from "../lib/contentType";
import { Button } from "./core/Button";
import { Editor } from "./core/Editor/LazyEditor";
import { renderEditor } from "./core/Editor/renderEditor";
import { type EventDetailAction, EventDetailHeader, EventViewer } from "./core/EventViewer";
import { EventViewerRow } from "./core/EventViewerRow";
import { HotkeyList } from "./core/HotkeyList";
@@ -117,10 +124,10 @@ function WebsocketEventRow({
messageType === "error"
? "alert_triangle"
: messageType === "close" || messageType === "open"
? "info"
: isServer
? "arrow_big_down_dash"
: "arrow_big_up_dash";
? "info"
: isServer
? "arrow_big_down_dash"
: "arrow_big_up_dash";
const content =
messageType === "close" ? (
@@ -167,7 +174,7 @@ function WebsocketEventDetail({
}) {
const message = useMemo(() => {
if (hexDump) {
return event.message ? hexy(event.message) : "";
return event.message ? hexdump(event.message) : "";
}
return event.message ? new TextDecoder("utf-8").decode(Uint8Array.from(event.message)) : "";
}, [event.message, hexDump]);
@@ -227,6 +234,8 @@ function WebsocketEventDetail({
</VStack>
) : event.message.length === 0 ? (
<EmptyStateText>No Content</EmptyStateText>
) : hexDump ? (
<HexdumpViewer data={event.message} renderEditor={renderEditor} />
) : (
<Editor
language={language}
+2 -2
View File
@@ -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 = (
@@ -5,20 +5,17 @@ import {
setWorkspaceKey,
} from "@yaakapp-internal/crypto";
import type { WorkspaceMeta } from "@yaakapp-internal/models";
import { Banner, HStack, VStack } from "@yaakapp-internal/ui";
import { Banner, HStack, VStack, useStateWithDeps, IconTooltip, Label } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import { useEffect, useState } from "react";
import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from "../hooks/useActiveWorkspace";
import { createFastMutation } from "../hooks/useFastMutation";
import { useStateWithDeps } from "../hooks/useStateWithDeps";
import { showConfirm } from "../lib/confirm";
import { CopyIconButton } from "./CopyIconButton";
import type { ButtonProps } from "./core/Button";
import { Button } from "./core/Button";
import { IconButton } from "./core/IconButton";
import { IconTooltip } from "./core/IconTooltip";
import { Label } from "./core/Label";
import { PlainInput } from "./core/PlainInput";
import { SettingRow } from "./core/SettingRow";
import { EncryptionHelp } from "./EncryptionHelp";
@@ -1,5 +1,5 @@
import { patchModel, workspaceMetasAtom, workspacesAtom } from "@yaakapp-internal/models";
import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui";
import { Banner, HStack, InlineCode, CountBadge } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useAuthTab } from "../hooks/useAuthTab";
import { useHeadersTab } from "../hooks/useHeadersTab";
@@ -9,7 +9,6 @@ import { showDialog } from "../lib/dialog";
import { router } from "../lib/router";
import { CopyIconButton } from "./CopyIconButton";
import { Button } from "./core/Button";
import { CountBadge } from "./core/CountBadge";
import { PlainInput } from "./core/PlainInput";
import { SettingsList, SettingsSection } from "./core/SettingRow";
import { TabContent, Tabs } from "./core/Tabs/Tabs";
@@ -1,36 +0,0 @@
import { describe, expect, test } from "vite-plus/test";
import { parseBulkPairLine } from "./BulkPairEditor";
describe("parseBulkPairLine", () => {
test("parses colon-space pairs as name and value", () => {
expect(parseBulkPairLine("foo: bar")).toMatchObject({
enabled: true,
name: "foo",
value: "bar",
});
});
test("preserves colon-without-space lines as a name with an empty value", () => {
expect(parseBulkPairLine("foo:bar")).toMatchObject({
enabled: true,
name: "foo:bar",
value: "",
});
});
test("preserves malformed lines instead of dropping their contents", () => {
expect(parseBulkPairLine("not a pair")).toMatchObject({
enabled: true,
name: "not a pair",
value: "",
});
});
test("unescapes newlines in parsed values", () => {
expect(parseBulkPairLine("foo: bar\\nbaz")).toMatchObject({
enabled: true,
name: "foo",
value: "bar\nbaz",
});
});
});
@@ -1,63 +0,0 @@
import { useCallback, useMemo } from "react";
import { generateId } from "../../lib/generateId";
import { Editor } from "./Editor/LazyEditor";
import type { Pair, PairEditorProps, PairWithId } from "./PairEditor";
type Props = PairEditorProps;
export function BulkPairEditor({
pairs,
onChange,
namePlaceholder,
valuePlaceholder,
forceUpdateKey,
forcedEnvironmentId,
stateKey,
}: Props) {
const pairsText = useMemo(() => {
return pairs
.filter((p) => !(p.name.trim() === "" && p.value.trim() === ""))
.map(formatBulkPairLine)
.join("\n");
}, [pairs]);
const handleChange = useCallback(
(text: string) => {
const pairs = text
.split("\n")
.filter((l: string) => l.trim())
.map(parseBulkPairLine);
onChange(pairs);
},
[onChange],
);
return (
<Editor
autocompleteFunctions
autocompleteVariables
stateKey={`bulk_pair.${stateKey}`}
forcedEnvironmentId={forcedEnvironmentId}
forceUpdateKey={forceUpdateKey}
placeholder={`${namePlaceholder ?? "name"}: ${valuePlaceholder ?? "value"}`}
defaultValue={pairsText}
language="pairs"
onChange={handleChange}
/>
);
}
export function formatBulkPairLine(pair: Pair) {
const value = pair.value.replaceAll("\n", "\\n");
return `${pair.name}: ${value}`;
}
export function parseBulkPairLine(line: string): PairWithId {
const [, name, value] = line.match(/^([^:]+):\s+(.*)$/) ?? [];
return {
enabled: true,
name: (name ?? line).trim(),
value: (value ?? "").replaceAll("\\n", "\n").trim(),
id: generateId(),
};
}
+2 -73
View File
@@ -1,73 +1,2 @@
import { HStack, Icon } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { ReactNode } from "react";
import { IconTooltip } from "./IconTooltip";
export interface CheckboxProps {
checked: boolean | "indeterminate";
title: ReactNode;
onChange: (checked: boolean) => void;
className?: string;
disabled?: boolean;
inputWrapperClassName?: string;
hideLabel?: boolean;
fullWidth?: boolean;
help?: ReactNode;
size?: "sm" | "md";
}
export function Checkbox({
checked,
onChange,
className,
inputWrapperClassName,
disabled,
title,
hideLabel,
fullWidth,
help,
size = "sm",
}: CheckboxProps) {
return (
<HStack
as="label"
alignItems="center"
space={2}
className={classNames(className, "text-text mr-auto")}
>
<div className={classNames(inputWrapperClassName, "x-theme-input", "relative flex mr-0.5")}>
<input
aria-hidden
className={classNames(
"appearance-none shrink-0 border border-border",
size === "sm" && "w-4 h-4",
size === "md" && "w-5 h-5",
"rounded-sm outline-hidden ring-0",
!disabled && "hocus:border-border-focus hocus:bg-focus/[5%]",
disabled && "border-dotted",
)}
type="checkbox"
disabled={disabled}
onChange={() => {
onChange(checked === "indeterminate" ? true : !checked);
}}
/>
<div className="absolute inset-0 flex items-center justify-center">
<Icon
size={size}
className={classNames(disabled && "opacity-disabled")}
icon={checked === "indeterminate" ? "minus" : checked ? "check" : "empty"}
/>
</div>
</div>
{!hideLabel && (
<div
className={classNames("text-sm", fullWidth && "w-full", disabled && "opacity-disabled")}
>
{title}
</div>
)}
{help && <IconTooltip content={help} />}
</HStack>
);
}
export type { CheckboxProps } from "@yaakapp-internal/ui";
export { Checkbox } from "@yaakapp-internal/ui";
@@ -21,6 +21,8 @@ interface Props<T> {
isCheckboxDisabled?: (node: CheckboxTreeNode<T>) => boolean;
/** An irrelevant row is hidden unless one of its descendants is relevant */
isRelevant: (node: CheckboxTreeNode<T>) => boolean;
/** A node that starts collapsed, so a large subtree doesn't crowd out the rest */
isCollapsedByDefault?: (node: CheckboxTreeNode<T>) => boolean;
renderRow: (node: CheckboxTreeNode<T>) => ReactNode;
onSelectRow?: (node: CheckboxTreeNode<T>) => void;
canSelectRow?: (node: CheckboxTreeNode<T>) => boolean;
@@ -29,7 +31,9 @@ interface Props<T> {
export function CheckboxTree<T>(props: Props<T>) {
const { node, depth = 0 } = props;
const [collapsed, setCollapsed] = useState<boolean>(false);
const [collapsed, setCollapsed] = useState<boolean>(
() => props.isCollapsedByDefault?.(node) ?? false,
);
if (!hasRelevantNode(node, props.isRelevant)) return null;
const checked = props.checked(node);
+32
View File
@@ -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>
);
}
+17 -12
View File
@@ -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,60 +1,27 @@
import classNames from "classnames";
import { atom, useAtom } from "jotai";
import type { HTMLAttributes, ReactNode } from "react";
import { useMemo } from "react";
import {
DetailsBanner as BaseDetailsBanner,
type DetailsBannerProps,
type StoredValue,
} from "@yaakapp-internal/ui";
import { atom, useAtom, type WritableAtom } from "jotai";
import { type SetStateAction, useMemo } from "react";
import { atomWithKVStorage } from "../../lib/atoms/atomWithKVStorage";
import type { BannerProps } from "@yaakapp-internal/ui";
import { Banner } from "@yaakapp-internal/ui";
interface Props extends HTMLAttributes<HTMLDetailsElement> {
summary: ReactNode;
color?: BannerProps["color"];
defaultOpen?: boolean;
storageKey?: string;
export function DetailsBanner(props: Omit<DetailsBannerProps, "useStoredValue">) {
return <BaseDetailsBanner {...props} useStoredValue={useDetailsBannerStoredValue} />;
}
export function DetailsBanner({
className,
color,
summary,
children,
defaultOpen,
storageKey,
...extraProps
}: Props) {
// oxlint-disable-next-line react-hooks/exhaustive-deps -- We only want to recompute the atom when storageKey changes
const openAtom = useMemo(
export function useDetailsBannerStoredValue<T extends StoredValue>(
key: string | string[] | null,
fallback: T,
) {
// oxlint-disable-next-line react-hooks/exhaustive-deps -- We only want to recompute the atom when the key changes
const valueAtom = useMemo<WritableAtom<T, [SetStateAction<T>], void>>(
() =>
storageKey
? atomWithKVStorage<boolean>(["details_banner", storageKey], defaultOpen ?? false)
: atom(defaultOpen ?? false),
[storageKey],
);
const [isOpen, setIsOpen] = useAtom(openAtom);
const handleToggle = (e: React.SyntheticEvent<HTMLDetailsElement>) => {
if (storageKey) {
setIsOpen(e.currentTarget.open);
}
};
return (
<Banner color={color} className={className}>
<details className="group list-none" open={isOpen} onToggle={handleToggle} {...extraProps}>
<summary className="cursor-default! select-none! list-none flex items-center gap-3 focus:outline-hidden opacity-70">
<div
className={classNames(
"transition-transform",
"group-open:rotate-90",
"w-0 h-0 border-t-[0.3em] border-b-[0.3em] border-l-[0.5em] border-r-0",
"border-t-transparent border-b-transparent border-l-text-subtle",
)}
/>
{summary}
</summary>
<div className="mt-1.5 pb-2">{children}</div>
</details>
</Banner>
key != null
? atomWithKVStorage<T>(["details_banner", ...[key].flat()], fallback)
: atom(fallback),
[key],
);
return useAtom(valueAtom);
}
+2 -131
View File
@@ -1,131 +1,2 @@
import type { DialogSize } from "@yaakapp-internal/plugins";
import { Heading, 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 { IconButton } from "./IconButton";
export interface DialogProps {
children: ReactNode;
open: boolean;
onClose?: () => void;
/** Block dismissal from the backdrop, Escape key, and built-in close button. */
disableClose?: boolean;
title?: ReactNode;
description?: ReactNode;
className?: string;
size?: DialogSize;
/** Hide the built-in close button without changing backdrop or Escape behavior. */
hideX?: boolean;
noPadding?: boolean;
noScroll?: boolean;
vAlign?: "top" | "center";
}
export function Dialog({
children,
className,
size = "full",
open,
onClose,
disableClose,
title,
description,
hideX,
noPadding,
noScroll,
vAlign = "center",
}: DialogProps) {
const titleId = useMemo(() => Math.random().toString(36).slice(2), []);
const descriptionId = useMemo(
() => (description ? Math.random().toString(36).slice(2) : undefined),
[description],
);
return (
<Overlay open={open} onClose={disableClose ? undefined : onClose} portalName="dialog">
<div
role="dialog"
className={classNames(
"py-4 x-theme-dialog absolute inset-0 pointer-events-none",
"h-full flex flex-col items-center justify-center",
vAlign === "top" && "justify-start",
vAlign === "center" && "justify-center",
)}
aria-labelledby={titleId}
aria-describedby={descriptionId}
tabIndex={-1}
onKeyDown={(e) => {
// NOTE: We handle Escape on the element itself so that it doesn't close multiple
// dialogs and can be intercepted by children if needed.
if (e.key === "Escape") {
if (!disableClose) onClose?.();
e.stopPropagation();
e.preventDefault();
}
}}
>
<m.div
initial={{ top: 5, scale: 0.97 }}
animate={{ top: 0, scale: 1 }}
className={classNames(
className,
"grid grid-rows-[auto_auto_minmax(0,1fr)]",
"grid-cols-1", // must be here for inline code blocks to correctly break words
"relative bg-surface pointer-events-auto",
"rounded-lg",
"border border-border-subtle shadow-lg shadow-[rgba(0,0,0,0.1)]",
"min-h-40",
"max-w-[calc(100vw-5rem)] max-h-[calc(100vh-5rem)]",
size === "sm" && "w-120",
size === "md" && "w-200",
size === "lg" && "w-280",
size === "full" && "w-screen h-screen",
size === "dynamic" && "min-w-80 max-w-[100vw]",
)}
>
{title ? (
<Heading className="px-6 mt-4 mb-2" level={1} id={titleId}>
{title}
</Heading>
) : (
<span />
)}
{description ? (
<div className="min-h-0 px-6 text-text-subtle mb-3" id={descriptionId}>
{description}
</div>
) : (
<span />
)}
<div
className={classNames(
"h-full w-full grid grid-cols-[minmax(0,1fr)] grid-rows-1",
!noPadding && "px-6 py-2",
!noScroll && "overflow-y-auto overflow-x-hidden",
)}
>
{children}
</div>
{/*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">
<IconButton
className="opacity-70 hover:opacity-100"
onClick={onClose}
title="Close dialog (Esc)"
aria-label="Close"
size="sm"
icon="x"
/>
</div>
)}
</m.div>
</div>
</Overlay>
);
}
export type { DialogFooterAction, DialogProps } from "@yaakapp-internal/ui";
export { Dialog, DialogFooter } from "@yaakapp-internal/ui";
File diff suppressed because it is too large. Load diff
@@ -5,7 +5,8 @@ import { EditorView } from "@codemirror/view";
import classNames from "classnames";
import { useEffect, useRef } from "react";
import "./DiffViewer.css";
import { readonlyExtensions, syntaxHighlightStyle } from "./extensions";
import { syntaxHighlightStyle } from "@yaakapp-internal/ui/editor";
import { readonlyExtensions } from "./extensions";
interface Props {
/** Original/previous version (left side) */
@@ -1,147 +1,53 @@
import { startCompletion } from "@codemirror/autocomplete";
import { defaultKeymap, historyField, indentWithTab } from "@codemirror/commands";
import { foldState, forceParsing } from "@codemirror/language";
import type { EditorStateConfig, Extension } from "@codemirror/state";
import { Compartment, EditorState } from "@codemirror/state";
import { EditorView, keymap, placeholder as placeholderExt, tooltips } from "@codemirror/view";
import { emacs } from "@replit/codemirror-emacs";
import { vim } from "@replit/codemirror-vim";
import { vscodeKeymap } from "@replit/codemirror-vscode-keymap";
import { debounce } from "@yaakapp-internal/lib";
import type { EditorKeymap } from "@yaakapp-internal/models";
import type { Extension } from "@codemirror/state";
import type { EditorView } from "@codemirror/view";
import { settingsAtom } from "@yaakapp-internal/models";
import type { EditorLanguage, TemplateFunction } from "@yaakapp-internal/plugins";
import { HStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { CodeEditor, type CodeEditorProps } from "@yaakapp-internal/ui/editor";
import type { GraphQLSchema } from "graphql";
import { useAtomValue } from "jotai";
import type { ReactNode, RefObject } from "react";
import {
Children,
cloneElement,
isValidElement,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
} from "react";
import { useCallback, useMemo, useRef } from "react";
import { activeEnvironmentAtom } from "../../../hooks/useActiveEnvironment";
import type { WrappedEnvironmentVariable } from "../../../hooks/useEnvironmentVariables";
import { useEnvironmentVariables } from "../../../hooks/useEnvironmentVariables";
import { eventMatchesHotkey } from "../../../hooks/useHotKey";
import { useRequestEditor } from "../../../hooks/useRequestEditor";
import { useTemplateFunctionCompletionOptions } from "../../../hooks/useTemplateFunctions";
import { docFingerprint } from "../../../lib/docFingerprint";
import { editEnvironment } from "../../../lib/editEnvironment";
import { tryFormatJson, tryFormatXml } from "../../../lib/formatters";
import { jotaiStore } from "../../../lib/jotai";
import { withEncryptionEnabled } from "../../../lib/setupOrConfigureEncryption";
import { TemplateFunctionDialog } from "../../TemplateFunctionDialog";
import { IconButton } from "../IconButton";
import "./Editor.css";
import {
baseExtensions,
editableExtensions,
getLanguageExtension,
multiLineExtensions,
readonlyExtensions,
} from "./extensions";
import { getLanguageExtension } from "./extensions";
import type { GenericCompletionConfig } from "./genericCompletion";
import { singleLineExtensions } from "./singleLine";
import { largeValues, mediaValues } from "./largeValues";
// VSCode's Tab actions mess with the single-line editor tab actions, so remove it.
const vsCodeWithoutTab = vscodeKeymap.filter((k) => k.key !== "Tab");
const keymapExtensions: Record<EditorKeymap, Extension> = {
vim: vim(),
emacs: emacs(),
vscode: keymap.of(vsCodeWithoutTab),
default: [],
};
export interface EditorProps {
actions?: ReactNode;
autoFocus?: boolean;
autoSelect?: boolean;
export type EditorProps = Omit<CodeEditorProps, "language" | "extensions" | "keymap"> & {
autocomplete?: GenericCompletionConfig;
autocompleteFunctions?: boolean;
autocompleteVariables?: boolean | ((v: WrappedEnvironmentVariable) => boolean);
className?: string;
defaultValue?: string | null;
disableTabIndent?: boolean;
disabled?: boolean;
extraExtensions?: Extension[] | Extension;
forcedEnvironmentId?: string;
forceUpdateKey?: string | number;
format?: (v: string) => Promise<string>;
heightMode?: "auto" | "full";
hideGutter?: boolean;
id?: string;
language?: EditorLanguage | "pairs" | "url" | "timeline" | null;
lintExtension?: Extension;
graphQLSchema?: GraphQLSchema | null;
onBlur?: () => void;
onChange?: (value: string) => void;
onFocus?: () => void;
onKeyDown?: (e: KeyboardEvent) => void;
onPaste?: (value: string) => void;
onPasteOverwrite?: (e: ClipboardEvent, value: string) => void;
placeholder?: string;
readOnly?: boolean;
singleLine?: boolean;
containerOnly?: boolean;
stateKey: string | null;
tooltipContainer?: HTMLElement;
type?: "text" | "password";
wrapLines?: boolean;
setRef?: (view: EditorView | null) => void;
}
const stateFields = { history: historyField, folds: foldState };
language?: EditorLanguage | "pairs" | "url" | "timeline" | null;
};
const emptyVariables: WrappedEnvironmentVariable[] = [];
const emptyExtension: Extension = [];
export function Editor(props: EditorProps) {
return <EditorInner key={props.stateKey} {...props} />;
}
function EditorInner({
actions,
autoFocus,
autoSelect,
export function Editor({
autocomplete,
autocompleteFunctions,
autocompleteVariables,
className,
defaultValue,
disableTabIndent,
disabled,
extraExtensions,
forcedEnvironmentId,
forceUpdateKey,
format,
heightMode,
hideGutter,
graphQLSchema,
hideGutter,
language,
lintExtension,
onBlur,
onChange,
onFocus,
onKeyDown,
onPaste,
onPasteOverwrite,
placeholder,
readOnly,
singleLine,
containerOnly,
stateKey,
type,
wrapLines,
setRef,
...props
}: EditorProps) {
const settings = useAtomValue(settingsAtom);
@@ -158,20 +64,10 @@ function EditorInner({
wrapLines = settings.editorSoftWrap;
}
if (disabled) {
if (props.disabled) {
readOnly = true;
}
if (
singleLine ||
language == null ||
language === "text" ||
language === "url" ||
language === "pairs"
) {
disableTabIndent = true;
}
if (format == null && !readOnly) {
format =
language === "json"
@@ -181,130 +77,22 @@ function EditorInner({
: undefined;
}
const cm = useRef<{ view: EditorView; languageCompartment: Compartment } | null>(null);
// Use ref so we can update the handler without re-initializing the editor
const handleChange = useRef<EditorProps["onChange"]>(onChange);
useEffect(() => {
handleChange.current = onChange;
}, [onChange]);
// Use ref so we can update the handler without re-initializing the editor
const handlePaste = useRef<EditorProps["onPaste"]>(onPaste);
useEffect(() => {
handlePaste.current = onPaste;
}, [onPaste]);
// Use ref so we can update the handler without re-initializing the editor
const handlePasteOverwrite = useRef<EditorProps["onPasteOverwrite"]>(onPasteOverwrite);
useEffect(() => {
handlePasteOverwrite.current = onPasteOverwrite;
}, [onPasteOverwrite]);
// Use ref so we can update the handler without re-initializing the editor
const handleFocus = useRef<EditorProps["onFocus"]>(onFocus);
useEffect(() => {
handleFocus.current = onFocus;
}, [onFocus]);
// Use ref so we can update the handler without re-initializing the editor
const handleBlur = useRef<EditorProps["onBlur"]>(onBlur);
useEffect(() => {
handleBlur.current = onBlur;
}, [onBlur]);
// Use ref so we can update the handler without re-initializing the editor
const handleKeyDown = useRef<EditorProps["onKeyDown"]>(onKeyDown);
useEffect(() => {
handleKeyDown.current = onKeyDown;
}, [onKeyDown]);
// Update placeholder
const placeholderCompartment = useRef(new Compartment());
useEffect(
function configurePlaceholder() {
if (cm.current === null) return;
const ext = placeholderExt(placeholderElFromText(placeholder));
const effects = placeholderCompartment.current.reconfigure(ext);
cm.current?.view.dispatch({ effects });
// Template dialogs are opened against the editor the tag was clicked in
const viewRef = useRef<EditorView | null>(null);
const { setRef } = props;
const handleSetRef = useCallback(
(view: EditorView | null) => {
viewRef.current = view;
setRef?.(view);
},
[placeholder],
);
// Update vim
const keymapCompartment = useRef(new Compartment());
useEffect(
function configureKeymap() {
if (cm.current === null) return;
const current = keymapCompartment.current.get(cm.current.view.state) ?? [];
// PERF: This is expensive with hundreds of editors on screen, so only do it when necessary
if (settings.editorKeymap === "default" && current === keymapExtensions.default) return; // Nothing to do
if (settings.editorKeymap === "vim" && current === keymapExtensions.vim) return; // Nothing to do
if (settings.editorKeymap === "vscode" && current === keymapExtensions.vscode) return; // Nothing to do
if (settings.editorKeymap === "emacs" && current === keymapExtensions.emacs) return; // Nothing to do
const ext = keymapExtensions[settings.editorKeymap] ?? keymapExtensions.default;
const effects = keymapCompartment.current.reconfigure(ext);
cm.current.view.dispatch({ effects });
},
[settings.editorKeymap],
);
// Update wrap lines
const wrapLinesCompartment = useRef(new Compartment());
useEffect(
function configureWrapLines() {
if (cm.current === null) return;
const current = wrapLinesCompartment.current.get(cm.current.view.state) ?? emptyExtension;
// PERF: This is expensive with hundreds of editors on screen, so only do it when necessary
if (wrapLines && current !== emptyExtension) return; // Nothing to do
if (!wrapLines && current === emptyExtension) return; // Nothing to do
const ext = wrapLines ? EditorView.lineWrapping : emptyExtension;
const effects = wrapLinesCompartment.current.reconfigure(ext);
cm.current?.view.dispatch({ effects });
},
[wrapLines],
);
// Update tab indent
const tabIndentCompartment = useRef(new Compartment());
useEffect(
function configureTabIndent() {
if (cm.current === null) return;
const current = tabIndentCompartment.current.get(cm.current.view.state) ?? emptyExtension;
// PERF: This is expensive with hundreds of editors on screen, so only do it when necessary
if (disableTabIndent && current !== emptyExtension) return; // Nothing to do
if (!disableTabIndent && current === emptyExtension) return; // Nothing to do
const ext = !disableTabIndent ? keymap.of([indentWithTab]) : emptyExtension;
const effects = tabIndentCompartment.current.reconfigure(ext);
cm.current?.view.dispatch({ effects });
},
[disableTabIndent],
);
// Update read-only
const readOnlyCompartment = useRef(new Compartment());
useEffect(
function configureReadOnly() {
if (cm.current === null) return;
const current = readOnlyCompartment.current.get(cm.current.view.state) ?? emptyExtension;
const next = readOnly ? readonlyExtensions : editableExtensions;
// PERF: This is expensive with hundreds of editors on screen, so only do it when necessary
if (current === next) return;
const effects = readOnlyCompartment.current.reconfigure(next);
cm.current?.view.dispatch({ effects });
},
[readOnly],
[setRef],
);
const onClickFunction = useCallback(
async (fn: TemplateFunction, tagValue: string, startPos: number) => {
const show = () => {
if (cm.current === null) return;
TemplateFunctionDialog.show(fn, tagValue, startPos, cm.current.view);
if (viewRef.current === null) return;
TemplateFunctionDialog.show(fn, tagValue, startPos, viewRef.current);
};
if (fn.name === "secure") {
@@ -344,427 +132,64 @@ function EditorInner({
!!autocompleteFunctions,
);
// Update the language extension when the language changes
// oxlint-disable-next-line react-hooks/exhaustive-deps -- intentionally limited deps
useEffect(() => {
if (cm.current === null) return;
const { view, languageCompartment } = cm.current;
const ext = getLanguageExtension({
useTemplating,
const languageExtension = useMemo(
() =>
getLanguageExtension({
useTemplating,
language,
lintExtension,
hideGutter,
environmentVariables,
autocomplete,
completionOptions,
onClickVariable,
onClickMissingVariable,
onClickPathParameter,
graphQLSchema: graphQLSchema ?? null,
}),
[
language,
lintExtension,
hideGutter,
environmentVariables,
autocomplete,
completionOptions,
environmentVariables,
onClickVariable,
onClickMissingVariable,
onClickPathParameter,
graphQLSchema: graphQLSchema ?? null,
});
view.dispatch({ effects: languageCompartment.reconfigure(ext) });
}, [
language,
lintExtension,
autocomplete,
environmentVariables,
onClickFunction,
onClickVariable,
onClickMissingVariable,
onClickPathParameter,
completionOptions,
useTemplating,
graphQLSchema,
hideGutter,
]);
// Initialize the editor when ref mounts
// oxlint-disable-next-line react-hooks/exhaustive-deps -- only reinitialize when necessary
const initEditorRef = useCallback(
function initEditorRef(container: HTMLDivElement | null) {
if (container === null) {
flushCachedEditorState(stateKey);
cm.current?.view.destroy();
cm.current = null;
return;
}
try {
const languageCompartment = new Compartment();
const langExt = getLanguageExtension({
useTemplating,
language,
lintExtension,
completionOptions,
autocomplete,
environmentVariables,
onClickVariable,
onClickMissingVariable,
onClickPathParameter,
graphQLSchema: graphQLSchema ?? null,
});
const extensions = [
languageCompartment.of(langExt),
placeholderCompartment.current.of(placeholderExt(placeholderElFromText(placeholder))),
wrapLinesCompartment.current.of(wrapLines ? EditorView.lineWrapping : emptyExtension),
tabIndentCompartment.current.of(
!disableTabIndent ? keymap.of([indentWithTab]) : emptyExtension,
),
keymapCompartment.current.of(
keymapExtensions[settings.editorKeymap] ?? keymapExtensions.default,
),
readOnlyCompartment.current.of(readOnly ? readonlyExtensions : editableExtensions),
...getExtensions({
container,
singleLine,
hideGutter,
stateKey,
onChange: handleChange,
onPaste: handlePaste,
onPasteOverwrite: handlePasteOverwrite,
onFocus: handleFocus,
onBlur: handleBlur,
onKeyDown: handleKeyDown,
}),
...(Array.isArray(extraExtensions)
? extraExtensions
: extraExtensions
? [extraExtensions]
: []),
];
const cachedJsonState = getCachedEditorState(defaultValue ?? "", stateKey, !!readOnly);
const doc = `${defaultValue ?? ""}`;
const config: EditorStateConfig = { extensions, doc };
const state = cachedJsonState
? EditorState.fromJSON(cachedJsonState, config, stateFields)
: EditorState.create(config);
const view = new EditorView({ state, parent: container });
// For large documents, the parser may parse the max number of lines and fail to add
// things like fold markers because of it.
// This forces it to parse more but keeps the timeout to the default of 100 ms.
forceParsing(view, 9e6, 100);
cm.current = { view, languageCompartment };
if (autoFocus) {
view.focus();
}
if (autoSelect) {
view.dispatch({ selection: { anchor: 0, head: view.state.doc.length } });
}
setRef?.(view);
} catch (e) {
console.log("Failed to initialize Codemirror", e);
}
},
[forceUpdateKey],
completionOptions,
useTemplating,
graphQLSchema,
hideGutter,
],
);
// For read-only mode, update content when `defaultValue` changes
useEffect(
function updateReadOnlyEditor() {
if (readOnly && cm.current?.view != null) {
updateContents(cm.current.view, defaultValue || "");
}
},
[defaultValue, readOnly],
);
// Force input to update when receiving change and not in focus
useLayoutEffect(
function updateNonFocusedEditor() {
const notFocused = !cm.current?.view.hasFocus;
if (notFocused && cm.current != null) {
updateContents(cm.current.view, defaultValue || "");
}
},
[defaultValue],
);
// Add bg classes to actions, so they appear over the text
const decoratedActions = useMemo(() => {
const results = [];
const actionClassName = classNames(
"bg-surface transition-opacity transform-gpu opacity-0 group-hover:opacity-100 hover:opacity-100! shadow",
);
if (format) {
results.push(
<IconButton
showConfirm
key="format"
size="sm"
title="Reformat contents"
icon="magic_wand"
variant="border"
className={classNames(actionClassName)}
onClick={async () => {
if (cm.current === null) return;
const { doc } = cm.current.view.state;
const formatted = await format(doc.toString());
// Update editor and blur because the cursor will reset anyway
cm.current.view.dispatch({
changes: { from: 0, to: doc.length, insert: formatted },
});
cm.current.view.contentDOM.blur();
// Fire change event
onChange?.(formatted);
}}
/>,
);
}
results.push(
Children.map(actions, (existingChild) => {
if (!isValidElement<{ className?: string }>(existingChild)) return null;
const existingProps = existingChild.props;
return cloneElement(existingChild, {
...existingProps,
className: classNames(existingProps.className, actionClassName),
});
}),
);
return results;
}, [actions, format, onChange]);
const cmContainer = (
<div
ref={initEditorRef}
className={classNames(
className,
"cm-wrapper text-base",
disabled && "opacity-disabled",
type === "password" && "cm-obscure-text",
heightMode === "auto" ? "cm-auto-height" : "cm-full-height",
singleLine ? "cm-singleline" : "cm-multiline",
readOnly && "cm-readonly",
)}
/>
);
if (singleLine || containerOnly) {
return cmContainer;
}
return (
<div className="group relative h-full w-full x-theme-editor bg-surface">
{cmContainer}
{decoratedActions && (
<HStack
space={1}
justifyContent="end"
className={classNames(
"absolute bottom-2 left-0 right-0",
"pointer-events-none", // No pointer events, so we don't block the editor
)}
>
{decoratedActions}
</HStack>
)}
</div>
<CodeEditor
{...props}
editableExtensions={mediaValues}
extensions={extraExtensions}
format={format}
hideGutter={hideGutter}
keyDownHandler={startCompletionOnHotkey}
keymap={settings.editorKeymap}
language={codeEditorLanguage(language)}
languageExtension={languageExtension}
lintExtension={lintExtension}
readOnly={readOnly}
readOnlyExtensions={largeValues}
setRef={handleSetRef}
wrapLines={wrapLines}
/>
);
}
function getExtensions({
stateKey,
container,
singleLine,
hideGutter,
onChange,
onPaste,
onPasteOverwrite,
onFocus,
onBlur,
onKeyDown,
}: Pick<EditorProps, "singleLine" | "hideGutter"> & {
stateKey: EditorProps["stateKey"];
container: HTMLDivElement | null;
onChange: RefObject<EditorProps["onChange"]>;
onPaste: RefObject<EditorProps["onPaste"]>;
onPasteOverwrite: RefObject<EditorProps["onPasteOverwrite"]>;
onFocus: RefObject<EditorProps["onFocus"]>;
onBlur: RefObject<EditorProps["onBlur"]>;
onKeyDown: RefObject<EditorProps["onKeyDown"]>;
}) {
// TODO: Ensure tooltips render inside the dialog if we are in one.
const parent =
container?.closest<HTMLDivElement>('[role="dialog"]') ??
document.querySelector<HTMLDivElement>("#cm-portal") ??
undefined;
return [
...baseExtensions, // Must be first
EditorView.contentAttributes.of({
autocapitalize: "off",
autocorrect: "off",
// Keeps macOS Writing Tools from offering to write code for us
writingsuggestions: "false",
}),
EditorView.domEventHandlers({
focus: () => {
onFocus.current?.();
},
blur: () => {
onBlur.current?.();
},
keydown: (e, view) => {
// Check if the hotkey matches the editor.autocomplete action
if (eventMatchesHotkey(e, "editor.autocomplete")) {
e.preventDefault();
startCompletion(view);
return true;
}
onKeyDown.current?.(e);
},
paste: (e, v) => {
const textData = e.clipboardData?.getData("text/plain") ?? "";
onPaste.current?.(textData);
if (v.state.selection.main.from === 0 && v.state.selection.main.to === v.state.doc.length) {
onPasteOverwrite.current?.(e, textData);
}
},
}),
tooltips({ parent }),
keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== "Enter") : defaultKeymap),
...(singleLine ? [singleLineExtensions()] : []),
...(!singleLine ? multiLineExtensions({ hideGutter }) : []),
// ------------------------ //
// Things that must be last //
// ------------------------ //
EditorView.updateListener.of((update) => {
if (update.startState === update.state) return;
if (onChange && update.docChanged) {
onChange.current?.(update.state.doc.toString());
}
saveCachedEditorStateDebounced(stateKey, update.state);
}),
];
/** The client's own languages bring their extension along, so CodeEditor needn't know them */
function codeEditorLanguage(language: EditorProps["language"]): CodeEditorProps["language"] {
return language === "url" || language === "pairs" || language === "timeline" ? null : language;
}
const placeholderElFromText = (text: string | undefined) => {
const el = document.createElement("div");
// Default to <SPACE> because codemirror needs it for sizing. I'm not sure why, but probably something
// to do with how Yaak "hacks" it with CSS for single line input.
el.innerHTML = text ? text.replaceAll("\n", "<br/>") : " ";
return el;
};
// Caching the state is too expensive to do on every update (every keystroke and cursor move),
// so debounce it per state key and flush when the editor unmounts.
//
// The cost scales with the document, and every update pays it in full:
// - `state.toJSON` flattens the whole rope into a string, ~0.13 ms per 200 KB
// - the fingerprint is an exact md5 for editable documents, ~0.3 ms per 200 KB. `docFingerprint`
// only samples read-only ones, so editing a large body still hashes all of it
// - `sessionStorage.setItem` is synchronous and blocks the main thread
// Typing in a 200 KB body costs ~0.4 ms per keystroke before the storage write, and a 1 MB one
// ~2.3 ms. Read-only documents skip most of the hashing but still serialize, which is the larger
// half once documents get into the megabytes.
const SAVE_STATE_DEBOUNCE_MS = 500;
const stateSavers = new Map<string, ReturnType<typeof debounce>>();
function saveCachedEditorStateDebounced(stateKey: string | null, state: EditorState) {
if (!stateKey) return;
let saver = stateSavers.get(stateKey);
if (saver == null) {
saver = debounce(
(s: EditorState) => saveCachedEditorState(stateKey, s),
SAVE_STATE_DEBOUNCE_MS,
);
stateSavers.set(stateKey, saver);
}
saver(state);
}
// NOTE: Only called when an editor unmounts, so the saver is dropped rather than left in the map
// for every state key the session has ever shown. A pending saver holds the last EditorState,
// which holds the whole document.
function flushCachedEditorState(stateKey: string | null) {
if (!stateKey) return;
const saver = stateSavers.get(stateKey);
if (saver == null) return;
saver.flush();
stateSavers.delete(stateKey);
}
function saveCachedEditorState(stateKey: string | null, state: EditorState | null) {
if (!stateKey || state == null) return;
const stateObj = state.toJSON(stateFields);
// Save state in sessionStorage by removing doc and saving a fingerprint of it instead.
// This will be checked on restore and put back in if it matches.
// Editable documents get the exact hash, so a collision can never restore undo history
// belonging to other content
stateObj.docHash = docFingerprint(stateObj.doc, { exact: !state.readOnly });
stateObj.doc = undefined;
try {
sessionStorage.setItem(computeFullStateKey(stateKey), JSON.stringify(stateObj));
} catch (err) {
console.log("Failed to save to editor state", stateKey, err);
}
}
function getCachedEditorState(doc: string, stateKey: string | null, readOnly: boolean) {
if (stateKey == null) return;
try {
const stateStr = sessionStorage.getItem(computeFullStateKey(stateKey));
if (stateStr == null) return null;
const { docHash, ...state } = JSON.parse(stateStr);
// Ensure the doc matches the one that was used to save the state
if (docHash !== docFingerprint(doc, { exact: !readOnly })) {
return null;
}
state.doc = doc;
return state;
} catch (err) {
console.log("Failed to restore editor storage", stateKey, err);
}
return null;
}
function computeFullStateKey(stateKey: string): string {
return `editor.${stateKey}`;
}
function updateContents(view: EditorView, text: string) {
// Replace codemirror contents
const currentDoc = view.state.doc.toString();
if (currentDoc === text) {
return;
}
if (text.startsWith(currentDoc)) {
// If we're just appending, append only the changes. This preserves
// things like scroll position.
view.dispatch({
changes: view.state.changes({
from: currentDoc.length,
insert: text.slice(currentDoc.length),
}),
});
} else {
// If we're replacing everything, reset the entire content
view.dispatch({
changes: view.state.changes({
from: 0,
to: currentDoc.length,
insert: text,
}),
});
}
function startCompletionOnHotkey(e: KeyboardEvent, view: EditorView): boolean {
if (!eventMatchesHotkey(e, "editor.autocomplete")) return false;
e.preventDefault();
startCompletion(view);
return true;
}
@@ -1,141 +1,30 @@
import {
autocompletion,
closeBrackets,
closeBracketsKeymap,
completionKeymap,
} from "@codemirror/autocomplete";
import { history, historyKeymap } from "@codemirror/commands";
import { go } from "@codemirror/lang-go";
import { java } from "@codemirror/lang-java";
import { javascript } from "@codemirror/lang-javascript";
import { markdown } from "@codemirror/lang-markdown";
import { php } from "@codemirror/lang-php";
import { python } from "@codemirror/lang-python";
import { xml } from "@codemirror/lang-xml";
import {
bracketMatching,
codeFolding,
foldGutter,
foldKeymap,
HighlightStyle,
indentOnInput,
LanguageSupport,
StreamLanguage,
syntaxHighlighting,
} from "@codemirror/language";
import { c, csharp, kotlin, objectiveC } from "@codemirror/legacy-modes/mode/clike";
import { clojure } from "@codemirror/legacy-modes/mode/clojure";
import { http } from "@codemirror/legacy-modes/mode/http";
import { oCaml } from "@codemirror/legacy-modes/mode/mllike";
import { powerShell } from "@codemirror/legacy-modes/mode/powershell";
import { r } from "@codemirror/legacy-modes/mode/r";
import { ruby } from "@codemirror/legacy-modes/mode/ruby";
import { shell } from "@codemirror/legacy-modes/mode/shell";
import { swift } from "@codemirror/legacy-modes/mode/swift";
import { linter, lintGutter, lintKeymap } from "@codemirror/lint";
import { search, searchKeymap } from "@codemirror/search";
import { linter } from "@codemirror/lint";
import type { Extension } from "@codemirror/state";
import { EditorState } from "@codemirror/state";
import {
crosshairCursor,
drawSelection,
dropCursor,
EditorView,
highlightActiveLineGutter,
highlightSpecialChars,
keymap,
lineNumbers,
rectangularSelection,
} from "@codemirror/view";
import { tags as t } from "@lezer/highlight";
import { jsonc, jsoncLanguage } from "@shopify/lang-jsonc";
closeBracketsExtensions,
getLanguageExtension as getBaseLanguageExtension,
type LanguageSupports,
readonlyExtensions as baseReadonlyExtensions,
syntaxExtensions,
} from "@yaakapp-internal/ui/editor";
import { graphql } from "cm6-graphql";
import type { GraphQLSchema } from "graphql";
import { activeRequestIdAtom } from "../../../hooks/useActiveRequestId";
import type { WrappedEnvironmentVariable } from "../../../hooks/useEnvironmentVariables";
import { jotaiStore } from "../../../lib/jotai";
import { renderMarkdown } from "../../../lib/markdown";
import { pluralizeCount } from "../../../lib/pluralize";
import { showGraphQLDocExplorerAtom } from "../../graphql/graphqlAtoms";
import type { EditorProps } from "./Editor";
import { jsonParseLinter } from "./json-lint";
import { largeValues, mediaValues } from "./largeValues";
import { largeValues } from "./largeValues";
import { pairs } from "./pairs/extension";
import { searchMatchCount } from "./searchMatchCount";
import { text } from "./text/extension";
import { timeline } from "./timeline/extension";
import type { TwigCompletionOption } from "./twig/completion";
import { twig } from "./twig/extension";
import { pathParametersPlugin } from "./twig/pathParameters";
import { url } from "./url/extension";
export const syntaxHighlightStyle = HighlightStyle.define([
{
tag: [t.documentMeta, t.blockComment, t.lineComment, t.docComment, t.comment],
color: "var(--textSubtlest)",
},
{
tag: [t.emphasis],
textDecoration: "underline",
},
{
tag: [t.angleBracket, t.paren, t.bracket, t.squareBracket, t.brace, t.separator, t.punctuation],
color: "var(--textSubtle)",
},
{
tag: [t.link, t.name, t.tagName, t.angleBracket, t.docString, t.number],
color: "var(--info)",
},
{ tag: [t.variableName], color: "var(--success)" },
{ tag: [t.bool], color: "var(--warning)" },
{ tag: [t.attributeName, t.propertyName], color: "var(--primary)" },
{ tag: [t.attributeValue], color: "var(--warning)" },
{ tag: [t.string], color: "var(--notice)" },
{ tag: [t.atom, t.meta, t.operator, t.bool, t.null, t.keyword], color: "var(--danger)" },
]);
const syntaxTheme = EditorView.theme({}, { dark: true });
const closeBracketsExtensions: Extension = [closeBrackets(), keymap.of([...closeBracketsKeymap])];
const legacyLang = (mode: Parameters<typeof StreamLanguage.define>[0]) => {
return () => new LanguageSupport(StreamLanguage.define(mode));
};
const syntaxExtensions: Record<
NonNullable<EditorProps["language"]>,
null | (() => LanguageSupport)
> = {
graphql: null,
json: jsonc,
javascript: javascript,
// HTML as XML because HTML is oddly slow
html: xml,
xml: xml,
url: url,
pairs: pairs,
text: text,
timeline: timeline,
markdown: markdown,
c: legacyLang(c),
clojure: legacyLang(clojure),
csharp: legacyLang(csharp),
go: go,
http: legacyLang(http),
java: java,
kotlin: legacyLang(kotlin),
objective_c: legacyLang(objectiveC),
ocaml: legacyLang(oCaml),
php: php,
powershell: legacyLang(powerShell),
python: python,
r: legacyLang(r),
ruby: legacyLang(ruby),
shell: legacyLang(shell),
swift: legacyLang(swift),
};
const closeBracketsFor: (keyof typeof syntaxExtensions)[] = ["json", "javascript", "graphql"];
const languages: LanguageSupports = { ...syntaxExtensions, url, pairs, timeline };
export function getLanguageExtension({
useTemplating,
@@ -157,18 +46,7 @@ export function getLanguageExtension({
onClickPathParameter: (name: string) => void;
completionOptions: TwigCompletionOption[];
graphQLSchema: GraphQLSchema | null;
} & Pick<EditorProps, "language" | "autocomplete" | "hideGutter" | "lintExtension">) {
const extraExtensions: Extension[] = [];
if (language === "url") {
extraExtensions.push(pathParametersPlugin(onClickPathParameter));
}
// Only close brackets on languages that need it
if (language && closeBracketsFor.includes(language)) {
extraExtensions.push(closeBracketsExtensions);
}
} & Pick<EditorProps, "language" | "autocomplete" | "hideGutter" | "lintExtension">): Extension {
// GraphQL is a special exception
if (language === "graphql") {
return [
@@ -189,155 +67,31 @@ export function getLanguageExtension({
}));
},
}),
extraExtensions,
[closeBracketsExtensions],
];
}
if (language === "json") {
extraExtensions.push(lintExtension ?? linter(jsonParseLinter()));
extraExtensions.push(
jsoncLanguage.data.of({
commentTokens: { line: "//", block: { open: "/*", close: "*/" } },
}),
);
if (!hideGutter) {
extraExtensions.push(lintGutter());
}
}
const maybeBase = language ? syntaxExtensions[language] : null;
const base = typeof maybeBase === "function" ? maybeBase() : null;
if (base == null) {
return [];
}
if (!useTemplating) {
return [base, extraExtensions];
}
return twig({
base,
environmentVariables,
completionOptions,
autocomplete,
onClickVariable,
onClickMissingVariable,
onClickPathParameter,
extraExtensions,
return getBaseLanguageExtension({
language,
hideGutter,
languages,
lintExtension: language === "json" ? (lintExtension ?? linter(jsonParseLinter())) : undefined,
extraExtensions: language === "url" ? [pathParametersPlugin(onClickPathParameter)] : [],
wrap: useTemplating
? (base, extraExtensions) =>
twig({
base,
environmentVariables,
completionOptions,
autocomplete,
onClickVariable,
onClickMissingVariable,
onClickPathParameter,
extraExtensions,
})
: undefined,
});
}
// Filter out autocomplete start triggers from completionKeymap since we handle it via configurable hotkeys.
// Keep navigation keys (ArrowUp/Down, Enter, Escape, etc.) but remove startCompletion bindings.
const filteredCompletionKeymap = completionKeymap.filter((binding) => {
const key = binding.key?.toLowerCase() ?? "";
const mac = (binding as { mac?: string }).mac?.toLowerCase() ?? "";
// Filter out Ctrl-Space and Mac-specific autocomplete triggers (Alt-`, Alt-i)
const isStartTrigger = key.includes("space") || mac.includes("alt-") || mac.includes("`");
return !isStartTrigger;
});
export const baseExtensions = [
highlightSpecialChars(),
history(),
dropCursor(),
drawSelection(),
autocompletion({
tooltipClass: () => "x-theme-menu",
closeOnBlur: true, // Set to `false` for debugging in devtools without closing it
defaultKeymap: false, // We handle the trigger via configurable hotkeys
compareCompletions: (a, b) => {
// Don't sort completions at all, only on boost
return (a.boost ?? 0) - (b.boost ?? 0);
},
}),
syntaxHighlighting(syntaxHighlightStyle),
syntaxTheme,
keymap.of([...historyKeymap, ...filteredCompletionKeymap]),
];
export const readonlyExtensions = [
EditorState.readOnly.of(true),
EditorView.contentAttributes.of({ tabindex: "-1" }),
// Nobody is editing this, so every rule applies
largeValues,
];
/**
* The counterpart for a document being edited.
*
* Only encoded media collapses here: it is pasted rather than typed, and replaced rather than
* edited, so a tag stands in for it without hiding anything anyone meant to read. The rules that
* hide text by length alone stay out. See {@link mediaValues}.
*/
export const editableExtensions = [mediaValues];
export const multiLineExtensions = ({ hideGutter }: { hideGutter?: boolean }) => [
search({ top: true }),
searchMatchCount(),
hideGutter
? []
: [
lineNumbers(),
foldGutter({
markerDOM: (open) => {
const el = document.createElement("div");
el.classList.add("fold-gutter-icon");
el.tabIndex = -1;
if (open) {
el.setAttribute("data-open", "");
}
return el;
},
}),
],
codeFolding({
placeholderDOM(_view, onclick, prepared) {
const el = document.createElement("span");
el.onclick = onclick;
el.className = "cm-foldPlaceholder";
el.innerText = prepared || "…";
el.title = "unfold";
el.ariaLabel = "folded code";
return el;
},
/**
* Show the number of items when code folded. NOTE: this doesn't get called when restoring
* a previous serialized editor state, which is a bummer
*/
preparePlaceholder(state, range) {
let count: number | undefined;
let startToken = "{";
let endToken = "}";
const prevLine = state.doc.lineAt(range.from).text;
const isArray = prevLine.lastIndexOf("[") > prevLine.lastIndexOf("{");
if (isArray) {
startToken = "[";
endToken = "]";
}
const internal = state.sliceDoc(range.from, range.to);
const toParse = startToken + internal + endToken;
try {
const parsed = JSON.parse(toParse);
count = Object.keys(parsed).length;
} catch {
/* empty */
}
if (count !== undefined) {
const label = isArray ? "item" : "key";
return pluralizeCount(label, count);
}
},
}),
indentOnInput(),
rectangularSelection(),
crosshairCursor(),
bracketMatching(),
highlightActiveLineGutter(),
keymap.of([...searchKeymap, ...foldKeymap, ...lintKeymap]),
];
// Nobody is editing this, so every rule applies
export const readonlyExtensions = [...baseReadonlyExtensions, largeValues];
@@ -1,15 +1,8 @@
import type { Completion, CompletionContext } from "@codemirror/autocomplete";
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
import type { CompletionContext } from "@codemirror/autocomplete";
import type { GenericCompletionConfig } from "@yaakapp-internal/ui";
import { defaultBoost } from "./twig/completion";
export type GenericCompletion = GenericCompletionOption & {
apply?: Completion["apply"];
};
export interface GenericCompletionConfig {
minMatch?: number;
options: GenericCompletion[];
}
export type { GenericCompletion, GenericCompletionConfig } from "@yaakapp-internal/ui";
/**
* Complete options, always matching until the start of the line
@@ -0,0 +1,46 @@
import type { EditorView } from "@codemirror/view";
import { describe, expect, test } from "vite-plus/test";
import { jsonParseLinter } from "./json-lint";
// The linter only reads the document text off the view
function view(doc: string): EditorView {
return { state: { doc: { toString: () => doc } } } as EditorView;
}
const lint = jsonParseLinter();
describe("jsonParseLinter", () => {
test("reports nothing for an empty document", () => {
expect(lint(view(" "))).toEqual([]);
});
test("reports nothing for valid JSON", () => {
expect(lint(view('{"a": 1}'))).toEqual([]);
});
test("reports a message for invalid JSON", () => {
expect(lint(view('{"a": }'))).toHaveLength(1);
});
test("ignores a template tag standing in for a value", () => {
expect(lint(view('{"a": "${[ my_var ]}"}'))).toEqual([]);
});
test("ignores a tag whose quoted argument contains `]}`", () => {
expect(lint(view(`{"a": "\${[ fn(arg='x]}y') ]}"}`))).toEqual([]);
});
test("ignores a tag whose quoted argument contains an escaped quote", () => {
expect(lint(view(`{"a": "\${[ fn(arg='it\\'s ]}') ]}"}`))).toEqual([]);
});
test("still reports errors around a tag with a quoted `]}`", () => {
expect(lint(view(`{"a": "\${[ fn(arg='x]}y') ]}",}`))).toHaveLength(1);
});
test("keeps the tag's length so error offsets stay correct", () => {
const doc = `{"a" "\${[ fn(arg='x]}y') ]}"}`;
const [error] = lint(view(doc));
expect(error?.from).toEqual(doc.indexOf('"${['));
});
});
@@ -1,58 +1,15 @@
import type { Diagnostic } from "@codemirror/lint";
import type { EditorView } from "@codemirror/view";
import { type ParseError, parse, printParseErrorCode } from "jsonc-parser";
import {
type JsonLintOptions,
jsonParseLinter as baseJsonParseLinter,
} from "@yaakapp-internal/ui/editor";
import { replaceTemplateTags } from "../../../lib/templateTags";
const TEMPLATE_SYNTAX_REGEX = /\$\{\[[\s\S]*?]}/g;
// jsonc-parser reports error codes, so these are the words the editor shows for them
const MESSAGES: Record<string, string> = {
InvalidSymbol: "Invalid symbol",
InvalidNumberFormat: "Invalid number format",
PropertyNameExpected: "Property name expected",
ValueExpected: "Value expected",
ColonExpected: "Colon expected",
CommaExpected: "Comma expected",
CloseBraceExpected: "Closing brace expected",
CloseBracketExpected: "Closing bracket expected",
EndOfFileExpected: "End of file expected",
InvalidCommentToken: "Comments are not allowed",
UnexpectedEndOfComment: "Unexpected end of comment",
UnexpectedEndOfString: "Unexpected end of string",
UnexpectedEndOfNumber: "Unexpected end of number",
InvalidUnicode: "Invalid unicode sequence",
InvalidEscapeCharacter: "Invalid escape character",
InvalidCharacter: "Invalid character",
};
interface JsonLintOptions {
allowComments?: boolean;
allowTrailingCommas?: boolean;
export function jsonParseLinter(options?: Omit<JsonLintOptions, "escape">) {
return baseJsonParseLinter({ ...options, escape: escapeTemplateTags });
}
export function jsonParseLinter(options?: JsonLintOptions) {
return (view: EditorView): Diagnostic[] => {
const doc = view.state.doc.toString();
// We need lint to not break on stuff like {"foo:" ${[ ... ]}} so we'll replace all template
// syntax with repeating `1` characters, so it's valid JSON and the position is still correct.
const escapedDoc = doc.replace(TEMPLATE_SYNTAX_REGEX, (m) => "1".repeat(m.length));
const errors: ParseError[] = [];
parse(escapedDoc, errors, {
allowTrailingComma: options?.allowTrailingCommas ?? false,
disallowComments: !(options?.allowComments ?? true),
});
// Later errors are mostly consequences of the first one, so only that one is shown
const error = errors[0];
if (error == null) return [];
return [
{
from: error.offset,
to: error.offset + error.length,
severity: "error",
message: MESSAGES[printParseErrorCode(error.error)] ?? "Invalid JSON",
},
];
};
// 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.
function escapeTemplateTags(doc: string): string {
return replaceTemplateTags(doc, (m) => "1".repeat(m.end - m.start));
}
@@ -1,6 +1,5 @@
import { EditorState } from "@codemirror/state";
import { jsonc } from "@shopify/lang-jsonc";
import { text } from "./text/extension";
import { twig } from "./twig/extension";
import { describe, expect, test } from "vite-plus/test";
import {
@@ -11,6 +10,7 @@ import {
MAX_VISIBLE_LINE_CHARS,
} from "./largeValues";
import type { SniffedValue } from "./sniffValue";
import { text } from "@yaakapp-internal/ui/editor";
const BIG = "A".repeat(1_000_000);
@@ -0,0 +1,7 @@
import type { CodeEditorProps } from "@yaakapp-internal/ui/editor";
import { Editor } from "./LazyEditor";
/** Renders a ui component's editor as the client's, with its settings and value handling */
export function renderEditor({ extensions, ...props }: CodeEditorProps) {
return <Editor {...props} extraExtensions={extensions} />;
}
@@ -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 { twigCompletion, type TwigCompletionOption } from "./completion";
import { parser } from "./twig";
import { singleLineExtensions } from "@yaakapp-internal/ui/editor";
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,
};
@@ -5,11 +5,11 @@ import { parseMixed } from "@lezer/common";
import type { WrappedEnvironmentVariable } from "../../../../hooks/useEnvironmentVariables";
import type { GenericCompletionConfig } from "../genericCompletion";
import { genericCompletion } from "../genericCompletion";
import { textLanguage } from "../text/extension";
import type { TwigCompletionOption } from "./completion";
import { twigCompletion } from "./completion";
import { templateTagsPlugin } from "./templateTags";
import { parser as twigParser } from "./twig";
import { textLanguage } from "@yaakapp-internal/ui/editor";
export function twig({
base,
@@ -4,4 +4,6 @@ export const highlight = styleTags({
TagOpen: t.bracket,
TagClose: t.bracket,
TagContent: t.keyword,
// Strings are part of the tag body, so they get the same color as the rest of it
TagString: t.keyword,
});
@@ -1,13 +1,17 @@
@top Template { (Tag | Text)* }
// `TagString` keeps a quoted argument from closing the tag, so `${[ fn(a='x]}y') ]}` stays a
// single Tag. Only a terminated string matches, so a half-typed `'` degrades to TagContent and
// the tag still closes at the next `]}`.
@local tokens {
TagClose { "]}" }
TagString { "'" (![\\'] | "\\" _)* "'" }
@else TagContent
}
@skip { } {
TagOpen { "${[" }
Tag { TagOpen (TagContent)+ TagClose }
Tag { TagOpen (TagContent | TagString)+ TagClose }
}
@tokens {
@@ -3,5 +3,6 @@ export const Template = 1,
Tag = 2,
TagOpen = 3,
TagContent = 4,
TagClose = 5,
Text = 6;
TagString = 5,
TagClose = 6,
Text = 7;
Loaded 100 of 794 files, more files were not shown because too many files have changed in this diff. Show more