mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-09-01 16:17:25 +02:00
Compare commits
119
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50cccf1d25 | ||
|
|
0aae516f3a | ||
|
|
b7b8ae5f94 | ||
|
|
6777003b70 | ||
|
|
426c6d5eb6 | ||
|
|
068afe325e | ||
|
|
cef1129d4b | ||
|
|
9a7bcf73bb | ||
|
|
aa76d501f0 | ||
|
|
ce8cb5e7bc | ||
|
|
66c13d7e13 | ||
|
|
388dd8815f | ||
|
|
cb295b7102 | ||
|
|
4e835cf7e8 | ||
|
|
2350689d7a | ||
|
|
4eebd606ef | ||
|
|
c4f96f3f11 | ||
|
|
71c217d3f0 | ||
|
|
d89831a84d | ||
|
|
3332ae263f | ||
|
|
df1fd864b2 | ||
|
|
a2d54ca774 | ||
|
|
36fec8b005 | ||
|
|
115615d994 | ||
|
|
538f782068 | ||
|
|
3f202ff664 | ||
|
|
131b7e5ab1 | ||
|
|
69083918f9 | ||
|
|
4b2dcf9a1a | ||
|
|
569f552d79 | ||
|
|
33f32cccf6 | ||
|
|
7ca772347f | ||
|
|
b89c448345 | ||
|
|
2021df112a | ||
|
|
2d2a390bfd | ||
|
|
5cce23566a | ||
|
|
e99f6d2bc7 | ||
|
|
bea58b16b4 | ||
|
|
93fba4d9b4 | ||
|
|
b9071eafe0 | ||
|
|
778c74c635 | ||
|
|
0f434361a7 | ||
|
|
d27d11af7c | ||
|
|
1a19a06a23 | ||
|
|
07a9a6c6c0 | ||
|
|
e54240d579 | ||
|
|
8bca013ab4 | ||
|
|
10e962a0e6 | ||
|
|
78954e10c8 | ||
|
|
9eb7a001da | ||
|
|
6a02cbe525 | ||
|
|
6f91f76064 | ||
|
|
32e92d484b | ||
|
|
cdbbef34f8 | ||
|
|
4838353585 | ||
|
|
93001e3da7 | ||
|
|
b31c066717 | ||
|
|
5d1d24870a | ||
|
|
6f0d0ef275 | ||
|
|
85a9b2a908 | ||
|
|
f3f05502d1 | ||
|
|
2e0f7d1818 | ||
|
|
dc793181bb | ||
|
|
7dfa7e07e3 | ||
|
|
23e7229e63 | ||
|
|
1f91cddab9 | ||
|
|
be004425fa | ||
|
|
4f03c5c390 | ||
|
|
cb43f0b9b9 | ||
|
|
68671377fd | ||
|
|
2383f06e71 | ||
|
|
0068be9ffc | ||
|
|
def7d752db | ||
|
|
85aed740a8 | ||
|
|
d3055c64b3 | ||
|
|
912aaeb7a7 | ||
|
|
d0af512a0c | ||
|
|
e032c4c8d6 | ||
|
|
c3fc3fb68a | ||
|
|
7e2db7799b | ||
|
|
c596e25e5e | ||
|
|
74d1b5d6ce | ||
|
|
b9d4f76193 | ||
|
|
d4a963e216 | ||
|
|
be765d7bcc | ||
|
|
d43de3d95a | ||
|
|
bc0494fe0e | ||
|
|
c57a64d455 | ||
|
|
5bebd74b09 | ||
|
|
a6dd058a3d | ||
|
|
1fe8261fbd | ||
|
|
0e4c355e8b | ||
|
|
f6d926f4b9 | ||
|
|
a6be9dbaee | ||
|
|
67a628d67a | ||
|
|
0bf7eaed81 | ||
|
|
784a3d3a32 | ||
|
|
077bfa87b8 | ||
|
|
733f2e5929 | ||
|
|
0f994f89ac | ||
|
|
24f76398f9 | ||
|
|
14475915df | ||
|
|
a50e04f565 | ||
|
|
32fbd66912 | ||
|
|
3503a9da8e | ||
|
|
cef6abf5d0 | ||
|
|
3f098f95fe | ||
|
|
195f89337f | ||
|
|
57fffe5a41 | ||
|
|
0c24d6562a | ||
|
|
b1ea3e3560 | ||
|
|
d72b7d7d30 | ||
|
|
b40e2cdc1b | ||
|
|
19cc6ee6d4 | ||
|
|
e59ecce886 | ||
|
|
c30f5b767b | ||
|
|
42b22d4c07 | ||
|
|
e05feba708 | ||
|
|
6b9b3660de |
@@ -0,0 +1,11 @@
|
|||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
dist
|
||||||
|
**/dist
|
||||||
|
target
|
||||||
|
**/target
|
||||||
|
.claude
|
||||||
|
vendored
|
||||||
|
**/vendored
|
||||||
|
*.log
|
||||||
|
.git
|
||||||
@@ -630,6 +630,22 @@ async function checkPullRequest({
|
|||||||
const pr = response.data;
|
const pr = response.data;
|
||||||
const issueNumber = pr.number;
|
const issueNumber = pr.number;
|
||||||
|
|
||||||
|
if (pr.user.type === "Bot") {
|
||||||
|
core.notice(
|
||||||
|
`Skipping contribution policy for bot PR #${pr.number} from @${pr.user.login}.`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
blocked: false,
|
||||||
|
number: pr.number,
|
||||||
|
summary: summarizeResult({
|
||||||
|
pr,
|
||||||
|
skipped: true,
|
||||||
|
skipReason: `bot @${pr.user.login}`,
|
||||||
|
}),
|
||||||
|
skipped: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
minimumAutomaticPullNumber != null &&
|
minimumAutomaticPullNumber != null &&
|
||||||
pr.number < minimumAutomaticPullNumber
|
pr.number < minimumAutomaticPullNumber
|
||||||
|
|||||||
@@ -31,3 +31,7 @@ jobs:
|
|||||||
run: vp test
|
run: vp test
|
||||||
- name: Run Rust Tests
|
- name: Run Rust Tests
|
||||||
run: cargo test --all --features yaak-app-client/wry
|
run: cargo test --all --features yaak-app-client/wry
|
||||||
|
- name: OpenAPI import round-trip
|
||||||
|
run: |
|
||||||
|
cargo build -p yaak-cli
|
||||||
|
node plugins/importer-openapi/tests/roundtrip.mjs
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ jobs:
|
|||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "22"
|
node-version: "24"
|
||||||
|
|
||||||
- name: Install source generators
|
- name: Install source generators
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ jobs:
|
|||||||
runtime: "wry"
|
runtime: "wry"
|
||||||
targets: "aarch64-pc-windows-msvc"
|
targets: "aarch64-pc-windows-msvc"
|
||||||
runs-on: ${{ matrix.platform }}
|
runs-on: ${{ matrix.platform }}
|
||||||
timeout-minutes: 40
|
timeout-minutes: 60
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout yaakapp/app
|
- name: Checkout yaakapp/app
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -103,6 +103,18 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y cmake ninja-build libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libnss3 patchelf xdg-utils
|
sudo apt-get install -y cmake ninja-build libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libnss3 patchelf xdg-utils
|
||||||
|
# crates/yaak-wasm compiles SQLite to wasm via sqlite-wasm-rs, whose C shim
|
||||||
|
# uses C23 [[noreturn]] and expects a freestanding wasm32 target. Ubuntu
|
||||||
|
# 22.04 ships only clang <=15: 14 rejects the attribute, and 15 falls
|
||||||
|
# through to host glibc headers ("bits/libc-header-start.h" not found).
|
||||||
|
# clang-18 handles it (it is what ubuntu-24.04 uses). Install it from
|
||||||
|
# apt.llvm.org since 22.04's repos stop at 15. Only the wasm build uses
|
||||||
|
# this compiler, so the shipped binary keeps 22.04's glibc floor.
|
||||||
|
wget -qO /tmp/llvm.sh https://apt.llvm.org/llvm.sh
|
||||||
|
chmod +x /tmp/llvm.sh
|
||||||
|
sudo /tmp/llvm.sh 18
|
||||||
|
echo "CC_wasm32_unknown_unknown=/usr/bin/clang-18" >> "$GITHUB_ENV"
|
||||||
|
echo "AR_wasm32_unknown_unknown=/usr/bin/llvm-ar-18" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
- name: Install Protoc for plugin-runtime
|
- name: Install Protoc for plugin-runtime
|
||||||
uses: arduino/setup-protoc@v3
|
uses: arduino/setup-protoc@v3
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
name: Release web image
|
||||||
|
|
||||||
|
# Builds ghcr.io/mountain-loop/yaak-web: the browser client and the server that serves it.
|
||||||
|
# One image per architecture on its own native runner (emulating a Rust release build is hours),
|
||||||
|
# joined into one multi-arch tag at the end.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: [v*]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version:
|
||||||
|
description: Version to publish, without the v (e.g. 2026.2.0). Empty publishes main and sha tags only.
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
IMAGE: ghcr.io/mountain-loop/yaak-web
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
if: github.repository == 'mountain-loop/yaak'
|
||||||
|
name: Build ${{ matrix.platform }}
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- platform: linux/amd64
|
||||||
|
runner: ubuntu-22.04
|
||||||
|
arch: amd64
|
||||||
|
- platform: linux/arm64
|
||||||
|
runner: ubuntu-22.04-arm
|
||||||
|
arch: arm64
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push by digest
|
||||||
|
id: build
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: Dockerfile.web
|
||||||
|
platforms: ${{ matrix.platform }}
|
||||||
|
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
|
||||||
|
|
||||||
|
- name: Export digest
|
||||||
|
run: |
|
||||||
|
mkdir -p "${{ runner.temp }}/digests"
|
||||||
|
digest="${{ steps.build.outputs.digest }}"
|
||||||
|
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||||
|
|
||||||
|
- name: Upload digest
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: digest-${{ matrix.arch }}
|
||||||
|
path: ${{ runner.temp }}/digests/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
publish:
|
||||||
|
name: Publish manifest
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Download digests
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: ${{ runner.temp }}/digests
|
||||||
|
pattern: digest-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Set up Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
# `latest` follows a release tag, and a manual run that names a version — the way to
|
||||||
|
# publish before the first release. A prerelease (v2026.2.1-beta.1) never takes it.
|
||||||
|
- name: Tags
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.IMAGE }}
|
||||||
|
flavor: latest=false
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=raw,value=${{ inputs.version }},enable=${{ inputs.version != '' }}
|
||||||
|
type=raw,value=latest,enable=${{ inputs.version != '' || (github.event_name == 'push' && !contains(github.ref_name, '-')) }}
|
||||||
|
type=ref,event=branch
|
||||||
|
type=sha,format=short
|
||||||
|
|
||||||
|
- name: Create and push the manifest
|
||||||
|
working-directory: ${{ runner.temp }}/digests
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools create \
|
||||||
|
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||||
|
$(printf '${{ env.IMAGE }}@sha256:%s ' *)
|
||||||
|
|
||||||
|
- name: Inspect
|
||||||
|
run: docker buildx imagetools inspect ${{ env.IMAGE }}:${{ steps.meta.outputs.version }}
|
||||||
@@ -58,3 +58,4 @@ flatpak/node-sources.json
|
|||||||
|
|
||||||
# Claude Code local settings
|
# Claude Code local settings
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
.claude/worktrees/
|
||||||
|
|||||||
Generated
+517
-89
File diff suppressed because it is too large
Load Diff
@@ -2,9 +2,11 @@
|
|||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = [
|
members = [
|
||||||
"crates/yaak",
|
"crates/yaak",
|
||||||
|
"crates/yaak-commands",
|
||||||
# Common/foundation crates
|
# Common/foundation crates
|
||||||
"crates/common/yaak-database",
|
"crates/common/yaak-database",
|
||||||
"crates/common/yaak-rpc",
|
"crates/common/yaak-rpc",
|
||||||
|
"crates/common/yaak-rpc-schema",
|
||||||
# Shared crates (no Tauri dependency)
|
# Shared crates (no Tauri dependency)
|
||||||
"crates/yaak-core",
|
"crates/yaak-core",
|
||||||
"crates/yaak-common",
|
"crates/yaak-common",
|
||||||
@@ -12,6 +14,7 @@ members = [
|
|||||||
"crates/yaak-git",
|
"crates/yaak-git",
|
||||||
"crates/yaak-grpc",
|
"crates/yaak-grpc",
|
||||||
"crates/yaak-http",
|
"crates/yaak-http",
|
||||||
|
"crates/yaak-lifecycle",
|
||||||
"crates/yaak-models",
|
"crates/yaak-models",
|
||||||
"crates/yaak-plugins",
|
"crates/yaak-plugins",
|
||||||
"crates/yaak-sse",
|
"crates/yaak-sse",
|
||||||
@@ -19,10 +22,13 @@ members = [
|
|||||||
"crates/yaak-templates",
|
"crates/yaak-templates",
|
||||||
"crates/yaak-tls",
|
"crates/yaak-tls",
|
||||||
"crates/yaak-ws",
|
"crates/yaak-ws",
|
||||||
|
"crates/yaak-wasm",
|
||||||
"crates/yaak-api",
|
"crates/yaak-api",
|
||||||
"crates/yaak-proxy",
|
"crates/yaak-proxy",
|
||||||
# Proxy-specific crates
|
# Proxy-specific crates
|
||||||
"crates-proxy/yaak-proxy-lib",
|
"crates-proxy/yaak-proxy-lib",
|
||||||
|
# Server crates (the browser tier's hosted send executor)
|
||||||
|
"crates-server/yaak-web",
|
||||||
# CLI crates
|
# CLI crates
|
||||||
"crates-cli/yaak-cli",
|
"crates-cli/yaak-cli",
|
||||||
# Tauri-specific crates
|
# Tauri-specific crates
|
||||||
@@ -63,15 +69,18 @@ ts-rs = "11.1.0"
|
|||||||
# Internal crates - common/foundation
|
# Internal crates - common/foundation
|
||||||
yaak-database = { path = "crates/common/yaak-database" }
|
yaak-database = { path = "crates/common/yaak-database" }
|
||||||
yaak-rpc = { path = "crates/common/yaak-rpc" }
|
yaak-rpc = { path = "crates/common/yaak-rpc" }
|
||||||
|
yaak-rpc-schema = { path = "crates/common/yaak-rpc-schema" }
|
||||||
|
|
||||||
# Internal crates - shared
|
# Internal crates - shared
|
||||||
yaak-core = { path = "crates/yaak-core" }
|
yaak-core = { path = "crates/yaak-core" }
|
||||||
yaak = { path = "crates/yaak" }
|
yaak = { path = "crates/yaak" }
|
||||||
|
yaak-commands = { path = "crates/yaak-commands" }
|
||||||
yaak-common = { path = "crates/yaak-common" }
|
yaak-common = { path = "crates/yaak-common" }
|
||||||
yaak-crypto = { path = "crates/yaak-crypto" }
|
yaak-crypto = { path = "crates/yaak-crypto" }
|
||||||
yaak-git = { path = "crates/yaak-git" }
|
yaak-git = { path = "crates/yaak-git" }
|
||||||
yaak-grpc = { path = "crates/yaak-grpc" }
|
yaak-grpc = { path = "crates/yaak-grpc" }
|
||||||
yaak-http = { path = "crates/yaak-http" }
|
yaak-http = { path = "crates/yaak-http" }
|
||||||
|
yaak-lifecycle = { path = "crates/yaak-lifecycle" }
|
||||||
yaak-models = { path = "crates/yaak-models" }
|
yaak-models = { path = "crates/yaak-models" }
|
||||||
yaak-plugins = { path = "crates/yaak-plugins" }
|
yaak-plugins = { path = "crates/yaak-plugins" }
|
||||||
yaak-sse = { path = "crates/yaak-sse" }
|
yaak-sse = { path = "crates/yaak-sse" }
|
||||||
|
|||||||
@@ -44,6 +44,25 @@ After bootstrapping, start the app in development mode:
|
|||||||
npm start
|
npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Run the App in a Browser
|
||||||
|
|
||||||
|
The client can also run as a plain web page, with no Tauri and no local process
|
||||||
|
behind it. Set `YAAK_TARGET=web` and start the frontend on its own:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
YAAK_TARGET=web npm run dev --workspace @yaakapp/yaak-client
|
||||||
|
```
|
||||||
|
|
||||||
|
That flag picks the browser host in `packages/platform/src/web/`, which answers
|
||||||
|
commands from an IndexedDB database the page owns instead of from the Rust
|
||||||
|
engine. Data persists across reloads and is shared between tabs on the same
|
||||||
|
origin. Sending HTTP is not available yet — the Send button reports that and
|
||||||
|
everything else about the request is still saved. `packages/platform/src/web/README.md`
|
||||||
|
lists which commands the browser host implements and which it declines.
|
||||||
|
|
||||||
|
Desktop builds are unaffected: without the flag the platform package installs
|
||||||
|
the Tauri host exactly as before.
|
||||||
|
|
||||||
## SQLite Migrations
|
## SQLite Migrations
|
||||||
|
|
||||||
New migrations can be created from the `src-tauri/` directory:
|
New migrations can be created from the `src-tauri/` directory:
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
# Yaak in a browser, whole: the web client and the server that executes its sends, in one
|
||||||
|
# image serving both from one origin.
|
||||||
|
#
|
||||||
|
# docker run -p 8080:8080 ghcr.io/mountain-loop/yaak-web
|
||||||
|
#
|
||||||
|
# See crates-server/yaak-web/README.md for the knobs.
|
||||||
|
|
||||||
|
FROM node:22-slim AS web
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
git python3 make g++ ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY . .
|
||||||
|
# `npm ci` runs a prepare hook (`vp config`) that shells out to git, and there is no .git in
|
||||||
|
# the build context — it is ignored, and in a worktree it is a pointer file anyway.
|
||||||
|
RUN git init -q && git add -A \
|
||||||
|
&& git -c user.email=build@yaak.app -c user.name=build commit -qm build
|
||||||
|
# Empty means the tab posts sends to its own origin, which is what this image serves. Set it
|
||||||
|
# only to build a bundle for a deployment whose server lives somewhere else.
|
||||||
|
ARG VITE_YAAK_WEB_URL=""
|
||||||
|
ENV VITE_YAAK_WEB_URL=$VITE_YAAK_WEB_URL
|
||||||
|
ENV YAAK_TARGET=web
|
||||||
|
# crates/yaak-wasm's wasm package is committed; rebuilding it needs a clang with a WebAssembly
|
||||||
|
# backend, which this image has no reason to carry.
|
||||||
|
ENV SKIP_WASM_BUILD=1
|
||||||
|
RUN npm ci
|
||||||
|
RUN node_modules/.bin/vp -C apps/yaak-client build
|
||||||
|
|
||||||
|
FROM rust:1-bookworm AS server
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
pkg-config libssl-dev protobuf-compiler && rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY . .
|
||||||
|
RUN cargo build --release -p yaak-web
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates libssl3 && rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY --from=server /app/target/release/yaak-web /usr/local/bin/yaak-web
|
||||||
|
COPY --from=web /app/dist/apps/yaak-client /srv
|
||||||
|
ENV YAAK_WEB_BIND=0.0.0.0:8080
|
||||||
|
EXPOSE 8080
|
||||||
|
USER nobody
|
||||||
|
# Overriding the command (dropping --serve) leaves the stateless send executor:
|
||||||
|
# docker run ghcr.io/mountain-loop/yaak-web yaak-web
|
||||||
|
CMD ["yaak-web", "--serve", "/srv"]
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https://github.com/MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a> <a href="https://github.com/dharsanb"><img src="https://github.com/dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a> <a href="https://github.com/railwayapp"><img src="https://github.com/railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a> <a href="https://github.com/caseyamcl"><img src="https://github.com/caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a> <a href="https://github.com/bytebase"><img src="https://github.com/bytebase.png" width="80px" alt="User avatar: bytebase" /></a> <a href="https://github.com/"><img src="https://raw.githubusercontent.com/JamesIves/github-sponsors-readme-action/dev/.github/assets/placeholder.png" width="80px" alt="User avatar: " /></a> <!-- sponsors-premium -->
|
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https://github.com/MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a> <a href="https://github.com/dharsanb"><img src="https://github.com/dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a> <a href="https://github.com/railwayapp"><img src="https://github.com/railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a> <a href="https://github.com/caseyamcl"><img src="https://github.com/caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a> <a href="https://github.com/bytebase"><img src="https://github.com/bytebase.png" width="80px" alt="User avatar: bytebase" /></a> <a href="https://github.com/"><img src="https://raw.githubusercontent.com/JamesIves/github-sponsors-readme-action/dev/.github/assets/placeholder.png" width="80px" alt="User avatar: " /></a> <!-- sponsors-premium -->
|
||||||
</p>
|
</p>
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https://github.com/seanwash.png" width="50px" alt="User avatar: seanwash" /></a> <a href="https://github.com/jerath"><img src="https://github.com/jerath.png" width="50px" alt="User avatar: jerath" /></a> <a href="https://github.com/itsa-sh"><img src="https://github.com/itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a> <a href="https://github.com/dmmulroy"><img src="https://github.com/dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a> <a href="https://github.com/timcole"><img src="https://github.com/timcole.png" width="50px" alt="User avatar: timcole" /></a> <a href="https://github.com/VLZH"><img src="https://github.com/VLZH.png" width="50px" alt="User avatar: VLZH" /></a> <a href="https://github.com/terasaka2k"><img src="https://github.com/terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a> <a href="https://github.com/andriyor"><img src="https://github.com/andriyor.png" width="50px" alt="User avatar: andriyor" /></a> <a href="https://github.com/majudhu"><img src="https://github.com/majudhu.png" width="50px" alt="User avatar: majudhu" /></a> <a href="https://github.com/axelrindle"><img src="https://github.com/axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a> <a href="https://github.com/jirizverina"><img src="https://github.com/jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a> <a href="https://github.com/chip-well"><img src="https://github.com/chip-well.png" width="50px" alt="User avatar: chip-well" /></a> <a href="https://github.com/GRAYAH"><img src="https://github.com/GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a> <a href="https://github.com/flashblaze"><img src="https://github.com/flashblaze.png" width="50px" alt="User avatar: flashblaze" /></a> <a href="https://github.com/Frostist"><img src="https://github.com/Frostist.png" width="50px" alt="User avatar: Frostist" /></a> <!-- sponsors-base -->
|
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https://github.com/seanwash.png" width="50px" alt="User avatar: seanwash" /></a> <a href="https://github.com/jerath"><img src="https://github.com/jerath.png" width="50px" alt="User avatar: jerath" /></a> <a href="https://github.com/itsa-sh"><img src="https://github.com/itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a> <a href="https://github.com/dmmulroy"><img src="https://github.com/dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a> <a href="https://github.com/timcole"><img src="https://github.com/timcole.png" width="50px" alt="User avatar: timcole" /></a> <a href="https://github.com/VLZH"><img src="https://github.com/VLZH.png" width="50px" alt="User avatar: VLZH" /></a> <a href="https://github.com/terasaka2k"><img src="https://github.com/terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a> <a href="https://github.com/andriyor"><img src="https://github.com/andriyor.png" width="50px" alt="User avatar: andriyor" /></a> <a href="https://github.com/majudhu"><img src="https://github.com/majudhu.png" width="50px" alt="User avatar: majudhu" /></a> <a href="https://github.com/axelrindle"><img src="https://github.com/axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a> <a href="https://github.com/jirizverina"><img src="https://github.com/jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a> <a href="https://github.com/chip-well"><img src="https://github.com/chip-well.png" width="50px" alt="User avatar: chip-well" /></a> <a href="https://github.com/GRAYAH"><img src="https://github.com/GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a> <a href="https://github.com/flashblaze"><img src="https://github.com/flashblaze.png" width="50px" alt="User avatar: flashblaze" /></a> <a href="https://github.com/Frostist"><img src="https://github.com/Frostist.png" width="50px" alt="User avatar: Frostist" /></a> <a href="https://github.com/PurplProto"><img src="https://github.com/PurplProto.png" width="50px" alt="User avatar: PurplProto" /></a> <!-- sponsors-base -->
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||

|

|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||||
|
import { patchModelById } from "@yaakapp-internal/models";
|
||||||
|
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
||||||
|
import { createFastMutation } from "../hooks/useFastMutation";
|
||||||
|
import { wasUpdatedExternally } from "../hooks/useRequestUpdateKey";
|
||||||
|
import { createRequestAndNavigate } from "../lib/createRequestAndNavigate";
|
||||||
|
import { jotaiStore } from "../lib/jotai";
|
||||||
|
import { rpc } from "../lib/rpc";
|
||||||
|
import { showToast } from "../lib/toast";
|
||||||
|
|
||||||
|
export function looksLikeCurl(text: string) {
|
||||||
|
return text.trim().startsWith("curl ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const importCurl = createFastMutation<
|
||||||
|
void,
|
||||||
|
string,
|
||||||
|
{ overwriteRequestId?: string; command: string }
|
||||||
|
>({
|
||||||
|
mutationKey: ["import_curl"],
|
||||||
|
mutationFn: async ({ overwriteRequestId, command }) => {
|
||||||
|
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
||||||
|
const importedRequest: HttpRequest = await rpc("cmd_curl_to_request", {
|
||||||
|
command,
|
||||||
|
workspaceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
let verb: string;
|
||||||
|
if (overwriteRequestId == null) {
|
||||||
|
verb = "Created";
|
||||||
|
await createRequestAndNavigate(importedRequest);
|
||||||
|
} else {
|
||||||
|
verb = "Updated";
|
||||||
|
await patchModelById(importedRequest.model, overwriteRequestId, (r: HttpRequest) => ({
|
||||||
|
...importedRequest,
|
||||||
|
id: r.id,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
workspaceId: r.workspaceId,
|
||||||
|
folderId: r.folderId,
|
||||||
|
name: r.name,
|
||||||
|
sortPriority: r.sortPriority,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setTimeout(() => wasUpdatedExternally(overwriteRequestId), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast({
|
||||||
|
color: "success",
|
||||||
|
message: `${verb} request from Curl`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,26 +1,44 @@
|
|||||||
import type { SettingsTab } from "../components/Settings/Settings";
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
|
import type { SettingsTab, SettingsTabWithSubtab } from "../components/Settings/Settings";
|
||||||
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
||||||
import { createFastMutation } from "../hooks/useFastMutation";
|
import { createFastMutation } from "../hooks/useFastMutation";
|
||||||
|
import { showDialog } from "../lib/dialog";
|
||||||
import { jotaiStore } from "../lib/jotai";
|
import { jotaiStore } from "../lib/jotai";
|
||||||
import { router } from "../lib/router";
|
import { router } from "../lib/router";
|
||||||
import { invokeCmd } from "../lib/tauri";
|
import { rpc } from "../lib/rpc";
|
||||||
|
|
||||||
// Allow tab with optional subtab (e.g., "plugins:installed")
|
export const openSettings = createFastMutation<void, string, SettingsTabWithSubtab | null>({
|
||||||
type SettingsTabWithSubtab = SettingsTab | `${SettingsTab}:${string}` | null;
|
|
||||||
|
|
||||||
export const openSettings = createFastMutation<void, string, SettingsTabWithSubtab>({
|
|
||||||
mutationKey: ["open_settings"],
|
mutationKey: ["open_settings"],
|
||||||
mutationFn: async (tab) => {
|
mutationFn: async (tab) => {
|
||||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
||||||
if (workspaceId == null) return;
|
if (workspaceId == null) return;
|
||||||
|
|
||||||
|
// Settings is its own window where the host has windows to give. Where it
|
||||||
|
// doesn't — a browser tab — it's a dialog like any other, so opening it
|
||||||
|
// doesn't take you away from the request you were working on.
|
||||||
|
if (!platform.capabilities.multiWindow) {
|
||||||
|
// Imported here so Settings stays out of the startup bundle, the way the
|
||||||
|
// route that renders it on desktop already keeps it
|
||||||
|
const { default: Settings } = await import("../components/Settings/Settings");
|
||||||
|
showDialog({
|
||||||
|
id: "settings",
|
||||||
|
size: "md",
|
||||||
|
className: "h-[calc(100vh-5rem)] max-h-150! overflow-hidden",
|
||||||
|
noPadding: true,
|
||||||
|
noScroll: true,
|
||||||
|
// Keyed so opening a specific tab while the dialog is already up moves to it
|
||||||
|
render: ({ hide }) => <Settings key={tab ?? "general"} tab={tab} hide={hide} />,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const location = router.buildLocation({
|
const location = router.buildLocation({
|
||||||
to: "/workspaces/$workspaceId/settings",
|
to: "/workspaces/$workspaceId/settings",
|
||||||
params: { workspaceId },
|
params: { workspaceId },
|
||||||
search: { tab: (tab ?? undefined) as SettingsTab | undefined },
|
search: { tab: (tab ?? undefined) as SettingsTab | undefined },
|
||||||
});
|
});
|
||||||
|
|
||||||
await invokeCmd("cmd_new_child_window", {
|
await rpc("cmd_new_child_window", {
|
||||||
url: location.href,
|
url: location.href,
|
||||||
label: "settings",
|
label: "settings",
|
||||||
title: "Yaak Settings",
|
title: "Yaak Settings",
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
import { createFastMutation } from "../hooks/useFastMutation";
|
import { createFastMutation } from "../hooks/useFastMutation";
|
||||||
import { getRecentCookieJars } from "../hooks/useRecentCookieJars";
|
import { getRecentCookieJars } from "../hooks/useRecentCookieJars";
|
||||||
import { getRecentEnvironments } from "../hooks/useRecentEnvironments";
|
import { getRecentEnvironments } from "../hooks/useRecentEnvironments";
|
||||||
import { getRecentRequests } from "../hooks/useRecentRequests";
|
import { getRecentRequests } from "../hooks/useRecentRequests";
|
||||||
import { router } from "../lib/router";
|
import { router } from "../lib/router";
|
||||||
import { invokeCmd } from "../lib/tauri";
|
import { rpc } from "../lib/rpc";
|
||||||
|
|
||||||
export const switchWorkspace = createFastMutation<
|
export const switchWorkspace = createFastMutation<
|
||||||
void,
|
void,
|
||||||
@@ -24,13 +25,15 @@ export const switchWorkspace = createFastMutation<
|
|||||||
request_id: requestId,
|
request_id: requestId,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (inNewWindow) {
|
// A host without windows opens the workspace here instead. Refusing would
|
||||||
|
// strand the user on the workspace they were trying to leave.
|
||||||
|
if (inNewWindow && platform.capabilities.multiWindow) {
|
||||||
const location = router.buildLocation({
|
const location = router.buildLocation({
|
||||||
to: "/workspaces/$workspaceId",
|
to: "/workspaces/$workspaceId",
|
||||||
params: { workspaceId },
|
params: { workspaceId },
|
||||||
search,
|
search,
|
||||||
});
|
});
|
||||||
await invokeCmd<void>("cmd_new_main_window", { url: location.href });
|
await rpc<void>("cmd_new_main_window", { url: location.href });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { open } from "@tauri-apps/plugin-dialog";
|
|
||||||
import { gitClone } from "@yaakapp-internal/git";
|
import { gitClone } from "@yaakapp-internal/git";
|
||||||
import { Banner, VStack } from "@yaakapp-internal/ui";
|
import { Banner, VStack } from "@yaakapp-internal/ui";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -11,6 +10,7 @@ import { Checkbox } from "./core/Checkbox";
|
|||||||
import { IconButton } from "./core/IconButton";
|
import { IconButton } from "./core/IconButton";
|
||||||
import { PlainInput } from "./core/PlainInput";
|
import { PlainInput } from "./core/PlainInput";
|
||||||
import { promptCredentials } from "./git/credentials";
|
import { promptCredentials } from "./git/credentials";
|
||||||
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
hide: () => void;
|
hide: () => void;
|
||||||
@@ -38,7 +38,7 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
|
|||||||
hasSubdirectory && subdirectory ? `${directory}${sep}${subdirectory}` : directory;
|
hasSubdirectory && subdirectory ? `${directory}${sep}${subdirectory}` : directory;
|
||||||
|
|
||||||
const handleSelectDirectory = async () => {
|
const handleSelectDirectory = async () => {
|
||||||
const dir = await open({
|
const dir = await platform.dialog.open({
|
||||||
title: "Select Directory",
|
title: "Select Directory",
|
||||||
directory: true,
|
directory: true,
|
||||||
multiple: false,
|
multiple: false,
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { checkLicense } from "@yaakapp-internal/license";
|
||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
|
||||||
import type { LicenseCheckStatus } from "@yaakapp-internal/license";
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useKeyValue } from "../hooks/useKeyValue";
|
import { useKeyValue } from "../hooks/useKeyValue";
|
||||||
import { appInfo } from "../lib/appInfo";
|
import { appInfo } from "../lib/appInfo";
|
||||||
import { pricingUrl } from "../lib/pricingUrl";
|
import { pricingUrl } from "../lib/pricingUrl";
|
||||||
import { DismissibleBanner } from "./core/DismissibleBanner";
|
import { DismissibleBanner } from "./core/DismissibleBanner";
|
||||||
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
|
|
||||||
const COMMERCIAL_USE_SNOOZE_MS = 7 * 24 * 60 * 60 * 1000;
|
const COMMERCIAL_USE_SNOOZE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
const COMMERCIAL_USE_BANNER_MESSAGE =
|
const COMMERCIAL_USE_BANNER_MESSAGE =
|
||||||
@@ -95,7 +94,7 @@ async function shouldShowCommercialUsePrompt(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const license = await invoke<LicenseCheckStatus>("plugin:yaak-license|check");
|
const license = await checkLicense();
|
||||||
return license.status === "personal_use";
|
return license.status === "personal_use";
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log("Failed to check license before commercial-use prompt", err);
|
console.log("Failed to check license before commercial-use prompt", err);
|
||||||
@@ -104,7 +103,7 @@ async function shouldShowCommercialUsePrompt(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openCommercialUsePricing(source: string): Promise<void> {
|
async function openCommercialUsePricing(source: string): Promise<void> {
|
||||||
await openUrl(pricingUrl(`app.commercial-use.${source}`)).catch(console.error);
|
await platform.openUrl(pricingUrl(`app.commercial-use.${source}`)).catch(console.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSnoozed(value: string | null, ms: number): boolean {
|
function isSnoozed(value: string | null, ms: number): boolean {
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { contextMenusAtom, hideContextMenu } from "../lib/contextMenu";
|
||||||
|
import { ContextMenu } from "./core/Dropdown";
|
||||||
|
import { ErrorBoundary } from "./ErrorBoundary";
|
||||||
|
|
||||||
|
/** Renders menus opened by {@link showContextMenu}, the way {@link Dialogs} renders dialogs. */
|
||||||
|
export function ContextMenus() {
|
||||||
|
const menus = useAtomValue(contextMenusAtom);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{menus.map(({ id, items, triggerPosition, triggerRect, triggerEl }) => (
|
||||||
|
<ErrorBoundary key={id} name={`ContextMenu ${id}`}>
|
||||||
|
<ContextMenu
|
||||||
|
items={items}
|
||||||
|
triggerPosition={triggerPosition}
|
||||||
|
triggerRect={triggerRect}
|
||||||
|
// The trigger isn't a React component here, so it arrives as an element and gets
|
||||||
|
// wrapped to look like the ref the menu expects
|
||||||
|
triggerRef={{ current: triggerEl ?? null }}
|
||||||
|
onClose={() => hideContextMenu(id)}
|
||||||
|
/>
|
||||||
|
</ErrorBoundary>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { VStack } from "@yaakapp-internal/ui";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { router } from "../lib/router";
|
import { router } from "../lib/router";
|
||||||
import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
|
import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
|
||||||
import { invokeCmd } from "../lib/tauri";
|
import { rpc } from "../lib/rpc";
|
||||||
import { showErrorToast } from "../lib/toast";
|
import { showErrorToast } from "../lib/toast";
|
||||||
import { Button } from "./core/Button";
|
import { Button } from "./core/Button";
|
||||||
import { Checkbox } from "./core/Checkbox";
|
import { Checkbox } from "./core/Checkbox";
|
||||||
@@ -39,7 +39,7 @@ export function CreateWorkspaceDialog({ hide }: Props) {
|
|||||||
|
|
||||||
// Do getWorkspaceMeta instead of naively creating one because it might have
|
// Do getWorkspaceMeta instead of naively creating one because it might have
|
||||||
// been created already when the store refreshes the workspace meta after
|
// been created already when the store refreshes the workspace meta after
|
||||||
const workspaceMeta = await invokeCmd<WorkspaceMeta>("cmd_get_workspace_meta", {
|
const workspaceMeta = await rpc<WorkspaceMeta>("cmd_get_workspace_meta", {
|
||||||
workspaceId,
|
workspaceId,
|
||||||
});
|
});
|
||||||
await updateModel({
|
await updateModel({
|
||||||
|
|||||||
@@ -67,7 +67,14 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo
|
|||||||
)}
|
)}
|
||||||
// If no environments, the button simply opens the dialog.
|
// If no environments, the button simply opens the dialog.
|
||||||
// NOTE: We don't create a new button because we want to reuse the hotkey from the menu items
|
// NOTE: We don't create a new button because we want to reuse the hotkey from the menu items
|
||||||
onClick={subEnvironments.length === 0 ? () => editEnvironment(null) : undefined}
|
onClick={
|
||||||
|
subEnvironments.length === 0
|
||||||
|
? (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
editEnvironment(null);
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
{...buttonProps}
|
{...buttonProps}
|
||||||
>
|
>
|
||||||
<EnvironmentColorIndicator environment={activeEnvironment ?? null} />
|
<EnvironmentColorIndicator environment={activeEnvironment ?? null} />
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { save } from "@tauri-apps/plugin-dialog";
|
|
||||||
import type { Workspace } from "@yaakapp-internal/models";
|
import type { Workspace } from "@yaakapp-internal/models";
|
||||||
import { workspacesAtom } from "@yaakapp-internal/models";
|
import { workspacesAtom } from "@yaakapp-internal/models";
|
||||||
import { HStack, VStack } from "@yaakapp-internal/ui";
|
import { HStack, VStack } from "@yaakapp-internal/ui";
|
||||||
@@ -7,12 +6,13 @@ import { useCallback, useMemo, useState } from "react";
|
|||||||
import slugify from "slugify";
|
import slugify from "slugify";
|
||||||
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
|
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
|
||||||
import { pluralizeCount } from "../lib/pluralize";
|
import { pluralizeCount } from "../lib/pluralize";
|
||||||
import { invokeCmd } from "../lib/tauri";
|
import { rpc } from "../lib/rpc";
|
||||||
import { CommercialUseBanner } from "./CommercialUseBanner";
|
import { CommercialUseBanner } from "./CommercialUseBanner";
|
||||||
import { Button } from "./core/Button";
|
import { Button } from "./core/Button";
|
||||||
import { Checkbox } from "./core/Checkbox";
|
import { Checkbox } from "./core/Checkbox";
|
||||||
import { DetailsBanner } from "./core/DetailsBanner";
|
import { DetailsBanner } from "./core/DetailsBanner";
|
||||||
import { Link } from "./core/Link";
|
import { Link } from "./core/Link";
|
||||||
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onHide: () => void;
|
onHide: () => void;
|
||||||
@@ -65,7 +65,7 @@ function ExportDataDialogContent({
|
|||||||
const ids = Object.keys(selectedWorkspaces).filter((k) => selectedWorkspaces[k]);
|
const ids = Object.keys(selectedWorkspaces).filter((k) => selectedWorkspaces[k]);
|
||||||
const workspace = ids.length === 1 ? workspaces.find((w) => w.id === ids[0]) : undefined;
|
const workspace = ids.length === 1 ? workspaces.find((w) => w.id === ids[0]) : undefined;
|
||||||
const slug = workspace ? slugify(workspace.name, { lower: true }) : "workspaces";
|
const slug = workspace ? slugify(workspace.name, { lower: true }) : "workspaces";
|
||||||
const exportPath = await save({
|
const exportPath = await platform.dialog.save({
|
||||||
title: "Export Data",
|
title: "Export Data",
|
||||||
defaultPath: `yaak.${slug}.json`,
|
defaultPath: `yaak.${slug}.json`,
|
||||||
});
|
});
|
||||||
@@ -73,7 +73,7 @@ function ExportDataDialogContent({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await invokeCmd("cmd_export_data", {
|
await rpc("cmd_export_data", {
|
||||||
workspaceIds: ids,
|
workspaceIds: ids,
|
||||||
exportPath,
|
exportPath,
|
||||||
includePrivateEnvironments: includePrivateEnvironments,
|
includePrivateEnvironments: includePrivateEnvironments,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { HStack, VStack } from "@yaakapp-internal/ui";
|
|||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import type { FeedbackFeature } from "../lib/featureFeedbackConstants";
|
import type { FeedbackFeature } from "../lib/featureFeedbackConstants";
|
||||||
import { FEEDBACK_FEATURES } from "../lib/featureFeedbackConstants";
|
import { FEEDBACK_FEATURES } from "../lib/featureFeedbackConstants";
|
||||||
import { invokeCmd } from "../lib/tauri";
|
import { rpc } from "../lib/rpc";
|
||||||
import { hideToastById, showToast } from "../lib/toast";
|
import { hideToastById, showToast } from "../lib/toast";
|
||||||
import { Button } from "./core/Button";
|
import { Button } from "./core/Button";
|
||||||
import { Input } from "./core/Input";
|
import { Input } from "./core/Input";
|
||||||
@@ -31,7 +31,7 @@ export function FeedbackToast({ feature, onDone }: Props) {
|
|||||||
onDone();
|
onDone();
|
||||||
|
|
||||||
// Fire-and-forget; failures are intentionally ignored
|
// Fire-and-forget; failures are intentionally ignored
|
||||||
invokeCmd("cmd_send_feedback", { feature, text: trimmedText }).catch(() => {});
|
rpc("cmd_send_feedback", { feature, text: trimmedText }).catch(() => {});
|
||||||
showToast({
|
showToast({
|
||||||
id: `feature-feedback-${feature}`,
|
id: `feature-feedback-${feature}`,
|
||||||
timeout: 3000,
|
timeout: 3000,
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { open } from "@tauri-apps/plugin-dialog";
|
|
||||||
import type { GrpcRequest } from "@yaakapp-internal/models";
|
import type { GrpcRequest } from "@yaakapp-internal/models";
|
||||||
import { Banner, HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
import { Banner, HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||||
import { useActiveRequest } from "../hooks/useActiveRequest";
|
import { useActiveRequest } from "../hooks/useActiveRequest";
|
||||||
@@ -8,6 +7,7 @@ import { pluralizeCount } from "../lib/pluralize";
|
|||||||
import { Button } from "./core/Button";
|
import { Button } from "./core/Button";
|
||||||
import { IconButton } from "./core/IconButton";
|
import { IconButton } from "./core/IconButton";
|
||||||
import { Link } from "./core/Link";
|
import { Link } from "./core/Link";
|
||||||
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onDone: () => void;
|
onDone: () => void;
|
||||||
@@ -45,7 +45,7 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
|
|||||||
color="primary"
|
color="primary"
|
||||||
variant="border"
|
variant="border"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const selected = await open({
|
const selected = await platform.dialog.open({
|
||||||
title: "Select Proto Files",
|
title: "Select Proto Files",
|
||||||
multiple: true,
|
multiple: true,
|
||||||
filters: [{ name: "Proto Files", extensions: ["proto"] }],
|
filters: [{ name: "Proto Files", extensions: ["proto"] }],
|
||||||
@@ -63,7 +63,7 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
|
|||||||
variant="border"
|
variant="border"
|
||||||
color="primary"
|
color="primary"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const selected = await open({
|
const selected = await platform.dialog.open({
|
||||||
title: "Select Proto Directory",
|
title: "Select Proto Directory",
|
||||||
directory: true,
|
directory: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { type GrpcRequest, type HttpRequestHeader, patchModel } from "@yaakapp-internal/models";
|
import {
|
||||||
|
type GrpcRequest,
|
||||||
|
type HttpRequestHeader,
|
||||||
|
patchModel,
|
||||||
|
patchModelDebounced,
|
||||||
|
} from "@yaakapp-internal/models";
|
||||||
import { HStack, Icon, useContainerSize, VStack } from "@yaakapp-internal/ui";
|
import { HStack, Icon, useContainerSize, VStack } from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
@@ -75,11 +80,13 @@ export function GrpcRequestPane({
|
|||||||
const { width: paneWidth } = useContainerSize(urlContainerEl);
|
const { width: paneWidth } = useContainerSize(urlContainerEl);
|
||||||
|
|
||||||
const handleChangeUrl = useCallback(
|
const handleChangeUrl = useCallback(
|
||||||
(url: string) => patchModel(activeRequest, { url }),
|
(url: string) => patchModelDebounced(activeRequest, { url }),
|
||||||
[activeRequest],
|
[activeRequest],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleChangeMessage = useCallback(
|
const handleChangeMessage = useCallback(
|
||||||
|
// Not debounced: handleSend reads message from the store, so a pending
|
||||||
|
// debounced patch would send stale text
|
||||||
(message: string) => patchModel(activeRequest, { message }),
|
(message: string) => patchModel(activeRequest, { message }),
|
||||||
[activeRequest],
|
[activeRequest],
|
||||||
);
|
);
|
||||||
@@ -146,12 +153,12 @@ export function GrpcRequestPane({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleMetadataChange = useCallback(
|
const handleMetadataChange = useCallback(
|
||||||
(metadata: HttpRequestHeader[]) => patchModel(activeRequest, { metadata }),
|
(metadata: HttpRequestHeader[]) => patchModelDebounced(activeRequest, { metadata }),
|
||||||
[activeRequest],
|
[activeRequest],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDescriptionChange = useCallback(
|
const handleDescriptionChange = useCallback(
|
||||||
(description: string) => patchModel(activeRequest, { description }),
|
(description: string) => patchModelDebounced(activeRequest, { description }),
|
||||||
[activeRequest],
|
[activeRequest],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -299,7 +306,7 @@ export function GrpcRequestPane({
|
|||||||
className="font-sans text-xl! px-0!"
|
className="font-sans text-xl! px-0!"
|
||||||
containerClassName="border-0"
|
containerClassName="border-0"
|
||||||
placeholder={resolvedModelName(activeRequest)}
|
placeholder={resolvedModelName(activeRequest)}
|
||||||
onChange={(name) => patchModel(activeRequest, { name })}
|
onChange={(name) => patchModelDebounced(activeRequest, { name })}
|
||||||
/>
|
/>
|
||||||
<MarkdownEditor
|
<MarkdownEditor
|
||||||
name="request-description"
|
name="request-description"
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import type { HttpRequestHeader } from "@yaakapp-internal/models";
|
import type { HttpRequestHeader } from "@yaakapp-internal/models";
|
||||||
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
|
|
||||||
import { HStack } from "@yaakapp-internal/ui";
|
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 { charsets } from "../lib/data/charsets";
|
||||||
import { connections } from "../lib/data/connections";
|
import { connections } from "../lib/data/connections";
|
||||||
import { encodings } from "../lib/data/encodings";
|
import { encodings } from "../lib/data/encodings";
|
||||||
|
import type { HeaderValuePreset } from "../lib/data/headerValuePresets";
|
||||||
import { headerNames } from "../lib/data/headerNames";
|
import { headerNames } from "../lib/data/headerNames";
|
||||||
import { mimeTypes } from "../lib/data/mimetypes";
|
import { mimeTypes } from "../lib/data/mimetypes";
|
||||||
|
import { userAgents } from "../lib/data/userAgents";
|
||||||
import { CountBadge } from "./core/CountBadge";
|
import { CountBadge } from "./core/CountBadge";
|
||||||
import { DetailsBanner } from "./core/DetailsBanner";
|
import { DetailsBanner } from "./core/DetailsBanner";
|
||||||
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
import type { GenericCompletion, GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
||||||
import type { InputProps } from "./core/Input";
|
import type { InputProps } from "./core/Input";
|
||||||
import type { Pair, PairEditorProps } from "./core/PairEditor";
|
import type { Pair, PairEditorProps } from "./core/PairEditor";
|
||||||
import { PairEditorRow } from "./core/PairEditor";
|
import { PairEditorRow } from "./core/PairEditor";
|
||||||
@@ -105,12 +108,21 @@ export function HeadersEditor({
|
|||||||
|
|
||||||
const MIN_MATCH = 3;
|
const MIN_MATCH = 3;
|
||||||
|
|
||||||
const headerOptionsMap: Record<string, string[]> = {
|
const headerOptionsMap: Record<string, HeaderValuePreset[]> = {
|
||||||
"content-type": mimeTypes,
|
"content-type": mimeTypes,
|
||||||
accept: ["*/*", ...mimeTypes],
|
accept: ["*/*", ...mimeTypes],
|
||||||
"accept-encoding": encodings,
|
"accept-encoding": encodings,
|
||||||
|
"content-encoding": encodings,
|
||||||
connection: connections,
|
connection: connections,
|
||||||
"accept-charset": charsets,
|
"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 valueType = (pair: Pair): InputProps["type"] => {
|
||||||
@@ -132,12 +144,22 @@ const valueType = (pair: Pair): InputProps["type"] => {
|
|||||||
|
|
||||||
const valueAutocomplete = (headerName: string): GenericCompletionConfig | undefined => {
|
const valueAutocomplete = (headerName: string): GenericCompletionConfig | undefined => {
|
||||||
const name = headerName.toLowerCase().trim();
|
const name = headerName.toLowerCase().trim();
|
||||||
const options: GenericCompletionOption[] =
|
const options: GenericCompletion[] =
|
||||||
headerOptionsMap[name]?.map((o) => ({
|
headerOptionsMap[name]?.map((o) =>
|
||||||
label: o,
|
typeof o === "string"
|
||||||
type: "constant",
|
? {
|
||||||
boost: 1, // Put above other completions
|
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 };
|
return { minMatch: MIN_MATCH, options };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,23 @@
|
|||||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||||
import { patchModel } from "@yaakapp-internal/models";
|
import { getModel, patchModel, patchModelDebounced } from "@yaakapp-internal/models";
|
||||||
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
|
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { atom, useAtomValue } from "jotai";
|
import { atom, useAtomValue } from "jotai";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import { lazy, Suspense, useCallback, useMemo, useRef, useState } from "react";
|
import { lazy, Suspense, useCallback, useMemo, useRef, useState } from "react";
|
||||||
import { activeRequestIdAtom } from "../hooks/useActiveRequestId";
|
import { importCurl, looksLikeCurl } from "../commands/importCurl";
|
||||||
import { allRequestsAtom } from "../hooks/useAllRequests";
|
import { allRequestUrlsAtom } from "../hooks/useAllRequests";
|
||||||
import { useAuthTab } from "../hooks/useAuthTab";
|
import { useAuthTab } from "../hooks/useAuthTab";
|
||||||
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
|
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
|
||||||
import { useHeadersTab } from "../hooks/useHeadersTab";
|
import { useHeadersTab } from "../hooks/useHeadersTab";
|
||||||
import { useImportCurl } from "../hooks/useImportCurl";
|
|
||||||
import { useInheritedHeaders } from "../hooks/useInheritedHeaders";
|
import { useInheritedHeaders } from "../hooks/useInheritedHeaders";
|
||||||
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
|
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
|
||||||
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
|
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
|
||||||
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
|
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
|
||||||
import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
|
import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
|
||||||
import { deepEqualAtom } from "../lib/atoms";
|
|
||||||
import { languageFromContentType } from "../lib/contentType";
|
import { languageFromContentType } from "../lib/contentType";
|
||||||
import { generateId } from "../lib/generateId";
|
import { generateId } from "../lib/generateId";
|
||||||
import { extractPathPlaceholders } from "../lib/pathPlaceholders";
|
import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
|
||||||
import { convertRequestBody } from "../lib/requestBodyConversion";
|
import { convertRequestBody } from "../lib/requestBodyConversion";
|
||||||
import {
|
import {
|
||||||
BODY_TYPE_BINARY,
|
BODY_TYPE_BINARY,
|
||||||
@@ -39,9 +37,9 @@ import { BinaryFileEditor } from "./BinaryFileEditor";
|
|||||||
import { ConfirmLargeRequestBody } from "./ConfirmLargeRequestBody";
|
import { ConfirmLargeRequestBody } from "./ConfirmLargeRequestBody";
|
||||||
import { CountBadge } from "./core/CountBadge";
|
import { CountBadge } from "./core/CountBadge";
|
||||||
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
||||||
|
import { getUrlCompletionConfig } from "./core/Editor/url/completion";
|
||||||
import { Editor } from "./core/Editor/LazyEditor";
|
import { Editor } from "./core/Editor/LazyEditor";
|
||||||
import { InlineCode } from "@yaakapp-internal/ui";
|
import { InlineCode } from "@yaakapp-internal/ui";
|
||||||
import type { Pair } from "./core/PairEditor";
|
|
||||||
import { PlainInput } from "./core/PlainInput";
|
import { PlainInput } from "./core/PlainInput";
|
||||||
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
|
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
|
||||||
import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs";
|
import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs";
|
||||||
@@ -76,15 +74,12 @@ const TAB_SETTINGS = "settings";
|
|||||||
const TAB_DESCRIPTION = "description";
|
const TAB_DESCRIPTION = "description";
|
||||||
const TABS_STORAGE_KEY = "http_request_tabs";
|
const TABS_STORAGE_KEY = "http_request_tabs";
|
||||||
|
|
||||||
const nonActiveRequestUrlsAtom = atom((get) => {
|
// Derived from the identity-stable URL list so this only recomputes when a URL
|
||||||
const activeRequestId = get(activeRequestIdAtom);
|
// actually changes. The active request's own URL is included, but exact matches
|
||||||
const requests = get(allRequestsAtom);
|
// are filtered out at completion time by genericCompletion.
|
||||||
return requests
|
const requestUrlOptionsAtom = atom((get): GenericCompletionOption[] =>
|
||||||
.filter((r) => r.id !== activeRequestId)
|
get(allRequestUrlsAtom).map((url) => ({ type: "constant", label: url })),
|
||||||
.map((r): GenericCompletionOption => ({ type: "constant", label: r.url }));
|
);
|
||||||
});
|
|
||||||
|
|
||||||
const memoNotActiveRequestUrlsAtom = deepEqualAtom(nonActiveRequestUrlsAtom);
|
|
||||||
|
|
||||||
export function HttpRequestPane({ style, fullHeight, className, activeRequest }: Props) {
|
export function HttpRequestPane({ style, fullHeight, className, activeRequest }: Props) {
|
||||||
const activeRequestId = activeRequest.id;
|
const activeRequestId = activeRequest.id;
|
||||||
@@ -132,20 +127,33 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
|||||||
[activeRequest],
|
[activeRequest],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { urlParameterPairs, urlParametersKey } = useMemo(() => {
|
// Renaming a path placeholder has to rewrite the URL and rename the parameter together, or the
|
||||||
const placeholderNames = extractPathPlaceholders(activeRequest.url);
|
// value detaches from the placeholder.
|
||||||
const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value);
|
// NOTE: Reads the request fresh rather than closing over `activeRequest`. The row that calls this
|
||||||
const items: Pair[] = [...nonEmptyParameters];
|
// holds onto it until the URL's placeholders change, so a captured request would go stale and
|
||||||
for (const name of placeholderNames) {
|
// patch its parameter list back over newer edits.
|
||||||
const item = items.find((p) => p.name === name);
|
const handleRenamePathPlaceholder = useCallback(
|
||||||
if (item) {
|
(oldName: string, newName: string) => {
|
||||||
item.readOnlyName = true;
|
const request = getModel("http_request", activeRequestId);
|
||||||
} else {
|
if (request == null) return false;
|
||||||
items.push({ name, value: "", enabled: true, readOnlyName: true, id: generateId() });
|
|
||||||
}
|
const patch = renamePathPlaceholder(request, oldName, newName);
|
||||||
}
|
if (patch == null) return false; // Unusable name, so the editor reverts the field
|
||||||
return { urlParameterPairs: items, urlParametersKey: placeholderNames.join(",") };
|
void patchModel(request, patch);
|
||||||
}, [activeRequest.url, activeRequest.urlParameters]);
|
return true;
|
||||||
|
},
|
||||||
|
[activeRequestId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { urlParameterPairs, urlParametersKey } = useMemo(
|
||||||
|
() =>
|
||||||
|
derivePathPlaceholderPairs(
|
||||||
|
activeRequest.url,
|
||||||
|
activeRequest.urlParameters,
|
||||||
|
handleRenamePathPlaceholder,
|
||||||
|
),
|
||||||
|
[activeRequest.url, activeRequest.urlParameters, handleRenamePathPlaceholder],
|
||||||
|
);
|
||||||
|
|
||||||
let numParams = 0;
|
let numParams = 0;
|
||||||
if (
|
if (
|
||||||
@@ -270,38 +278,28 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
|||||||
const { activeResponse } = usePinnedHttpResponse(activeRequestId);
|
const { activeResponse } = usePinnedHttpResponse(activeRequestId);
|
||||||
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
|
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
|
||||||
const updateKey = useRequestUpdateKey(activeRequestId);
|
const updateKey = useRequestUpdateKey(activeRequestId);
|
||||||
const { mutate: importCurl } = useImportCurl();
|
|
||||||
|
|
||||||
const handleBodyChange = useCallback(
|
const handleBodyChange = useCallback(
|
||||||
(body: HttpRequest["body"]) => patchModel(activeRequest, { body }),
|
(body: HttpRequest["body"]) => patchModelDebounced(activeRequest, { body }),
|
||||||
[activeRequest],
|
[activeRequest],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleBodyTextChange = useCallback(
|
const handleBodyTextChange = useCallback(
|
||||||
(text: string) => patchModel(activeRequest, { body: { ...activeRequest.body, text } }),
|
(text: string) => patchModelDebounced(activeRequest, { body: { ...activeRequest.body, text } }),
|
||||||
[activeRequest],
|
[activeRequest],
|
||||||
);
|
);
|
||||||
|
|
||||||
const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom);
|
const autocompleteUrls = useAtomValue(requestUrlOptionsAtom);
|
||||||
|
|
||||||
const autocomplete: GenericCompletionConfig = useMemo(
|
const autocomplete: GenericCompletionConfig = useMemo(
|
||||||
() => ({
|
() => getUrlCompletionConfig(autocompleteUrls),
|
||||||
minMatch: 3,
|
|
||||||
options:
|
|
||||||
autocompleteUrls.length > 0
|
|
||||||
? autocompleteUrls
|
|
||||||
: [
|
|
||||||
{ label: "http://", type: "constant" },
|
|
||||||
{ label: "https://", type: "constant" },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
[autocompleteUrls],
|
[autocompleteUrls],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePaste = useCallback(
|
const handlePaste = useCallback(
|
||||||
async (e: ClipboardEvent, text: string) => {
|
async (e: ClipboardEvent, text: string) => {
|
||||||
if (text.startsWith("curl ")) {
|
if (looksLikeCurl(text)) {
|
||||||
importCurl({ overwriteRequestId: activeRequestId, command: text });
|
importCurl.mutate({ overwriteRequestId: activeRequestId, command: text });
|
||||||
} else {
|
} else {
|
||||||
const patch = prepareImportQuerystring(text);
|
const patch = prepareImportQuerystring(text);
|
||||||
if (patch != null) {
|
if (patch != null) {
|
||||||
@@ -323,7 +321,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[activeRequest, activeRequestId, forceParamsRefresh, forceUrlRefresh, importCurl],
|
[activeRequest, activeRequestId, forceParamsRefresh, forceUrlRefresh],
|
||||||
);
|
);
|
||||||
const handleSend = useCallback(
|
const handleSend = useCallback(
|
||||||
() => sendRequest(activeRequest.id ?? null),
|
() => sendRequest(activeRequest.id ?? null),
|
||||||
@@ -331,7 +329,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleUrlChange = useCallback(
|
const handleUrlChange = useCallback(
|
||||||
(url: string) => patchModel(activeRequest, { url }),
|
(url: string) => patchModelDebounced(activeRequest, { url }),
|
||||||
[activeRequest],
|
[activeRequest],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -377,7 +375,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
|||||||
forceUpdateKey={`${forceUpdateHeaderEditorKey}::${forceUpdateKey}`}
|
forceUpdateKey={`${forceUpdateHeaderEditorKey}::${forceUpdateKey}`}
|
||||||
headers={activeRequest.headers}
|
headers={activeRequest.headers}
|
||||||
stateKey={`headers.${activeRequest.id}`}
|
stateKey={`headers.${activeRequest.id}`}
|
||||||
onChange={(headers) => patchModel(activeRequest, { headers })}
|
onChange={(headers) => patchModelDebounced(activeRequest, { headers })}
|
||||||
/>
|
/>
|
||||||
</TabContent>
|
</TabContent>
|
||||||
<TabContent value={TAB_PARAMS}>
|
<TabContent value={TAB_PARAMS}>
|
||||||
@@ -385,7 +383,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
|||||||
stateKey={`params.${activeRequest.id}`}
|
stateKey={`params.${activeRequest.id}`}
|
||||||
forceUpdateKey={forceUpdateKey + urlParametersKey}
|
forceUpdateKey={forceUpdateKey + urlParametersKey}
|
||||||
pairs={urlParameterPairs}
|
pairs={urlParameterPairs}
|
||||||
onChange={(urlParameters) => patchModel(activeRequest, { urlParameters })}
|
onChange={(urlParameters) => patchModelDebounced(activeRequest, { urlParameters })}
|
||||||
/>
|
/>
|
||||||
</TabContent>
|
</TabContent>
|
||||||
<TabContent value={TAB_SETTINGS}>
|
<TabContent value={TAB_SETTINGS}>
|
||||||
@@ -437,7 +435,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
|||||||
requestId={activeRequest.id}
|
requestId={activeRequest.id}
|
||||||
contentType={contentType}
|
contentType={contentType}
|
||||||
body={activeRequest.body}
|
body={activeRequest.body}
|
||||||
onChange={(body) => patchModel(activeRequest, { body })}
|
onChange={(body) => patchModelDebounced(activeRequest, { body })}
|
||||||
onChangeContentType={handleContentTypeChange}
|
onChangeContentType={handleContentTypeChange}
|
||||||
/>
|
/>
|
||||||
) : typeof activeRequest.bodyType === "string" ? (
|
) : typeof activeRequest.bodyType === "string" ? (
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useCopyHttpResponse } from "../hooks/useCopyHttpResponse";
|
|||||||
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
|
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
|
||||||
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
|
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
|
||||||
import { useResponseBodyBytes, useResponseBodyText } from "../hooks/useResponseBodyText";
|
import { useResponseBodyBytes, useResponseBodyText } from "../hooks/useResponseBodyText";
|
||||||
|
import { useResponseBodyUrl } from "../hooks/useResponseBodyUrl";
|
||||||
import { useResponseViewMode } from "../hooks/useResponseViewMode";
|
import { useResponseViewMode } from "../hooks/useResponseViewMode";
|
||||||
import { useSaveResponse } from "../hooks/useSaveResponse";
|
import { useSaveResponse } from "../hooks/useSaveResponse";
|
||||||
import { useTimelineViewMode } from "../hooks/useTimelineViewMode";
|
import { useTimelineViewMode } from "../hooks/useTimelineViewMode";
|
||||||
@@ -409,14 +410,13 @@ function EnsureCompleteResponse({
|
|||||||
Component,
|
Component,
|
||||||
}: {
|
}: {
|
||||||
response: HttpResponse;
|
response: HttpResponse;
|
||||||
Component: ComponentType<{ bodyPath: string }>;
|
Component: ComponentType<{ bodyUrl: string }>;
|
||||||
}) {
|
}) {
|
||||||
if (response.bodyPath === null) {
|
// Wait until the response has been fully-downloaded before asking for it
|
||||||
return <div>Empty response body</div>;
|
const complete = response.state === "closed";
|
||||||
}
|
const bodyUrl = useResponseBodyUrl(complete ? response : null);
|
||||||
|
|
||||||
// Wait until the response has been fully-downloaded
|
if (!complete || bodyUrl.isPending) {
|
||||||
if (response.state !== "closed") {
|
|
||||||
return (
|
return (
|
||||||
<EmptyStateText>
|
<EmptyStateText>
|
||||||
<LoadingIcon />
|
<LoadingIcon />
|
||||||
@@ -424,7 +424,15 @@ function EnsureCompleteResponse({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Component bodyPath={response.bodyPath} />;
|
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 }) {
|
function HttpSvgViewer({ response }: { response: HttpResponse }) {
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { platform, useCapability } from "@yaakapp-internal/platform";
|
||||||
|
import { Icon } from "@yaakapp-internal/ui";
|
||||||
|
import * as m from "motion/react-m";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { importCurl, looksLikeCurl } from "../commands/importCurl";
|
||||||
|
import { useWindowFocus } from "../hooks/useWindowFocus";
|
||||||
|
import { showToast } from "../lib/toast";
|
||||||
|
import { Button } from "./core/Button";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offers to create a request from a Curl command on the clipboard. A host that can read
|
||||||
|
* the clipboard on its own offers it whenever the window is focused; one that would have
|
||||||
|
* to prompt for permission first waits for the user to paste instead.
|
||||||
|
*/
|
||||||
|
export function ImportCurl() {
|
||||||
|
return useCapability("clipboardRead") ? <ImportCurlButton /> : <ImportCurlOnPaste />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImportCurlButton() {
|
||||||
|
const focused = useWindowFocus();
|
||||||
|
const [clipboardText, setClipboardText] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// oxlint-disable-next-line react-hooks/exhaustive-deps -- none
|
||||||
|
useEffect(() => {
|
||||||
|
void platform.clipboard.readText().then(setClipboardText);
|
||||||
|
}, [focused]);
|
||||||
|
|
||||||
|
if (!looksLikeCurl(clipboardText)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<m.div
|
||||||
|
initial={{ opacity: 0, scale: 0 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ delay: 0.5 }}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="2xs"
|
||||||
|
variant="border"
|
||||||
|
color="success"
|
||||||
|
className="rounded-full"
|
||||||
|
rightSlot={<Icon icon="import" size="sm" />}
|
||||||
|
isLoading={isLoading}
|
||||||
|
title="Import Curl command from clipboard"
|
||||||
|
onClick={async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await importCurl.mutateAsync({ command: clipboardText });
|
||||||
|
setClipboardText(""); // Hide the button until the clipboard changes
|
||||||
|
} catch (e) {
|
||||||
|
console.log("Failed to import curl", e);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Import Curl
|
||||||
|
</Button>
|
||||||
|
</m.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImportCurlOnPaste() {
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePaste = (e: ClipboardEvent) => {
|
||||||
|
const command = e.clipboardData?.getData("text/plain") ?? "";
|
||||||
|
if (!looksLikeCurl(command)) return;
|
||||||
|
|
||||||
|
// Editable targets keep the text as text. The URL editor imports it itself.
|
||||||
|
if (isEditable(e.target)) return;
|
||||||
|
|
||||||
|
showToast({
|
||||||
|
id: "curl-paste",
|
||||||
|
color: "success",
|
||||||
|
message: (
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold">Curl command detected</h2>
|
||||||
|
<p className="text-text-subtle text-sm">Create a request from the pasted command?</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
action: ({ hide }) => (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
color="success"
|
||||||
|
className="mr-auto min-w-20"
|
||||||
|
rightSlot={<Icon icon="import" size="sm" />}
|
||||||
|
onClick={() => {
|
||||||
|
hide();
|
||||||
|
importCurl.mutate({ command });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Create Request
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("paste", handlePaste);
|
||||||
|
return () => document.removeEventListener("paste", handlePaste);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEditable(target: EventTarget | null) {
|
||||||
|
if (!(target instanceof HTMLElement)) return false;
|
||||||
|
if (target.isContentEditable) return true;
|
||||||
|
if (target.closest(".cm-editor") != null) return true;
|
||||||
|
return ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName);
|
||||||
|
}
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { clear, readText } from "@tauri-apps/plugin-clipboard-manager";
|
|
||||||
import * as m from "motion/react-m";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useImportCurl } from "../hooks/useImportCurl";
|
|
||||||
import { useWindowFocus } from "../hooks/useWindowFocus";
|
|
||||||
import { Button } from "./core/Button";
|
|
||||||
import { Icon } from "@yaakapp-internal/ui";
|
|
||||||
|
|
||||||
export function ImportCurlButton() {
|
|
||||||
const focused = useWindowFocus();
|
|
||||||
const [clipboardText, setClipboardText] = useState("");
|
|
||||||
|
|
||||||
const importCurl = useImportCurl();
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
// oxlint-disable-next-line react-hooks/exhaustive-deps -- none
|
|
||||||
useEffect(() => {
|
|
||||||
void readText().then(setClipboardText);
|
|
||||||
}, [focused]);
|
|
||||||
|
|
||||||
if (!clipboardText?.trim().startsWith("curl ")) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<m.div
|
|
||||||
initial={{ opacity: 0, scale: 0 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
transition={{ delay: 0.5 }}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
size="2xs"
|
|
||||||
variant="border"
|
|
||||||
color="success"
|
|
||||||
className="rounded-full"
|
|
||||||
rightSlot={<Icon icon="import" size="sm" />}
|
|
||||||
isLoading={isLoading}
|
|
||||||
title="Import Curl command from clipboard"
|
|
||||||
onClick={async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
await importCurl.mutateAsync({ command: clipboardText });
|
|
||||||
await clear(); // Clear the clipboard so the button goes away
|
|
||||||
setClipboardText("");
|
|
||||||
} catch (e) {
|
|
||||||
console.log("Failed to import curl", e);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Import Curl
|
|
||||||
</Button>
|
|
||||||
</m.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,56 +1,138 @@
|
|||||||
import { VStack } from "@yaakapp-internal/ui";
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
import { useState } from "react";
|
import { Icon, VStack } from "@yaakapp-internal/ui";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useLocalStorage } from "react-use";
|
import { useLocalStorage } from "react-use";
|
||||||
import { CommercialUseBanner } from "./CommercialUseBanner";
|
import { CommercialUseBanner } from "./CommercialUseBanner";
|
||||||
import { Button } from "./core/Button";
|
import { Button } from "./core/Button";
|
||||||
import { SelectFile } from "./SelectFile";
|
import { PlainInput } from "./core/PlainInput";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
importData: (filePath: string) => Promise<void>;
|
importFile: (filePath: string) => Promise<void>;
|
||||||
|
importUrl: (url: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ImportDataDialog({ importData }: Props) {
|
/**
|
||||||
|
* An absolute or relative path is unambiguously a file. Everything else is treated as a URL, so a
|
||||||
|
* bare host like `example.com/openapi.json` still works (the backend defaults it to https).
|
||||||
|
*/
|
||||||
|
function isFilePath(value: string): boolean {
|
||||||
|
return (
|
||||||
|
value.startsWith("/") ||
|
||||||
|
value.startsWith("./") ||
|
||||||
|
value.startsWith("../") ||
|
||||||
|
value.startsWith("~/") ||
|
||||||
|
value.startsWith("\\\\") ||
|
||||||
|
/^[a-zA-Z]:[\\/]/.test(value)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileName(path: string): string {
|
||||||
|
return path.split(/[/\\]/).at(-1) || path;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImportDataDialog({ importFile, importUrl }: Props) {
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
const [filePath, setFilePath] = useLocalStorage<string | null>("importFilePath", null);
|
// A file path or a URL. Both inputs write here, so there is only ever one thing to import
|
||||||
|
const [source, setSource] = useLocalStorage<string | null>("importPathOrUrl", null);
|
||||||
|
const [forceUpdateKey, setForceUpdateKey] = useState<number>(0);
|
||||||
|
const [isHovering, setIsHovering] = useState<boolean>(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const trimmedSource = source?.trim() ?? "";
|
||||||
|
const filePath = isFilePath(trimmedSource) ? trimmedSource : null;
|
||||||
|
|
||||||
|
const selectSource = (value: string) => {
|
||||||
|
setSource(value);
|
||||||
|
// Remount the input so it shows the path of the newly-picked file
|
||||||
|
setForceUpdateKey((k) => k + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Accept a file dropped anywhere on the dialog, the way SelectFile does for its button
|
||||||
|
useEffect(() => {
|
||||||
|
return platform.window.onDragDrop((event) => {
|
||||||
|
if (event.type === "over") {
|
||||||
|
const p = event.position;
|
||||||
|
const r = ref.current?.getBoundingClientRect();
|
||||||
|
if (r == null) return;
|
||||||
|
setIsHovering(p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom);
|
||||||
|
} else if (event.type === "drop" && isHovering) {
|
||||||
|
const p = event.paths[0];
|
||||||
|
if (p) selectSource(p);
|
||||||
|
setIsHovering(false);
|
||||||
|
} else {
|
||||||
|
setIsHovering(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [isHovering, setSource]);
|
||||||
|
|
||||||
|
const handleSelectFile = async () => {
|
||||||
|
const selected = await platform.dialog.open({ title: "Select File", multiple: false });
|
||||||
|
if (selected == null) return;
|
||||||
|
selectSource(selected);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImport = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
if (filePath != null) {
|
||||||
|
await importFile(filePath);
|
||||||
|
} else {
|
||||||
|
await importUrl(trimmedSource);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack space={5} className="pb-4">
|
<VStack ref={ref} space={4} className="pb-4">
|
||||||
<CommercialUseBanner source="data-import" title="Importing work data?" />
|
<CommercialUseBanner source="data-import" title="Importing work data?" />
|
||||||
|
|
||||||
<VStack space={1}>
|
<button
|
||||||
<ul className="list-disc pl-5">
|
type="button"
|
||||||
<li>OpenAPI 3.0, 3.1</li>
|
onClick={handleSelectFile}
|
||||||
<li>Postman Collection v2, v2.1</li>
|
className={classNames(
|
||||||
<li>Insomnia v4+</li>
|
"w-full rounded-lg border border-dashed px-4 py-6",
|
||||||
<li>Swagger 2.0</li>
|
"flex flex-col items-center gap-1 text-center",
|
||||||
<li>
|
isHovering ? "border-notice bg-surface-highlight" : "border-border hover:border-text",
|
||||||
Curl commands <em className="text-text-subtle">(or paste into URL)</em>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</VStack>
|
|
||||||
<VStack space={2}>
|
|
||||||
<SelectFile
|
|
||||||
filePath={filePath ?? null}
|
|
||||||
onChange={({ filePath }) => setFilePath(filePath)}
|
|
||||||
/>
|
|
||||||
{filePath && (
|
|
||||||
<Button
|
|
||||||
color="primary"
|
|
||||||
disabled={!filePath || isLoading}
|
|
||||||
isLoading={isLoading}
|
|
||||||
size="sm"
|
|
||||||
onClick={async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
await importData(filePath);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isLoading ? "Importing" : "Import"}
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
<Icon icon="folder_input" className="text-text-subtlest w-8! h-8! mb-2" />
|
||||||
|
{/* Fixed height so the region doesn't resize between the empty and selected states */}
|
||||||
|
<div className="h-6 w-full flex items-center justify-center">
|
||||||
|
{filePath == null ? (
|
||||||
|
<div className="text-text">
|
||||||
|
<strong className="font-semibold">Choose a file</strong> or drag it here
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-text font-mono text-xs max-w-full truncate" title={filePath}>
|
||||||
|
{fileName(filePath)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-text-subtlest">
|
||||||
|
Supports OpenAPI, Swagger, Postman, Insomnia, and curl
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<VStack space={2}>
|
||||||
|
<PlainInput
|
||||||
|
label="Or enter a file path or URL"
|
||||||
|
size="sm"
|
||||||
|
placeholder="https://example.com/openapi.json"
|
||||||
|
defaultValue={source ?? ""}
|
||||||
|
forceUpdateKey={String(forceUpdateKey)}
|
||||||
|
onChange={setSource}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
disabled={trimmedSource === "" || isLoading}
|
||||||
|
isLoading={isLoading}
|
||||||
|
size="sm"
|
||||||
|
onClick={handleImport}
|
||||||
|
>
|
||||||
|
{isLoading ? "Importing" : "Import"}
|
||||||
|
</Button>
|
||||||
</VStack>
|
</VStack>
|
||||||
</VStack>
|
</VStack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,274 @@
|
|||||||
|
import { formatSize } from "@yaakapp-internal/lib/formatSize";
|
||||||
|
import { Banner, Button, HStack, LoadingIcon } 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 })),
|
||||||
|
);
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/** The whole value, exactly as it reads in the document */
|
||||||
|
text: string;
|
||||||
|
sniffed: SniffedValue | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a value the editor collapsed, in whatever viewer its type calls for.
|
||||||
|
*
|
||||||
|
* The editor only ever read the value's head, so this is where it is decoded in full — on an
|
||||||
|
* explicit click, behind a spinner, rather than during layout.
|
||||||
|
*/
|
||||||
|
export function LargeValueDialog({ text, sniffed }: Props) {
|
||||||
|
const viewer = sniffed == null ? null : viewerFor(sniffed.mime);
|
||||||
|
const decoded = useDecoded(text, viewer == null ? null : sniffed);
|
||||||
|
|
||||||
|
// Nothing recognised it, so there is nothing to decode it into
|
||||||
|
if (sniffed == null || viewer == null) {
|
||||||
|
return (
|
||||||
|
<PagedText text={text}>
|
||||||
|
{sniffed != null && (
|
||||||
|
<Banner color="info" className="mb-3">
|
||||||
|
{sniffed.label} content cannot be previewed, so it is shown as it appears in the
|
||||||
|
response.
|
||||||
|
</Banner>
|
||||||
|
)}
|
||||||
|
</PagedText>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decoded == null) {
|
||||||
|
return (
|
||||||
|
<HStack className="h-full" alignItems="center" justifyContent="center">
|
||||||
|
<LoadingIcon />
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("error" in decoded) {
|
||||||
|
return (
|
||||||
|
<PagedText text={text}>
|
||||||
|
<Banner color="danger" className="mb-3">
|
||||||
|
Failed to decode this {sniffed.label} value: {decoded.error}
|
||||||
|
</Banner>
|
||||||
|
</PagedText>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { bytes } = decoded;
|
||||||
|
|
||||||
|
switch (viewer) {
|
||||||
|
case "svg":
|
||||||
|
return <DecodedSvg bytes={bytes} />;
|
||||||
|
case "image":
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-auto flex items-center justify-center">
|
||||||
|
<ImageViewer data={toArrayBuffer(bytes)} mimeType={sniffed.mime} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "audio":
|
||||||
|
return <AudioViewer data={bytes} mimeType={sniffed.mime} />;
|
||||||
|
case "video":
|
||||||
|
return <VideoViewer data={bytes} mimeType={sniffed.mime} />;
|
||||||
|
case "pdf":
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<LoadingIcon />}>
|
||||||
|
<PdfViewer data={bytes} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
case "text":
|
||||||
|
return <DecodedText bytes={bytes} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decoding megabytes is worth doing once, not on every render — and the viewers below key their
|
||||||
|
* blob URLs off the string, so a fresh one each time would rebuild them for nothing.
|
||||||
|
*/
|
||||||
|
function DecodedSvg({ bytes }: { bytes: Uint8Array }) {
|
||||||
|
const text = useMemo(() => new TextDecoder().decode(bytes), [bytes]);
|
||||||
|
return <SvgViewer text={text} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DecodedText({ bytes }: { bytes: Uint8Array }) {
|
||||||
|
const text = useMemo(() => new TextDecoder().decode(bytes), [bytes]);
|
||||||
|
return <PagedText text={text} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same menu the tag in the editor carries, minus the one entry that would only reopen this.
|
||||||
|
*
|
||||||
|
* It lives in the dialog's title rather than above the content, which would cost a band of
|
||||||
|
* whitespace the width of the dialog to hold one small button.
|
||||||
|
*/
|
||||||
|
function LargeValueActions({ text, sniffed }: Props) {
|
||||||
|
const items = useMemo(
|
||||||
|
() =>
|
||||||
|
largeValueActions({
|
||||||
|
value: () => text,
|
||||||
|
sniffed,
|
||||||
|
// The tag copies only what it hides; in here, what you see is the whole value
|
||||||
|
copyText: () => text,
|
||||||
|
}),
|
||||||
|
[text, sniffed],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown items={items}>
|
||||||
|
<Button size="xs" variant="border" forDropdown>
|
||||||
|
Actions
|
||||||
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Viewer = "image" | "svg" | "audio" | "video" | "pdf" | "text";
|
||||||
|
|
||||||
|
/** Which viewer a media type calls for, or null if none of them can show it. */
|
||||||
|
function viewerFor(mime: string): Viewer | null {
|
||||||
|
if (mime.startsWith("image/svg")) return "svg";
|
||||||
|
if (mime.startsWith("image/")) return "image";
|
||||||
|
if (mime.startsWith("audio/")) return "audio";
|
||||||
|
if (mime.startsWith("video/")) return "video";
|
||||||
|
if (mime === "application/pdf") return "pdf";
|
||||||
|
if (mime.startsWith("text/") || /\b(json|xml|javascript|csv)\b/.test(mime)) return "text";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Decoded = { bytes: Uint8Array } | { error: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The value's bytes, once they exist.
|
||||||
|
*
|
||||||
|
* Decoding a few megabytes takes long enough to be seen, so it happens in an effect rather than
|
||||||
|
* during render — the dialog paints with a spinner first, instead of opening late.
|
||||||
|
*/
|
||||||
|
function useDecoded(text: string, sniffed: SniffedValue | null): Decoded | null {
|
||||||
|
const [decoded, setDecoded] = useState<Decoded | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (sniffed == null) return;
|
||||||
|
|
||||||
|
setDecoded(null);
|
||||||
|
let cancelled = false;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
let result: Decoded;
|
||||||
|
try {
|
||||||
|
result = { bytes: decodeValue(text, sniffed) };
|
||||||
|
} catch (err) {
|
||||||
|
result = { error: err instanceof Error ? err.message : String(err) };
|
||||||
|
}
|
||||||
|
if (!cancelled) setDecoded(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [text, sniffed]);
|
||||||
|
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A copy, since a `Uint8Array` may be a view onto a larger buffer */
|
||||||
|
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||||
|
return bytes.slice().buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Characters shown at once. A page this size lays out instantly once its lines are short. */
|
||||||
|
const PAGE_CHARS = 50_000;
|
||||||
|
|
||||||
|
/** Where a line is broken. The long-line cost this whole feature avoids is per line. */
|
||||||
|
const WRAP_CHARS = 120;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The raw text, in pages of short lines.
|
||||||
|
*
|
||||||
|
* What is left when nothing can render the value. Handing it to an editor whole would walk
|
||||||
|
* straight back into the layout stall that collapsed it in the first place, so it is paged and
|
||||||
|
* hard-wrapped instead: no line is ever long, and no page is ever big.
|
||||||
|
*/
|
||||||
|
function PagedText({ text, children }: { text: string; children?: ReactNode }) {
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const pages = Math.max(1, Math.ceil(text.length / PAGE_CHARS));
|
||||||
|
const from = page * PAGE_CHARS;
|
||||||
|
const to = Math.min(from + PAGE_CHARS, text.length);
|
||||||
|
const body = useMemo(() => wrap(text.slice(from, to)), [text, from, to]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)_auto] gap-3">
|
||||||
|
<div>{children}</div>
|
||||||
|
<pre className="overflow-auto font-mono text-sm text-text-subtle select-text">{body}</pre>
|
||||||
|
<HStack space={2} alignItems="center" className="text-sm text-text-subtle">
|
||||||
|
<span>
|
||||||
|
{(to - from).toLocaleString()} of {text.length.toLocaleString()} characters
|
||||||
|
</span>
|
||||||
|
{pages > 1 && (
|
||||||
|
<HStack space={2} alignItems="center" className="ml-auto">
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="border"
|
||||||
|
disabled={page === 0}
|
||||||
|
onClick={() => setPage((p) => p - 1)}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<span>
|
||||||
|
Page {page + 1} of {pages.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="border"
|
||||||
|
disabled={page >= pages - 1}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Breaks every line at {@link WRAP_CHARS}, leaving the ones already shorter alone. */
|
||||||
|
function wrap(text: string): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (const line of text.split("\n")) {
|
||||||
|
if (line.length <= WRAP_CHARS) {
|
||||||
|
lines.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < line.length; i += WRAP_CHARS) {
|
||||||
|
lines.push(line.slice(i, i + WRAP_CHARS));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showLargeValueDialog({ text, sniffed }: Props) {
|
||||||
|
showDialog({
|
||||||
|
id: "large-value",
|
||||||
|
size: "lg",
|
||||||
|
className: "h-[calc(100vh-10rem)]",
|
||||||
|
// Everything in one line: the same words the tag uses, and the same menu it carries. A
|
||||||
|
// separate description and a row of its own for the button cost three bands to say this.
|
||||||
|
title: (
|
||||||
|
<HStack space={3} alignItems="center">
|
||||||
|
<span>
|
||||||
|
{sniffed == null ? "Hidden Value" : sniffed.label} · {formatSize(text.length)}
|
||||||
|
</span>
|
||||||
|
<LargeValueActions text={text} sniffed={sniffed} />
|
||||||
|
</HStack>
|
||||||
|
),
|
||||||
|
render: () => <LargeValueDialog text={text} sniffed={sniffed} />,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
|
||||||
import type { LicenseCheckStatus } from "@yaakapp-internal/license";
|
import type { LicenseCheckStatus } from "@yaakapp-internal/license";
|
||||||
import { useLicense } from "@yaakapp-internal/license";
|
import { useLicense } from "@yaakapp-internal/license";
|
||||||
import { settingsAtom } from "@yaakapp-internal/models";
|
import { settingsAtom } from "@yaakapp-internal/models";
|
||||||
@@ -14,6 +13,7 @@ import type { ButtonProps } from "./core/Button";
|
|||||||
import { Dropdown, type DropdownItem } from "./core/Dropdown";
|
import { Dropdown, type DropdownItem } from "./core/Dropdown";
|
||||||
import { Icon } from "@yaakapp-internal/ui";
|
import { Icon } from "@yaakapp-internal/ui";
|
||||||
import { PillButton } from "./core/PillButton";
|
import { PillButton } from "./core/PillButton";
|
||||||
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
|
|
||||||
const dismissedAtom = atomWithKVStorage<string | null>("dismissed_license_expired", null);
|
const dismissedAtom = atomWithKVStorage<string | null>("dismissed_license_expired", null);
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ function getDetail(
|
|||||||
leftSlot: <Icon icon="gift" />,
|
leftSlot: <Icon icon="gift" />,
|
||||||
rightSlot: <Icon icon="external_link" size="sm" className="opacity-disabled" />,
|
rightSlot: <Icon icon="external_link" size="sm" className="opacity-disabled" />,
|
||||||
hidden: data.data.changes === 0 || data.data.changesUrl == null,
|
hidden: data.data.changes === 0 || data.data.changesUrl == null,
|
||||||
onSelect: () => openUrl(data.data.changesUrl ?? ""),
|
onSelect: () => platform.openUrl(data.data.changesUrl ?? ""),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "separator",
|
type: "separator",
|
||||||
@@ -63,7 +63,7 @@ function getDetail(
|
|||||||
leftSlot: <Icon icon="refresh" />,
|
leftSlot: <Icon icon="refresh" />,
|
||||||
rightSlot: <Icon icon="external_link" size="sm" className="opacity-disabled" />,
|
rightSlot: <Icon icon="external_link" size="sm" className="opacity-disabled" />,
|
||||||
hidden: data.data.changesUrl == null,
|
hidden: data.data.changesUrl == null,
|
||||||
onSelect: () => openUrl(data.data.billingUrl),
|
onSelect: () => platform.openUrl(data.data.billingUrl),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Enter License Key",
|
label: "Enter License Key",
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
|
||||||
import { resolveResource } from "@tauri-apps/api/path";
|
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
src: string;
|
src: string;
|
||||||
@@ -12,8 +11,8 @@ export function LocalImage({ src: srcPath, className }: Props) {
|
|||||||
const src = useQuery({
|
const src = useQuery({
|
||||||
queryKey: ["local-image", srcPath],
|
queryKey: ["local-image", srcPath],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const p = await resolveResource(srcPath);
|
const p = await platform.files.resolveResource(srcPath);
|
||||||
return convertFileSrc(p);
|
return platform.files.url(p);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import type {
|
|||||||
Folder,
|
Folder,
|
||||||
GrpcRequest,
|
GrpcRequest,
|
||||||
HttpRequest,
|
HttpRequest,
|
||||||
|
HttpVersion,
|
||||||
InheritedBoolSetting,
|
InheritedBoolSetting,
|
||||||
|
InheritedHttpVersionSetting,
|
||||||
InheritedIntSetting,
|
InheritedIntSetting,
|
||||||
WebsocketRequest,
|
WebsocketRequest,
|
||||||
Workspace,
|
Workspace,
|
||||||
@@ -13,6 +15,7 @@ import {
|
|||||||
modelSupportsSetting,
|
modelSupportsSetting,
|
||||||
type RequestSettingDefinition,
|
type RequestSettingDefinition,
|
||||||
SETTING_FOLLOW_REDIRECTS,
|
SETTING_FOLLOW_REDIRECTS,
|
||||||
|
SETTING_HTTP_VERSION,
|
||||||
SETTING_REQUEST_MESSAGE_SIZE,
|
SETTING_REQUEST_MESSAGE_SIZE,
|
||||||
SETTING_REQUEST_TIMEOUT,
|
SETTING_REQUEST_TIMEOUT,
|
||||||
SETTING_SEND_COOKIES,
|
SETTING_SEND_COOKIES,
|
||||||
@@ -21,6 +24,7 @@ import {
|
|||||||
} from "../lib/requestSettings";
|
} from "../lib/requestSettings";
|
||||||
import { Checkbox } from "./core/Checkbox";
|
import { Checkbox } from "./core/Checkbox";
|
||||||
import { PlainInput } from "./core/PlainInput";
|
import { PlainInput } from "./core/PlainInput";
|
||||||
|
import { Select } from "./core/Select";
|
||||||
import {
|
import {
|
||||||
SettingOverrideRow,
|
SettingOverrideRow,
|
||||||
SettingRow,
|
SettingRow,
|
||||||
@@ -38,37 +42,21 @@ interface Props {
|
|||||||
model: ModelWithSettings;
|
model: ModelWithSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ModelWithSettings =
|
type ModelWithSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest;
|
||||||
| Workspace
|
|
||||||
| Folder
|
|
||||||
| HttpRequest
|
|
||||||
| WebsocketRequest
|
|
||||||
| GrpcRequest;
|
|
||||||
type ModelWithHttpSettings = Workspace | Folder | HttpRequest;
|
type ModelWithHttpSettings = Workspace | Folder | HttpRequest;
|
||||||
type ModelWithTlsSettings =
|
type ModelWithTlsSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest;
|
||||||
| Workspace
|
type ModelWithCookieSettings = Workspace | Folder | HttpRequest | WebsocketRequest;
|
||||||
| Folder
|
type ModelWithMessageSizeSettings = Workspace | Folder | WebsocketRequest | GrpcRequest;
|
||||||
| HttpRequest
|
|
||||||
| WebsocketRequest
|
|
||||||
| GrpcRequest;
|
|
||||||
type ModelWithCookieSettings =
|
|
||||||
| Workspace
|
|
||||||
| Folder
|
|
||||||
| HttpRequest
|
|
||||||
| WebsocketRequest;
|
|
||||||
type ModelWithMessageSizeSettings =
|
|
||||||
| Workspace
|
|
||||||
| Folder
|
|
||||||
| WebsocketRequest
|
|
||||||
| GrpcRequest;
|
|
||||||
type BooleanSetting = boolean | InheritedBoolSetting;
|
type BooleanSetting = boolean | InheritedBoolSetting;
|
||||||
type IntegerSetting = number | InheritedIntSetting;
|
type IntegerSetting = number | InheritedIntSetting;
|
||||||
|
type HttpVersionSetting = HttpVersion | InheritedHttpVersionSetting;
|
||||||
type CookieSettingsPatch = {
|
type CookieSettingsPatch = {
|
||||||
settingSendCookies?: ModelWithCookieSettings["settingSendCookies"];
|
settingSendCookies?: ModelWithCookieSettings["settingSendCookies"];
|
||||||
settingStoreCookies?: ModelWithCookieSettings["settingStoreCookies"];
|
settingStoreCookies?: ModelWithCookieSettings["settingStoreCookies"];
|
||||||
};
|
};
|
||||||
type HttpSettingsPatch = {
|
type HttpSettingsPatch = {
|
||||||
settingFollowRedirects?: ModelWithHttpSettings["settingFollowRedirects"];
|
settingFollowRedirects?: ModelWithHttpSettings["settingFollowRedirects"];
|
||||||
|
settingHttpVersion?: ModelWithHttpSettings["settingHttpVersion"];
|
||||||
settingRequestTimeout?: ModelWithHttpSettings["settingRequestTimeout"];
|
settingRequestTimeout?: ModelWithHttpSettings["settingRequestTimeout"];
|
||||||
};
|
};
|
||||||
type TlsSettingsPatch = {
|
type TlsSettingsPatch = {
|
||||||
@@ -78,10 +66,7 @@ type MessageSizeSettingsPatch = {
|
|||||||
settingRequestMessageSize?: ModelWithMessageSizeSettings["settingRequestMessageSize"];
|
settingRequestMessageSize?: ModelWithMessageSizeSettings["settingRequestMessageSize"];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ModelSettingsEditor({
|
export function ModelSettingsEditor({ model, showSectionTitles = false }: Props) {
|
||||||
model,
|
|
||||||
showSectionTitles = false,
|
|
||||||
}: Props) {
|
|
||||||
const ancestors = useModelAncestors(model);
|
const ancestors = useModelAncestors(model);
|
||||||
const supportsHttpSettings = modelSupportsHttpSettings(model);
|
const supportsHttpSettings = modelSupportsHttpSettings(model);
|
||||||
const supportsCookieSettings = modelSupportsCookieSettings(model);
|
const supportsCookieSettings = modelSupportsCookieSettings(model);
|
||||||
@@ -154,12 +139,26 @@ export function ModelSettingsEditor({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{supportsHttpSettings && (
|
||||||
|
<HttpVersionSettingRow
|
||||||
|
settingDefinition={SETTING_HTTP_VERSION}
|
||||||
|
setting={model.settingHttpVersion}
|
||||||
|
inheritedValue={resolveInheritedValue(
|
||||||
|
ancestors,
|
||||||
|
SETTING_HTTP_VERSION.modelKey,
|
||||||
|
model.settingHttpVersion,
|
||||||
|
)}
|
||||||
|
onChange={(settingHttpVersion) =>
|
||||||
|
patchHttpSettings(model, {
|
||||||
|
settingHttpVersion,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
)}
|
)}
|
||||||
{supportsCookieSettings && (
|
{supportsCookieSettings && (
|
||||||
<SettingsSection
|
<SettingsSection title={supportsTlsSettings || showSectionTitles ? "Cookies" : null}>
|
||||||
title={supportsTlsSettings || showSectionTitles ? "Cookies" : null}
|
|
||||||
>
|
|
||||||
<BooleanSettingRow
|
<BooleanSettingRow
|
||||||
settingDefinition={SETTING_SEND_COOKIES}
|
settingDefinition={SETTING_SEND_COOKIES}
|
||||||
setting={model.settingSendCookies}
|
setting={model.settingSendCookies}
|
||||||
@@ -195,7 +194,7 @@ export function ModelSettingsEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function countOverriddenSettings(model: ModelWithSettings) {
|
export function countOverriddenSettings(model: ModelWithSettings) {
|
||||||
const settings: (BooleanSetting | IntegerSetting)[] = [];
|
const settings: (BooleanSetting | IntegerSetting | HttpVersionSetting)[] = [];
|
||||||
|
|
||||||
if (modelSupportsCookieSettings(model)) {
|
if (modelSupportsCookieSettings(model)) {
|
||||||
settings.push(model.settingSendCookies, model.settingStoreCookies);
|
settings.push(model.settingSendCookies, model.settingStoreCookies);
|
||||||
@@ -204,22 +203,22 @@ export function countOverriddenSettings(model: ModelWithSettings) {
|
|||||||
settings.push(model.settingValidateCertificates);
|
settings.push(model.settingValidateCertificates);
|
||||||
|
|
||||||
if (modelSupportsHttpSettings(model)) {
|
if (modelSupportsHttpSettings(model)) {
|
||||||
settings.push(model.settingFollowRedirects, model.settingRequestTimeout);
|
settings.push(
|
||||||
|
model.settingFollowRedirects,
|
||||||
|
model.settingRequestTimeout,
|
||||||
|
model.settingHttpVersion,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (modelSupportsMessageSizeSettings(model)) {
|
if (modelSupportsMessageSizeSettings(model)) {
|
||||||
settings.push(model.settingRequestMessageSize);
|
settings.push(model.settingRequestMessageSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
return settings.filter(
|
return settings.filter((setting) => isInheritedSetting(setting) && setting.enabled === true)
|
||||||
(setting) => isInheritedSetting(setting) && setting.enabled === true,
|
.length;
|
||||||
).length;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function patchCookieSettings(
|
function patchCookieSettings(model: ModelWithCookieSettings, patch: Partial<CookieSettingsPatch>) {
|
||||||
model: ModelWithCookieSettings,
|
|
||||||
patch: Partial<CookieSettingsPatch>,
|
|
||||||
) {
|
|
||||||
switch (model.model) {
|
switch (model.model) {
|
||||||
case "workspace":
|
case "workspace":
|
||||||
return patchModel(model, patch as Partial<Workspace>);
|
return patchModel(model, patch as Partial<Workspace>);
|
||||||
@@ -232,10 +231,7 @@ function patchCookieSettings(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function patchHttpSettings(
|
function patchHttpSettings(model: ModelWithHttpSettings, patch: Partial<HttpSettingsPatch>) {
|
||||||
model: ModelWithHttpSettings,
|
|
||||||
patch: Partial<HttpSettingsPatch>,
|
|
||||||
) {
|
|
||||||
switch (model.model) {
|
switch (model.model) {
|
||||||
case "workspace":
|
case "workspace":
|
||||||
return patchModel(model, patch as Partial<Workspace>);
|
return patchModel(model, patch as Partial<Workspace>);
|
||||||
@@ -246,10 +242,7 @@ function patchHttpSettings(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function patchTlsSettings(
|
function patchTlsSettings(model: ModelWithTlsSettings, patch: Partial<TlsSettingsPatch>) {
|
||||||
model: ModelWithTlsSettings,
|
|
||||||
patch: Partial<TlsSettingsPatch>,
|
|
||||||
) {
|
|
||||||
switch (model.model) {
|
switch (model.model) {
|
||||||
case "workspace":
|
case "workspace":
|
||||||
return patchModel(model, patch as Partial<Workspace>);
|
return patchModel(model, patch as Partial<Workspace>);
|
||||||
@@ -280,21 +273,15 @@ function patchMessageSizeSettings(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function modelSupportsHttpSettings(
|
function modelSupportsHttpSettings(model: ModelWithSettings): model is ModelWithHttpSettings {
|
||||||
model: ModelWithSettings,
|
|
||||||
): model is ModelWithHttpSettings {
|
|
||||||
return modelSupportsSetting(model, SETTING_REQUEST_TIMEOUT);
|
return modelSupportsSetting(model, SETTING_REQUEST_TIMEOUT);
|
||||||
}
|
}
|
||||||
|
|
||||||
function modelSupportsCookieSettings(
|
function modelSupportsCookieSettings(model: ModelWithSettings): model is ModelWithCookieSettings {
|
||||||
model: ModelWithSettings,
|
|
||||||
): model is ModelWithCookieSettings {
|
|
||||||
return modelSupportsSetting(model, SETTING_SEND_COOKIES);
|
return modelSupportsSetting(model, SETTING_SEND_COOKIES);
|
||||||
}
|
}
|
||||||
|
|
||||||
function modelSupportsTlsSettings(
|
function modelSupportsTlsSettings(model: ModelWithSettings): model is ModelWithTlsSettings {
|
||||||
model: ModelWithSettings,
|
|
||||||
): model is ModelWithTlsSettings {
|
|
||||||
return modelSupportsSetting(model, SETTING_VALIDATE_CERTIFICATES);
|
return modelSupportsSetting(model, SETTING_VALIDATE_CERTIFICATES);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,11 +304,7 @@ function BooleanSettingRow({
|
|||||||
}) {
|
}) {
|
||||||
const inherited = isInheritedSetting(setting);
|
const inherited = isInheritedSetting(setting);
|
||||||
const overridden = inherited ? setting.enabled === true : false;
|
const overridden = inherited ? setting.enabled === true : false;
|
||||||
const value = inherited
|
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
|
||||||
? overridden
|
|
||||||
? setting.value
|
|
||||||
: inheritedValue
|
|
||||||
: setting;
|
|
||||||
|
|
||||||
if (!inherited) {
|
if (!inherited) {
|
||||||
return (
|
return (
|
||||||
@@ -352,6 +335,63 @@ function BooleanSettingRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const HTTP_VERSION_OPTIONS: { label: string; value: HttpVersion }[] = [
|
||||||
|
{ label: "Automatic", value: "auto" },
|
||||||
|
{ label: "HTTP/1.1", value: "http1" },
|
||||||
|
{ label: "HTTP/2", value: "http2" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function HttpVersionSettingRow({
|
||||||
|
inheritedValue,
|
||||||
|
setting,
|
||||||
|
settingDefinition,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
inheritedValue: HttpVersion;
|
||||||
|
setting: HttpVersionSetting;
|
||||||
|
settingDefinition: RequestSettingDefinition<"settingHttpVersion">;
|
||||||
|
onChange: (setting: HttpVersionSetting) => void;
|
||||||
|
}) {
|
||||||
|
const inherited = isInheritedSetting(setting);
|
||||||
|
const overridden = inherited ? setting.enabled === true : false;
|
||||||
|
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
|
||||||
|
|
||||||
|
if (!inherited) {
|
||||||
|
return (
|
||||||
|
<SettingRow title={settingDefinition.title} description={settingDefinition.description}>
|
||||||
|
<Select
|
||||||
|
hideLabel
|
||||||
|
name={settingDefinition.modelKey}
|
||||||
|
label={settingDefinition.title}
|
||||||
|
size="sm"
|
||||||
|
value={value}
|
||||||
|
options={HTTP_VERSION_OPTIONS}
|
||||||
|
onChange={(value) => onChange(value)}
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingOverrideRow
|
||||||
|
title={settingDefinition.title}
|
||||||
|
description={settingDefinition.description}
|
||||||
|
overridden={overridden}
|
||||||
|
onResetOverride={() => onChange({ ...setting, enabled: false })}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
hideLabel
|
||||||
|
name={settingDefinition.modelKey}
|
||||||
|
label={settingDefinition.title}
|
||||||
|
size="sm"
|
||||||
|
value={value}
|
||||||
|
options={HTTP_VERSION_OPTIONS}
|
||||||
|
onChange={(value) => onChange({ ...setting, enabled: true, value })}
|
||||||
|
/>
|
||||||
|
</SettingOverrideRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function IntegerSettingRow({
|
function IntegerSettingRow({
|
||||||
inheritedValue,
|
inheritedValue,
|
||||||
setting,
|
setting,
|
||||||
@@ -365,18 +405,11 @@ function IntegerSettingRow({
|
|||||||
}) {
|
}) {
|
||||||
const inherited = isInheritedSetting(setting);
|
const inherited = isInheritedSetting(setting);
|
||||||
const overridden = inherited ? setting.enabled === true : false;
|
const overridden = inherited ? setting.enabled === true : false;
|
||||||
const value = inherited
|
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
|
||||||
? overridden
|
|
||||||
? setting.value
|
|
||||||
: inheritedValue
|
|
||||||
: setting;
|
|
||||||
|
|
||||||
if (!inherited) {
|
if (!inherited) {
|
||||||
return (
|
return (
|
||||||
<SettingRow
|
<SettingRow title={settingDefinition.title} description={settingDefinition.description}>
|
||||||
title={settingDefinition.title}
|
|
||||||
description={settingDefinition.description}
|
|
||||||
>
|
|
||||||
<NumberUnitInput
|
<NumberUnitInput
|
||||||
name={settingDefinition.modelKey}
|
name={settingDefinition.modelKey}
|
||||||
label={settingDefinition.title}
|
label={settingDefinition.title}
|
||||||
@@ -429,20 +462,13 @@ function MessageSizeSettingRow({
|
|||||||
}) {
|
}) {
|
||||||
const inherited = isInheritedSetting(setting);
|
const inherited = isInheritedSetting(setting);
|
||||||
const overridden = inherited ? setting.enabled === true : false;
|
const overridden = inherited ? setting.enabled === true : false;
|
||||||
const value = inherited
|
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
|
||||||
? overridden
|
|
||||||
? setting.value
|
|
||||||
: inheritedValue
|
|
||||||
: setting;
|
|
||||||
const displayValue = formatMegabytes(value);
|
const displayValue = formatMegabytes(value);
|
||||||
const placeholder = "0";
|
const placeholder = "0";
|
||||||
|
|
||||||
if (!inherited) {
|
if (!inherited) {
|
||||||
return (
|
return (
|
||||||
<SettingRow
|
<SettingRow title={settingDefinition.title} description={settingDefinition.description}>
|
||||||
title={settingDefinition.title}
|
|
||||||
description={settingDefinition.description}
|
|
||||||
>
|
|
||||||
<MessageSizeInput
|
<MessageSizeInput
|
||||||
name={settingDefinition.modelKey}
|
name={settingDefinition.modelKey}
|
||||||
label={settingDefinition.title}
|
label={settingDefinition.title}
|
||||||
@@ -567,13 +593,18 @@ function resolveInheritedValue(
|
|||||||
key: BooleanWorkspaceSettingKey,
|
key: BooleanWorkspaceSettingKey,
|
||||||
fallback: BooleanSetting,
|
fallback: BooleanSetting,
|
||||||
): boolean;
|
): boolean;
|
||||||
|
function resolveInheritedValue(
|
||||||
|
ancestors: (Folder | Workspace)[],
|
||||||
|
key: "settingHttpVersion",
|
||||||
|
fallback: HttpVersionSetting,
|
||||||
|
): HttpVersion;
|
||||||
function resolveInheritedValue(
|
function resolveInheritedValue(
|
||||||
ancestors: (Folder | Workspace)[],
|
ancestors: (Folder | Workspace)[],
|
||||||
key: keyof WorkspaceSettings,
|
key: keyof WorkspaceSettings,
|
||||||
fallback: BooleanSetting | IntegerSetting,
|
fallback: BooleanSetting | IntegerSetting | HttpVersionSetting,
|
||||||
) {
|
) {
|
||||||
for (const ancestor of ancestors) {
|
for (const ancestor of ancestors) {
|
||||||
const setting = ancestor[key] as BooleanSetting | IntegerSetting;
|
const setting = ancestor[key] as BooleanSetting | IntegerSetting | HttpVersionSetting;
|
||||||
if (isInheritedSetting(setting)) {
|
if (isInheritedSetting(setting)) {
|
||||||
if (setting.enabled === true) {
|
if (setting.enabled === true) {
|
||||||
return setting.value;
|
return setting.value;
|
||||||
@@ -589,6 +620,7 @@ function resolveInheritedValue(
|
|||||||
type WorkspaceSettings = Pick<
|
type WorkspaceSettings = Pick<
|
||||||
Workspace,
|
Workspace,
|
||||||
| "settingFollowRedirects"
|
| "settingFollowRedirects"
|
||||||
|
| "settingHttpVersion"
|
||||||
| "settingRequestMessageSize"
|
| "settingRequestMessageSize"
|
||||||
| "settingRequestTimeout"
|
| "settingRequestTimeout"
|
||||||
| "settingSendCookies"
|
| "settingSendCookies"
|
||||||
@@ -598,14 +630,12 @@ type WorkspaceSettings = Pick<
|
|||||||
|
|
||||||
type BooleanWorkspaceSettingKey = Exclude<
|
type BooleanWorkspaceSettingKey = Exclude<
|
||||||
keyof WorkspaceSettings,
|
keyof WorkspaceSettings,
|
||||||
"settingRequestTimeout" | "settingRequestMessageSize"
|
"settingRequestTimeout" | "settingRequestMessageSize" | "settingHttpVersion"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
function formatMegabytes(bytes: number) {
|
function formatMegabytes(bytes: number) {
|
||||||
const megabytes = bytes / BYTES_PER_MB;
|
const megabytes = bytes / BYTES_PER_MB;
|
||||||
return Number.isInteger(megabytes)
|
return Number.isInteger(megabytes) ? `${megabytes}` : megabytes.toFixed(3).replace(/\.?0+$/, "");
|
||||||
? `${megabytes}`
|
|
||||||
: megabytes.toFixed(3).replace(/\.?0+$/, "");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseMegabytes(value: string) {
|
function parseMegabytes(value: string) {
|
||||||
@@ -626,9 +656,5 @@ function isValidInteger(value: string) {
|
|||||||
function isValidMegabytes(value: string) {
|
function isValidMegabytes(value: string) {
|
||||||
if (value === "") return true;
|
if (value === "") return true;
|
||||||
const megabytes = Number(value);
|
const megabytes = Number(value);
|
||||||
return (
|
return Number.isFinite(megabytes) && megabytes >= 0 && megabytes <= MAX_MESSAGE_SIZE_MB;
|
||||||
Number.isFinite(megabytes) &&
|
|
||||||
megabytes >= 0 &&
|
|
||||||
megabytes <= MAX_MESSAGE_SIZE_MB
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
|
||||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||||
import { format, formatDistanceToNowStrict } from "date-fns";
|
import { format, formatDistanceToNowStrict } from "date-fns";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
@@ -6,6 +5,7 @@ import { CountBadge } from "./core/CountBadge";
|
|||||||
import { DetailsBanner } from "./core/DetailsBanner";
|
import { DetailsBanner } from "./core/DetailsBanner";
|
||||||
import { IconButton } from "./core/IconButton";
|
import { IconButton } from "./core/IconButton";
|
||||||
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
|
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
|
||||||
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
response: HttpResponse;
|
response: HttpResponse;
|
||||||
@@ -45,7 +45,7 @@ export function ResponseHeaders({ response }: Props) {
|
|||||||
iconSize="sm"
|
iconSize="sm"
|
||||||
className="inline-block w-auto h-auto! opacity-50 hover:opacity-100"
|
className="inline-block w-auto h-auto! opacity-50 hover:opacity-100"
|
||||||
icon="external_link"
|
icon="external_link"
|
||||||
onClick={() => openUrl(response.url)}
|
onClick={() => platform.openUrl(response.url)}
|
||||||
title="Open in browser"
|
title="Open in browser"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
|
||||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||||
import { IconButton } from "./core/IconButton";
|
import { IconButton } from "./core/IconButton";
|
||||||
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
|
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
|
||||||
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
response: HttpResponse;
|
response: HttpResponse;
|
||||||
@@ -26,7 +26,7 @@ export function ResponseInfo({ response }: Props) {
|
|||||||
iconSize="sm"
|
iconSize="sm"
|
||||||
className="inline-block w-auto ml-1 h-auto! opacity-50 hover:opacity-100"
|
className="inline-block w-auto ml-1 h-auto! opacity-50 hover:opacity-100"
|
||||||
icon="external_link"
|
icon="external_link"
|
||||||
onClick={() => openUrl(response.url)}
|
onClick={() => platform.openUrl(response.url)}
|
||||||
title="Open in browser"
|
title="Open in browser"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
import { open } from "@tauri-apps/plugin-dialog";
|
|
||||||
import { HStack } from "@yaakapp-internal/ui";
|
import { HStack } from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import mime from "mime";
|
import mime from "mime";
|
||||||
@@ -41,7 +40,7 @@ export function SelectFile({
|
|||||||
...props
|
...props
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const handleClick = async () => {
|
const handleClick = async () => {
|
||||||
const filePath = await open({
|
const filePath = await platform.dialog.open({
|
||||||
title: directory ? "Select Folder" : "Select File",
|
title: directory ? "Select Folder" : "Select File",
|
||||||
multiple: false,
|
multiple: false,
|
||||||
directory,
|
directory,
|
||||||
@@ -64,32 +63,21 @@ export function SelectFile({
|
|||||||
// NOTE: This doesn't work for Windows since native drag-n-drop can't work at the same tmie
|
// NOTE: This doesn't work for Windows since native drag-n-drop can't work at the same tmie
|
||||||
// as browser drag-n-drop.
|
// as browser drag-n-drop.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let unlisten: (() => void) | undefined;
|
return platform.window.onDragDrop((event) => {
|
||||||
const setup = async () => {
|
if (event.type === "over") {
|
||||||
const webview = getCurrentWebviewWindow();
|
const p = event.position;
|
||||||
unlisten = await webview.onDragDropEvent((event) => {
|
const r = ref.current?.getBoundingClientRect();
|
||||||
if (event.payload.type === "over") {
|
if (r == null) return;
|
||||||
const p = event.payload.position;
|
const isOver = p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom;
|
||||||
const r = ref.current?.getBoundingClientRect();
|
setIsHovering(isOver);
|
||||||
if (r == null) return;
|
} else if (event.type === "drop" && isHovering) {
|
||||||
const isOver = p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom;
|
const p = event.paths[0];
|
||||||
console.log("IS OVER", isOver);
|
if (p) onChange({ filePath: p, contentType: null });
|
||||||
setIsHovering(isOver);
|
setIsHovering(false);
|
||||||
} else if (event.payload.type === "drop" && isHovering) {
|
} else {
|
||||||
console.log("User dropped", event.payload.paths);
|
setIsHovering(false);
|
||||||
const p = event.payload.paths[0];
|
}
|
||||||
if (p) onChange({ filePath: p, contentType: null });
|
});
|
||||||
setIsHovering(false);
|
|
||||||
} else {
|
|
||||||
console.log("File drop cancelled");
|
|
||||||
setIsHovering(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
setup().catch(console.error);
|
|
||||||
return () => {
|
|
||||||
if (unlisten) unlisten();
|
|
||||||
};
|
|
||||||
}, [isHovering, onChange]);
|
}, [isHovering, onChange]);
|
||||||
|
|
||||||
const filePathWithNameOverride = nameOverride ? `${filePath} (${nameOverride})` : filePath;
|
const filePathWithNameOverride = nameOverride ? `${filePath} (${nameOverride})` : filePath;
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { useSearch } from "@tanstack/react-router";
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
|
||||||
import { type } from "@tauri-apps/plugin-os";
|
|
||||||
import { useLicense } from "@yaakapp-internal/license";
|
import { useLicense } from "@yaakapp-internal/license";
|
||||||
import { pluginsAtom, settingsAtom } from "@yaakapp-internal/models";
|
import { pluginsAtom, settingsAtom } from "@yaakapp-internal/models";
|
||||||
import { HeaderSize, HStack, Icon } from "@yaakapp-internal/ui";
|
import { HeaderSize, HStack, Icon } from "@yaakapp-internal/ui";
|
||||||
@@ -21,6 +19,8 @@ import { SettingsProxy } from "./SettingsProxy";
|
|||||||
import { SettingsTheme } from "./SettingsTheme";
|
import { SettingsTheme } from "./SettingsTheme";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
tab?: SettingsTabWithSubtab | null;
|
||||||
|
/** Set when Settings is in a dialog rather than owning a window. */
|
||||||
hide?: () => void;
|
hide?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,25 +43,19 @@ const tabs = [
|
|||||||
TAB_LICENSE,
|
TAB_LICENSE,
|
||||||
] as const;
|
] as const;
|
||||||
export type SettingsTab = (typeof tabs)[number];
|
export type SettingsTab = (typeof tabs)[number];
|
||||||
|
export type SettingsTabWithSubtab = SettingsTab | `${SettingsTab}:${string}`;
|
||||||
|
|
||||||
export default function Settings({ hide }: Props) {
|
export default function Settings({ tab, hide }: Props) {
|
||||||
const { tab: tabFromQuery } = useSearch({ from: "/workspaces/$workspaceId/settings" });
|
|
||||||
// Parse tab and subtab (e.g., "plugins:installed")
|
// Parse tab and subtab (e.g., "plugins:installed")
|
||||||
const [mainTab, subtab] = tabFromQuery?.split(":") ?? [];
|
const [mainTab, subtab] = tab?.split(":") ?? [];
|
||||||
const settings = useAtomValue(settingsAtom);
|
const settings = useAtomValue(settingsAtom);
|
||||||
const plugins = useAtomValue(pluginsAtom);
|
const plugins = useAtomValue(pluginsAtom);
|
||||||
const licenseCheck = useLicense();
|
const licenseCheck = useLicense();
|
||||||
|
|
||||||
// Close settings window on escape
|
// Close settings window on escape. In a dialog, the dialog handles Escape itself.
|
||||||
// TODO: Could this be put in a better place? Eg. in Rust key listener when creating the window
|
// TODO: Could this be put in a better place? Eg. in Rust key listener when creating the window
|
||||||
useKeyPressEvent("Escape", async () => {
|
useKeyPressEvent("Escape", async () => {
|
||||||
if (hide != null) {
|
if (hide == null) await platform.window.close();
|
||||||
// It's being shown in a dialog, so close the dialog
|
|
||||||
hide();
|
|
||||||
} else {
|
|
||||||
// It's being shown in a window, so close the window
|
|
||||||
await getCurrentWebviewWindow().close();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -75,7 +69,7 @@ export default function Settings({ hide }: Props) {
|
|||||||
onlyXWindowControl
|
onlyXWindowControl
|
||||||
size="md"
|
size="md"
|
||||||
className="x-theme-appHeader bg-surface text-text-subtle flex items-center justify-center border-b border-border-subtle text-sm font-semibold"
|
className="x-theme-appHeader bg-surface text-text-subtle flex items-center justify-center border-b border-border-subtle text-sm font-semibold"
|
||||||
osType={type()}
|
osType={platform.osType()}
|
||||||
hideWindowControls={settings.hideWindowControls}
|
hideWindowControls={settings.hideWindowControls}
|
||||||
useNativeTitlebar={settings.useNativeTitlebar}
|
useNativeTitlebar={settings.useNativeTitlebar}
|
||||||
interfaceScale={settings.interfaceScale}
|
interfaceScale={settings.interfaceScale}
|
||||||
@@ -85,13 +79,13 @@ export default function Settings({ hide }: Props) {
|
|||||||
justifyContent="center"
|
justifyContent="center"
|
||||||
className="w-full h-full grid grid-cols-[1fr_auto] pointer-events-none"
|
className="w-full h-full grid grid-cols-[1fr_auto] pointer-events-none"
|
||||||
>
|
>
|
||||||
<div className={classNames(type() === "macos" ? "text-center" : "pl-2")}>Settings</div>
|
<div className={classNames(platform.osType() === "macos" ? "text-center" : "pl-2")}>Settings</div>
|
||||||
</HStack>
|
</HStack>
|
||||||
</HeaderSize>
|
</HeaderSize>
|
||||||
)}
|
)}
|
||||||
<Tabs
|
<Tabs
|
||||||
layout="horizontal"
|
layout="horizontal"
|
||||||
defaultValue={mainTab || tabFromQuery}
|
defaultValue={mainTab}
|
||||||
addBorders
|
addBorders
|
||||||
tabListClassName="min-w-40 bg-surface x-theme-sidebar border-r border-border pl-3"
|
tabListClassName="min-w-40 bg-surface x-theme-sidebar border-r border-border pl-3"
|
||||||
label="Settings"
|
label="Settings"
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { revealItemInDir } from "@tauri-apps/plugin-opener";
|
|
||||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
||||||
import { Heading, VStack } from "@yaakapp-internal/ui";
|
import { Heading, VStack } from "@yaakapp-internal/ui";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
@@ -9,6 +8,7 @@ import { CargoFeature } from "../CargoFeature";
|
|||||||
import { CommercialUseBanner } from "../CommercialUseBanner";
|
import { CommercialUseBanner } from "../CommercialUseBanner";
|
||||||
import { DismissibleBanner } from "../core/DismissibleBanner";
|
import { DismissibleBanner } from "../core/DismissibleBanner";
|
||||||
import { IconButton } from "../core/IconButton";
|
import { IconButton } from "../core/IconButton";
|
||||||
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
import {
|
import {
|
||||||
ModelSettingRowBoolean,
|
ModelSettingRowBoolean,
|
||||||
ModelSettingSelectControl,
|
ModelSettingSelectControl,
|
||||||
@@ -152,7 +152,7 @@ export function SettingsGeneral() {
|
|||||||
{
|
{
|
||||||
title: revealInFinderText,
|
title: revealInFinderText,
|
||||||
icon: "folder_open",
|
icon: "folder_open",
|
||||||
onClick: () => revealItemInDir(appInfo.appDataDir),
|
onClick: () => platform.revealItemInDir(appInfo.appDataDir),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
@@ -168,7 +168,7 @@ export function SettingsGeneral() {
|
|||||||
{
|
{
|
||||||
title: revealInFinderText,
|
title: revealInFinderText,
|
||||||
icon: "folder_open",
|
icon: "folder_open",
|
||||||
onClick: () => revealItemInDir(appInfo.appLogDir),
|
onClick: () => platform.revealItemInDir(appInfo.appLogDir),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export function SettingsHotkeys() {
|
|||||||
<HotkeyRow
|
<HotkeyRow
|
||||||
key={action}
|
key={action}
|
||||||
action={action}
|
action={action}
|
||||||
currentKeys={hotkeys[action]}
|
currentKeys={hotkeys[action] ?? []}
|
||||||
defaultKeys={defaultHotkeys[action]}
|
defaultKeys={defaultHotkeys[action]}
|
||||||
onSave={async (keys) => {
|
onSave={async (keys) => {
|
||||||
const newHotkeys = { ...settings.hotkeys };
|
const newHotkeys = { ...settings.hotkeys };
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type } from "@tauri-apps/plugin-os";
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
import { useFonts } from "@yaakapp-internal/fonts";
|
import { useFonts } from "@yaakapp-internal/fonts";
|
||||||
import { useLicense } from "@yaakapp-internal/license";
|
import { useLicense } from "@yaakapp-internal/license";
|
||||||
import type { EditorKeymap, Settings } from "@yaakapp-internal/models";
|
import type { EditorKeymap, Settings } from "@yaakapp-internal/models";
|
||||||
@@ -9,7 +9,7 @@ import { useState } from "react";
|
|||||||
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
|
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
|
||||||
import { showConfirm } from "../../lib/confirm";
|
import { showConfirm } from "../../lib/confirm";
|
||||||
import { pricingUrl } from "../../lib/pricingUrl";
|
import { pricingUrl } from "../../lib/pricingUrl";
|
||||||
import { invokeCmd } from "../../lib/tauri";
|
import { rpc } from "../../lib/rpc";
|
||||||
import { CargoFeature } from "../CargoFeature";
|
import { CargoFeature } from "../CargoFeature";
|
||||||
import { Button } from "../core/Button";
|
import { Button } from "../core/Button";
|
||||||
import { Checkbox } from "../core/Checkbox";
|
import { Checkbox } from "../core/Checkbox";
|
||||||
@@ -176,7 +176,7 @@ export function SettingsInterface() {
|
|||||||
|
|
||||||
<SettingsSection title="Window">
|
<SettingsSection title="Window">
|
||||||
<NativeTitlebarSetting settings={settings} />
|
<NativeTitlebarSetting settings={settings} />
|
||||||
{type() !== "macos" && (
|
{platform.osType() !== "macos" && (
|
||||||
<ModelSettingRowBoolean
|
<ModelSettingRowBoolean
|
||||||
model={settings}
|
model={settings}
|
||||||
modelKey="hideWindowControls"
|
modelKey="hideWindowControls"
|
||||||
@@ -216,7 +216,7 @@ function NativeTitlebarSetting({ settings }: { settings: Settings }) {
|
|||||||
size="xs"
|
size="xs"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await patchModel(settings, { useNativeTitlebar: nativeTitlebar });
|
await patchModel(settings, { useNativeTitlebar: nativeTitlebar });
|
||||||
await invokeCmd("cmd_restart");
|
await rpc("cmd_restart");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Apply and Restart
|
Apply and Restart
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
|
||||||
import { useLicense } from "@yaakapp-internal/license";
|
import { useLicense } from "@yaakapp-internal/license";
|
||||||
import { Banner, HStack, Icon, VStack } from "@yaakapp-internal/ui";
|
import { Banner, HStack, Icon, VStack } from "@yaakapp-internal/ui";
|
||||||
import { differenceInDays } from "date-fns";
|
import { differenceInDays } from "date-fns";
|
||||||
@@ -12,6 +11,7 @@ import { Button } from "../core/Button";
|
|||||||
import { Link } from "../core/Link";
|
import { Link } from "../core/Link";
|
||||||
import { PlainInput } from "../core/PlainInput";
|
import { PlainInput } from "../core/PlainInput";
|
||||||
import { Separator } from "../core/Separator";
|
import { Separator } from "../core/Separator";
|
||||||
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
|
|
||||||
export function SettingsLicense() {
|
export function SettingsLicense() {
|
||||||
return (
|
return (
|
||||||
@@ -135,7 +135,7 @@ function SettingsLicenseCmp() {
|
|||||||
<Button
|
<Button
|
||||||
color="secondary"
|
color="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => openUrl("https://yaak.app/dashboard?intent=app.license.support")}
|
onClick={() => platform.openUrl("https://yaak.app/dashboard?intent=app.license.support")}
|
||||||
rightSlot={<Icon icon="external_link" />}
|
rightSlot={<Icon icon="external_link" />}
|
||||||
>
|
>
|
||||||
Direct Support
|
Direct Support
|
||||||
@@ -151,7 +151,7 @@ function SettingsLicenseCmp() {
|
|||||||
color="primary"
|
color="primary"
|
||||||
rightSlot={<Icon icon="external_link" />}
|
rightSlot={<Icon icon="external_link" />}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
openUrl(pricingUrl(`app.license.purchase.${check.data?.status ?? "unknown"}`))
|
platform.openUrl(pricingUrl(`app.license.purchase.${check.data?.status ?? "unknown"}`))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Purchase License
|
Purchase License
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
|
||||||
import type { Plugin } from "@yaakapp-internal/models";
|
import type { Plugin } from "@yaakapp-internal/models";
|
||||||
import { patchModel, pluginsAtom } from "@yaakapp-internal/models";
|
import { patchModel, pluginsAtom } from "@yaakapp-internal/models";
|
||||||
import type { PluginVersion } from "@yaakapp-internal/plugins";
|
import type { PluginVersion } from "@yaakapp-internal/plugins";
|
||||||
@@ -39,6 +38,7 @@ import { PlainInput } from "../core/PlainInput";
|
|||||||
import { TabContent, Tabs } from "../core/Tabs/Tabs";
|
import { TabContent, Tabs } from "../core/Tabs/Tabs";
|
||||||
import { EmptyStateText } from "../EmptyStateText";
|
import { EmptyStateText } from "../EmptyStateText";
|
||||||
import { SelectFile } from "../SelectFile";
|
import { SelectFile } from "../SelectFile";
|
||||||
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
|
|
||||||
interface SettingsPluginsProps {
|
interface SettingsPluginsProps {
|
||||||
defaultSubtab?: string;
|
defaultSubtab?: string;
|
||||||
@@ -113,7 +113,7 @@ export function SettingsPlugins({ defaultSubtab }: SettingsPluginsProps) {
|
|||||||
icon="help"
|
icon="help"
|
||||||
title="View documentation"
|
title="View documentation"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
openUrl("https://yaak.app/docs/plugin-development/plugins-quick-start")
|
platform.openUrl("https://yaak.app/docs/plugin-development/plugins-quick-start")
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
|
||||||
import { useLicense } from "@yaakapp-internal/license";
|
import { useLicense } from "@yaakapp-internal/license";
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { openSettings } from "../commands/openSettings";
|
import { openSettings } from "../commands/openSettings";
|
||||||
@@ -13,6 +12,7 @@ import { Dropdown } from "./core/Dropdown";
|
|||||||
import { Icon } from "@yaakapp-internal/ui";
|
import { Icon } from "@yaakapp-internal/ui";
|
||||||
import { IconButton } from "./core/IconButton";
|
import { IconButton } from "./core/IconButton";
|
||||||
import { KeyboardShortcutsDialog } from "./KeyboardShortcutsDialog";
|
import { KeyboardShortcutsDialog } from "./KeyboardShortcutsDialog";
|
||||||
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
|
|
||||||
export function SettingsDropdown() {
|
export function SettingsDropdown() {
|
||||||
const exportData = useExportData();
|
const exportData = useExportData();
|
||||||
@@ -62,7 +62,7 @@ export function SettingsDropdown() {
|
|||||||
{
|
{
|
||||||
label: "Create Run Button",
|
label: "Create Run Button",
|
||||||
leftSlot: <Icon icon="rocket" />,
|
leftSlot: <Icon icon="rocket" />,
|
||||||
onSelect: () => openUrl("https://yaak.app/button/new"),
|
onSelect: () => platform.openUrl("https://yaak.app/button/new"),
|
||||||
},
|
},
|
||||||
{ type: "separator", label: `Yaak v${appInfo.version}` },
|
{ type: "separator", label: `Yaak v${appInfo.version}` },
|
||||||
{
|
{
|
||||||
@@ -78,26 +78,26 @@ export function SettingsDropdown() {
|
|||||||
leftSlot: <Icon icon="circle_dollar_sign" />,
|
leftSlot: <Icon icon="circle_dollar_sign" />,
|
||||||
rightSlot: <Icon icon="external_link" color="success" className="opacity-60" />,
|
rightSlot: <Icon icon="external_link" color="success" className="opacity-60" />,
|
||||||
onSelect: () =>
|
onSelect: () =>
|
||||||
openUrl(pricingUrl(`app.menu.purchase.${check.data?.status ?? "unknown"}`)),
|
platform.openUrl(pricingUrl(`app.menu.purchase.${check.data?.status ?? "unknown"}`)),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Install CLI",
|
label: "Install CLI",
|
||||||
hidden: appInfo.cliVersion != null,
|
hidden: appInfo.cliVersion != null,
|
||||||
leftSlot: <Icon icon="square_terminal" />,
|
leftSlot: <Icon icon="square_terminal" />,
|
||||||
rightSlot: <Icon icon="external_link" color="secondary" />,
|
rightSlot: <Icon icon="external_link" color="secondary" />,
|
||||||
onSelect: () => openUrl("https://yaak.app/docs/cli"),
|
onSelect: () => platform.openUrl("https://yaak.app/docs/cli"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Feedback",
|
label: "Feedback",
|
||||||
leftSlot: <Icon icon="chat" />,
|
leftSlot: <Icon icon="chat" />,
|
||||||
rightSlot: <Icon icon="external_link" color="secondary" />,
|
rightSlot: <Icon icon="external_link" color="secondary" />,
|
||||||
onSelect: () => openUrl("https://yaak.app/feedback"),
|
onSelect: () => platform.openUrl("https://yaak.app/feedback"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Changelog",
|
label: "Changelog",
|
||||||
leftSlot: <Icon icon="cake" />,
|
leftSlot: <Icon icon="cake" />,
|
||||||
rightSlot: <Icon icon="external_link" color="secondary" />,
|
rightSlot: <Icon icon="external_link" color="secondary" />,
|
||||||
onSelect: () => openUrl(`https://yaak.app/changelog/${appInfo.version}`),
|
onSelect: () => platform.openUrl(`https://yaak.app/changelog/${appInfo.version}`),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ import { getFolderActions } from "../hooks/useFolderActions";
|
|||||||
import { getGrpcRequestActions } from "../hooks/useGrpcRequestActions";
|
import { getGrpcRequestActions } from "../hooks/useGrpcRequestActions";
|
||||||
import { useHotKey } from "../hooks/useHotKey";
|
import { useHotKey } from "../hooks/useHotKey";
|
||||||
import { getHttpRequestActions } from "../hooks/useHttpRequestActions";
|
import { getHttpRequestActions } from "../hooks/useHttpRequestActions";
|
||||||
import { useListenToTauriEvent } from "../hooks/useListenToTauriEvent";
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
|
import { usePlatformEvent } from "../hooks/usePlatformEvent";
|
||||||
import { getModelAncestors } from "../hooks/useModelAncestors";
|
import { getModelAncestors } from "../hooks/useModelAncestors";
|
||||||
import { sendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
|
import { sendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
|
||||||
import { useSidebarHidden } from "../hooks/useSidebarHidden";
|
import { useSidebarHidden } from "../hooks/useSidebarHidden";
|
||||||
@@ -112,6 +113,7 @@ function Sidebar({ className }: { className?: string }) {
|
|||||||
const treeId = `tree.${activeWorkspaceId ?? "unknown"}`;
|
const treeId = `tree.${activeWorkspaceId ?? "unknown"}`;
|
||||||
const filterText = useAtomValue(sidebarFilterAtom);
|
const filterText = useAtomValue(sidebarFilterAtom);
|
||||||
const [tree, allFields, emptyFilterSuggestions] = useAtomValue(sidebarTreeAtom) ?? [];
|
const [tree, allFields, emptyFilterSuggestions] = useAtomValue(sidebarTreeAtom) ?? [];
|
||||||
|
|
||||||
const wrapperRef = useRef<HTMLElement>(null);
|
const wrapperRef = useRef<HTMLElement>(null);
|
||||||
const treeRef = useRef<TreeHandle>(null);
|
const treeRef = useRef<TreeHandle>(null);
|
||||||
const filterRef = useRef<InputHandle>(null);
|
const filterRef = useRef<InputHandle>(null);
|
||||||
@@ -130,11 +132,17 @@ function Sidebar({ className }: { className?: string }) {
|
|||||||
if (!didFocus) filterRef.current?.focus();
|
if (!didFocus) filterRef.current?.focus();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Focus any new sidebar models when created
|
// Focus new sidebar models created by the user in this window. Writes from other
|
||||||
useListenToTauriEvent<ModelPayload>("model_write", ({ payload }) => {
|
// sources (import, sync, CLI) can carry thousands of models and shouldn't move
|
||||||
if (!isSidebarLeafModel(payload.model)) return;
|
// the selection.
|
||||||
if (!(payload.change.type === "upsert" && payload.change.created)) return;
|
usePlatformEvent<ModelPayload[]>("model_writes", (payloads) => {
|
||||||
treeRef.current?.selectItem(payload.model.id, true);
|
for (const payload of payloads) {
|
||||||
|
if (payload.updateSource.type !== "window") continue;
|
||||||
|
if (payload.updateSource.label !== platform.window.label) continue;
|
||||||
|
if (!isSidebarLeafModel(payload.model)) continue;
|
||||||
|
if (!(payload.change.type === "upsert" && payload.change.created)) continue;
|
||||||
|
treeRef.current?.selectItem(payload.model.id, true);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -724,7 +732,11 @@ function Sidebar({ className }: { className?: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Sidebar;
|
// Memoized so route navigations (which re-render the workspace layout) don't
|
||||||
|
// re-render the sidebar subtree. In large workspaces a sidebar re-render is
|
||||||
|
// very expensive: it re-renders DndContext, whose context churn re-renders
|
||||||
|
// every visible TreeItem regardless of their memo comparators.
|
||||||
|
export default memo(Sidebar);
|
||||||
|
|
||||||
function getGitContextMenuItems({
|
function getGitContextMenuItems({
|
||||||
items,
|
items,
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { readDir } from "@tauri-apps/plugin-fs";
|
|
||||||
import { Banner, VStack } from "@yaakapp-internal/ui";
|
import { Banner, VStack } from "@yaakapp-internal/ui";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
|
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
|
||||||
@@ -6,6 +5,7 @@ import { Button } from "./core/Button";
|
|||||||
import { Checkbox } from "./core/Checkbox";
|
import { Checkbox } from "./core/Checkbox";
|
||||||
import { SettingRowBoolean, SettingRowDirectory } from "./core/SettingRow";
|
import { SettingRowBoolean, SettingRowDirectory } from "./core/SettingRow";
|
||||||
import { SelectFile } from "./SelectFile";
|
import { SelectFile } from "./SelectFile";
|
||||||
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
|
|
||||||
export interface SyncToFilesystemSettingProps {
|
export interface SyncToFilesystemSettingProps {
|
||||||
layout?: "form" | "settings";
|
layout?: "form" | "settings";
|
||||||
@@ -24,7 +24,7 @@ export function SyncToFilesystemSetting({
|
|||||||
|
|
||||||
const handleFilePathChange = async (filePath: string | null) => {
|
const handleFilePathChange = async (filePath: string | null) => {
|
||||||
if (filePath != null) {
|
if (filePath != null) {
|
||||||
const files = await readDir(filePath);
|
const files = await platform.files.readDir(filePath);
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
setSyncDir(filePath);
|
setSyncDir(filePath);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
|
||||||
import { VStack } from "@yaakapp-internal/ui";
|
import { VStack } from "@yaakapp-internal/ui";
|
||||||
import { useCallback, useRef } from "react";
|
import { useCallback, useRef } from "react";
|
||||||
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
|
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
|
||||||
import type { PairEditorHandle, PairEditorProps } from "./core/PairEditor";
|
import type { EditablePair, PairEditorHandle, PairEditorProps } from "./core/PairEditor";
|
||||||
import { PairOrBulkEditor } from "./core/PairOrBulkEditor";
|
import { PairOrBulkEditor } from "./core/PairOrBulkEditor";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
forceUpdateKey: string;
|
forceUpdateKey: string;
|
||||||
pairs: HttpRequest["headers"];
|
pairs: EditablePair[];
|
||||||
stateKey: PairEditorProps["stateKey"];
|
stateKey: PairEditorProps["stateKey"];
|
||||||
onChange: (headers: HttpRequest["urlParameters"]) => void;
|
onChange: PairEditorProps["onChange"];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function UrlParametersEditor({ pairs, forceUpdateKey, onChange, stateKey }: Props) {
|
export function UrlParametersEditor({ pairs, forceUpdateKey, onChange, stateKey }: Props) {
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import type { WebsocketRequest } from "@yaakapp-internal/models";
|
import type { WebsocketRequest } from "@yaakapp-internal/models";
|
||||||
import { patchModel } from "@yaakapp-internal/models";
|
import {
|
||||||
|
flushAllModelWrites,
|
||||||
|
getModel,
|
||||||
|
patchModel,
|
||||||
|
patchModelDebounced,
|
||||||
|
} from "@yaakapp-internal/models";
|
||||||
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
|
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
|
||||||
import { closeWebsocket, connectWebsocket, sendWebsocket } from "@yaakapp-internal/ws";
|
import { closeWebsocket, connectWebsocket, sendWebsocket } from "@yaakapp-internal/ws";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
@@ -8,8 +13,7 @@ import type { CSSProperties } from "react";
|
|||||||
import { useCallback, useMemo, useRef } from "react";
|
import { useCallback, useMemo, useRef } from "react";
|
||||||
import { getActiveCookieJar } from "../hooks/useActiveCookieJar";
|
import { getActiveCookieJar } from "../hooks/useActiveCookieJar";
|
||||||
import { getActiveEnvironment } from "../hooks/useActiveEnvironment";
|
import { getActiveEnvironment } from "../hooks/useActiveEnvironment";
|
||||||
import { activeRequestIdAtom } from "../hooks/useActiveRequestId";
|
import { allRequestUrlsAtom } from "../hooks/useAllRequests";
|
||||||
import { allRequestsAtom } from "../hooks/useAllRequests";
|
|
||||||
import { useAuthTab } from "../hooks/useAuthTab";
|
import { useAuthTab } from "../hooks/useAuthTab";
|
||||||
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
|
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
|
||||||
import { useHeadersTab } from "../hooks/useHeadersTab";
|
import { useHeadersTab } from "../hooks/useHeadersTab";
|
||||||
@@ -18,17 +22,15 @@ import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
|
|||||||
import { activeWebsocketConnectionAtom } from "../hooks/usePinnedWebsocketConnection";
|
import { activeWebsocketConnectionAtom } from "../hooks/usePinnedWebsocketConnection";
|
||||||
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
|
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
|
||||||
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
|
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
|
||||||
import { deepEqualAtom } from "../lib/atoms";
|
|
||||||
import { languageFromContentType } from "../lib/contentType";
|
import { languageFromContentType } from "../lib/contentType";
|
||||||
import { generateId } from "../lib/generateId";
|
import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
|
||||||
import { extractPathPlaceholders } from "../lib/pathPlaceholders";
|
|
||||||
import { prepareImportQuerystring } from "../lib/prepareImportQuerystring";
|
import { prepareImportQuerystring } from "../lib/prepareImportQuerystring";
|
||||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
import { resolvedModelName } from "../lib/resolvedModelName";
|
||||||
import { CountBadge } from "./core/CountBadge";
|
import { CountBadge } from "./core/CountBadge";
|
||||||
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
||||||
|
import { getUrlCompletionConfig } from "./core/Editor/url/completion";
|
||||||
import { Editor } from "./core/Editor/LazyEditor";
|
import { Editor } from "./core/Editor/LazyEditor";
|
||||||
import { IconButton } from "./core/IconButton";
|
import { IconButton } from "./core/IconButton";
|
||||||
import type { Pair } from "./core/PairEditor";
|
|
||||||
import { PlainInput } from "./core/PlainInput";
|
import { PlainInput } from "./core/PlainInput";
|
||||||
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
|
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
|
||||||
import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs";
|
import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs";
|
||||||
@@ -54,15 +56,12 @@ const TAB_SETTINGS = "settings";
|
|||||||
const TAB_DESCRIPTION = "description";
|
const TAB_DESCRIPTION = "description";
|
||||||
const TABS_STORAGE_KEY = "websocket_request_tabs";
|
const TABS_STORAGE_KEY = "websocket_request_tabs";
|
||||||
|
|
||||||
const nonActiveRequestUrlsAtom = atom((get) => {
|
// Derived from the identity-stable URL list so this only recomputes when a URL
|
||||||
const activeRequestId = get(activeRequestIdAtom);
|
// actually changes. The active request's own URL is included, but exact matches
|
||||||
const requests = get(allRequestsAtom);
|
// are filtered out at completion time by genericCompletion.
|
||||||
return requests
|
const requestUrlOptionsAtom = atom((get): GenericCompletionOption[] =>
|
||||||
.filter((r) => r.id !== activeRequestId)
|
get(allRequestUrlsAtom).map((url) => ({ type: "constant", label: url })),
|
||||||
.map((r): GenericCompletionOption => ({ type: "constant", label: r.url }));
|
);
|
||||||
});
|
|
||||||
|
|
||||||
const memoNotActiveRequestUrlsAtom = deepEqualAtom(nonActiveRequestUrlsAtom);
|
|
||||||
|
|
||||||
export function WebsocketRequestPane({ style, fullHeight, className, activeRequest }: Props) {
|
export function WebsocketRequestPane({ style, fullHeight, className, activeRequest }: Props) {
|
||||||
const activeRequestId = activeRequest.id;
|
const activeRequestId = activeRequest.id;
|
||||||
@@ -83,20 +82,33 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { urlParameterPairs, urlParametersKey } = useMemo(() => {
|
// Renaming a path placeholder has to rewrite the URL and rename the parameter together, or the
|
||||||
const placeholderNames = extractPathPlaceholders(activeRequest.url);
|
// value detaches from the placeholder.
|
||||||
const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value);
|
// NOTE: Reads the request fresh rather than closing over `activeRequest`. The row that calls this
|
||||||
const items: Pair[] = [...nonEmptyParameters];
|
// holds onto it until the URL's placeholders change, so a captured request would go stale and
|
||||||
for (const name of placeholderNames) {
|
// patch its parameter list back over newer edits.
|
||||||
const item = items.find((p) => p.name === name);
|
const handleRenamePathPlaceholder = useCallback(
|
||||||
if (item) {
|
(oldName: string, newName: string) => {
|
||||||
item.readOnlyName = true;
|
const request = getModel("websocket_request", activeRequestId);
|
||||||
} else {
|
if (request == null) return false;
|
||||||
items.push({ name, value: "", enabled: true, readOnlyName: true, id: generateId() });
|
|
||||||
}
|
const patch = renamePathPlaceholder(request, oldName, newName);
|
||||||
}
|
if (patch == null) return false; // Unusable name, so the editor reverts the field
|
||||||
return { urlParameterPairs: items, urlParametersKey: placeholderNames.join(",") };
|
void patchModel(request, patch);
|
||||||
}, [activeRequest.url, activeRequest.urlParameters]);
|
return true;
|
||||||
|
},
|
||||||
|
[activeRequestId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { urlParameterPairs, urlParametersKey } = useMemo(
|
||||||
|
() =>
|
||||||
|
derivePathPlaceholderPairs(
|
||||||
|
activeRequest.url,
|
||||||
|
activeRequest.urlParameters,
|
||||||
|
handleRenamePathPlaceholder,
|
||||||
|
),
|
||||||
|
[activeRequest.url, activeRequest.urlParameters, handleRenamePathPlaceholder],
|
||||||
|
);
|
||||||
|
|
||||||
const tabs = useMemo<TabItem[]>(() => {
|
const tabs = useMemo<TabItem[]>(() => {
|
||||||
return [
|
return [
|
||||||
@@ -127,23 +139,15 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
|||||||
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
|
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
|
||||||
const connection = useAtomValue(activeWebsocketConnectionAtom);
|
const connection = useAtomValue(activeWebsocketConnectionAtom);
|
||||||
|
|
||||||
const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom);
|
const autocompleteUrls = useAtomValue(requestUrlOptionsAtom);
|
||||||
|
|
||||||
const autocomplete: GenericCompletionConfig = useMemo(
|
const autocomplete: GenericCompletionConfig = useMemo(
|
||||||
() => ({
|
() => getUrlCompletionConfig(autocompleteUrls),
|
||||||
minMatch: 3,
|
|
||||||
options:
|
|
||||||
autocompleteUrls.length > 0
|
|
||||||
? autocompleteUrls
|
|
||||||
: [
|
|
||||||
{ label: "http://", type: "constant" },
|
|
||||||
{ label: "https://", type: "constant" },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
[autocompleteUrls],
|
[autocompleteUrls],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleConnect = useCallback(async () => {
|
const handleConnect = useCallback(async () => {
|
||||||
|
await flushAllModelWrites(); // The backend reads the request from the DB
|
||||||
await connectWebsocket({
|
await connectWebsocket({
|
||||||
requestId: activeRequest.id,
|
requestId: activeRequest.id,
|
||||||
environmentId: getActiveEnvironment()?.id ?? null,
|
environmentId: getActiveEnvironment()?.id ?? null,
|
||||||
@@ -153,6 +157,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
|||||||
|
|
||||||
const handleSend = useCallback(async () => {
|
const handleSend = useCallback(async () => {
|
||||||
if (connection == null) return;
|
if (connection == null) return;
|
||||||
|
await flushAllModelWrites(); // The backend reads the message from the DB
|
||||||
await sendWebsocket({
|
await sendWebsocket({
|
||||||
connectionId: connection?.id,
|
connectionId: connection?.id,
|
||||||
environmentId: getActiveEnvironment()?.id ?? null,
|
environmentId: getActiveEnvironment()?.id ?? null,
|
||||||
@@ -165,7 +170,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
|||||||
}, [connection]);
|
}, [connection]);
|
||||||
|
|
||||||
const handleUrlChange = useCallback(
|
const handleUrlChange = useCallback(
|
||||||
(url: string) => patchModel(activeRequest, { url }),
|
(url: string) => patchModelDebounced(activeRequest, { url }),
|
||||||
[activeRequest],
|
[activeRequest],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -249,7 +254,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
|||||||
forceUpdateKey={forceUpdateKey}
|
forceUpdateKey={forceUpdateKey}
|
||||||
headers={activeRequest.headers}
|
headers={activeRequest.headers}
|
||||||
stateKey={`headers.${activeRequest.id}`}
|
stateKey={`headers.${activeRequest.id}`}
|
||||||
onChange={(headers) => patchModel(activeRequest, { headers })}
|
onChange={(headers) => patchModelDebounced(activeRequest, { headers })}
|
||||||
/>
|
/>
|
||||||
</TabContent>
|
</TabContent>
|
||||||
<TabContent value={TAB_PARAMS}>
|
<TabContent value={TAB_PARAMS}>
|
||||||
@@ -257,7 +262,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
|||||||
stateKey={`params.${activeRequest.id}`}
|
stateKey={`params.${activeRequest.id}`}
|
||||||
forceUpdateKey={forceUpdateKey + urlParametersKey}
|
forceUpdateKey={forceUpdateKey + urlParametersKey}
|
||||||
pairs={urlParameterPairs}
|
pairs={urlParameterPairs}
|
||||||
onChange={(urlParameters) => patchModel(activeRequest, { urlParameters })}
|
onChange={(urlParameters) => patchModelDebounced(activeRequest, { urlParameters })}
|
||||||
/>
|
/>
|
||||||
</TabContent>
|
</TabContent>
|
||||||
<TabContent value={TAB_MESSAGE}>
|
<TabContent value={TAB_MESSAGE}>
|
||||||
@@ -269,7 +274,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
|||||||
heightMode={fullHeight ? "full" : "auto"}
|
heightMode={fullHeight ? "full" : "auto"}
|
||||||
defaultValue={activeRequest.message}
|
defaultValue={activeRequest.message}
|
||||||
language={messageLanguage}
|
language={messageLanguage}
|
||||||
onChange={(message) => patchModel(activeRequest, { message })}
|
onChange={(message) => patchModelDebounced(activeRequest, { message })}
|
||||||
stateKey={`json.${activeRequest.id}`}
|
stateKey={`json.${activeRequest.id}`}
|
||||||
/>
|
/>
|
||||||
</TabContent>
|
</TabContent>
|
||||||
@@ -286,7 +291,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
|||||||
className="font-sans text-xl! px-0!"
|
className="font-sans text-xl! px-0!"
|
||||||
containerClassName="border-0"
|
containerClassName="border-0"
|
||||||
placeholder={resolvedModelName(activeRequest)}
|
placeholder={resolvedModelName(activeRequest)}
|
||||||
onChange={(name) => patchModel(activeRequest, { name })}
|
onChange={(name) => patchModelDebounced(activeRequest, { name })}
|
||||||
/>
|
/>
|
||||||
<MarkdownEditor
|
<MarkdownEditor
|
||||||
name="request-description"
|
name="request-description"
|
||||||
@@ -294,7 +299,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
|||||||
defaultValue={activeRequest.description}
|
defaultValue={activeRequest.description}
|
||||||
stateKey={`description.${activeRequest.id}`}
|
stateKey={`description.${activeRequest.id}`}
|
||||||
forceUpdateKey={forceUpdateKey}
|
forceUpdateKey={forceUpdateKey}
|
||||||
onChange={(description) => patchModel(activeRequest, { description })}
|
onChange={(description) => patchModelDebounced(activeRequest, { description })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TabContent>
|
</TabContent>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type } from "@tauri-apps/plugin-os";
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
import { settingsAtom, workspacesAtom } from "@yaakapp-internal/models";
|
import { settingsAtom, workspacesAtom } from "@yaakapp-internal/models";
|
||||||
import { Banner, HeaderSize, HStack, SidebarLayout } from "@yaakapp-internal/ui";
|
import { Banner, HeaderSize, HStack, SidebarLayout } from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
@@ -53,7 +53,7 @@ export function Workspace() {
|
|||||||
|
|
||||||
const workspaces = useAtomValue(workspacesAtom);
|
const workspaces = useAtomValue(workspacesAtom);
|
||||||
const settings = useAtomValue(settingsAtom);
|
const settings = useAtomValue(settingsAtom);
|
||||||
const osType = type();
|
const osType = platform.osType();
|
||||||
const [width, setWidth] = useSidebarWidth();
|
const [width, setWidth] = useSidebarWidth();
|
||||||
const [sidebarHidden, setSidebarHidden] = useSidebarHidden();
|
const [sidebarHidden, setSidebarHidden] = useSidebarHidden();
|
||||||
const [floatingSidebarHidden, setFloatingSidebarHidden] = useFloatingSidebarHidden();
|
const [floatingSidebarHidden, setFloatingSidebarHidden] = useFloatingSidebarHidden();
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { open } from "@tauri-apps/plugin-dialog";
|
|
||||||
import { revealItemInDir } from "@tauri-apps/plugin-opener";
|
|
||||||
import { getModel, settingsAtom, workspacesAtom } from "@yaakapp-internal/models";
|
import { getModel, settingsAtom, workspacesAtom } from "@yaakapp-internal/models";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
@@ -16,6 +14,7 @@ import { useCreateWorkspace } from "../hooks/useCreateWorkspace";
|
|||||||
import { useDeleteSendHistory } from "../hooks/useDeleteSendHistory";
|
import { useDeleteSendHistory } from "../hooks/useDeleteSendHistory";
|
||||||
import { useWorkspaceActions } from "../hooks/useWorkspaceActions";
|
import { useWorkspaceActions } from "../hooks/useWorkspaceActions";
|
||||||
import { showDialog } from "../lib/dialog";
|
import { showDialog } from "../lib/dialog";
|
||||||
|
import { importData } from "../lib/importData";
|
||||||
import { jotaiStore } from "../lib/jotai";
|
import { jotaiStore } from "../lib/jotai";
|
||||||
import { revealInFinderText } from "../lib/reveal";
|
import { revealInFinderText } from "../lib/reveal";
|
||||||
import { CloneGitRepositoryDialog } from "./CloneGitRepositoryDialog";
|
import { CloneGitRepositoryDialog } from "./CloneGitRepositoryDialog";
|
||||||
@@ -26,6 +25,7 @@ import { Icon } from "@yaakapp-internal/ui";
|
|||||||
import type { RadioDropdownItem } from "./core/RadioDropdown";
|
import type { RadioDropdownItem } from "./core/RadioDropdown";
|
||||||
import { RadioDropdown } from "./core/RadioDropdown";
|
import { RadioDropdown } from "./core/RadioDropdown";
|
||||||
import { SwitchWorkspaceDialog } from "./SwitchWorkspaceDialog";
|
import { SwitchWorkspaceDialog } from "./SwitchWorkspaceDialog";
|
||||||
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
|
|
||||||
type Props = Pick<ButtonProps, "className" | "justify" | "forDropdown" | "leftSlot">;
|
type Props = Pick<ButtonProps, "className" | "justify" | "forDropdown" | "leftSlot">;
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
|
|||||||
label: "Open Folder",
|
label: "Open Folder",
|
||||||
leftSlot: <Icon icon="folder_open" />,
|
leftSlot: <Icon icon="folder_open" />,
|
||||||
onSelect: async () => {
|
onSelect: async () => {
|
||||||
const dir = await open({
|
const dir = await platform.dialog.open({
|
||||||
title: "Select Workspace Directory",
|
title: "Select Workspace Directory",
|
||||||
directory: true,
|
directory: true,
|
||||||
multiple: false,
|
multiple: false,
|
||||||
@@ -90,6 +90,11 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
|
|||||||
leftSlot: <Icon icon="hard_drive_download" />,
|
leftSlot: <Icon icon="hard_drive_download" />,
|
||||||
onSelect: openCloneGitRepositoryDialog,
|
onSelect: openCloneGitRepositoryDialog,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Import Data",
|
||||||
|
leftSlot: <Icon icon="folder_input" />,
|
||||||
|
onSelect: () => importData.mutate(),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -114,7 +119,7 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
|
|||||||
leftSlot: <Icon icon="folder_symlink" />,
|
leftSlot: <Icon icon="folder_symlink" />,
|
||||||
onSelect: async () => {
|
onSelect: async () => {
|
||||||
if (workspaceMeta?.settingSyncDir == null) return;
|
if (workspaceMeta?.settingSyncDir == null) return;
|
||||||
await revealItemInDir(workspaceMeta.settingSyncDir);
|
await platform.revealItemInDir(workspaceMeta.settingSyncDir);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { CookieDropdown } from "./CookieDropdown";
|
|||||||
import { IconButton } from "./core/IconButton";
|
import { IconButton } from "./core/IconButton";
|
||||||
import { PillButton } from "./core/PillButton";
|
import { PillButton } from "./core/PillButton";
|
||||||
import { EnvironmentActionsDropdown } from "./EnvironmentActionsDropdown";
|
import { EnvironmentActionsDropdown } from "./EnvironmentActionsDropdown";
|
||||||
import { ImportCurlButton } from "./ImportCurlButton";
|
import { ImportCurl } from "./ImportCurl";
|
||||||
import { LicenseBadge } from "./LicenseBadge";
|
import { LicenseBadge } from "./LicenseBadge";
|
||||||
import { RecentRequestsDropdown } from "./RecentRequestsDropdown";
|
import { RecentRequestsDropdown } from "./RecentRequestsDropdown";
|
||||||
import { SettingsDropdown } from "./SettingsDropdown";
|
import { SettingsDropdown } from "./SettingsDropdown";
|
||||||
@@ -56,7 +56,7 @@ export const WorkspaceHeader = memo(function WorkspaceHeader({
|
|||||||
<RecentRequestsDropdown />
|
<RecentRequestsDropdown />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 flex gap-1 items-center h-full justify-end pointer-events-none pr-1">
|
<div className="flex-1 flex gap-1 items-center h-full justify-end pointer-events-none pr-1">
|
||||||
<ImportCurlButton />
|
<ImportCurl />
|
||||||
{showEncryptionSetup ? (
|
{showEncryptionSetup ? (
|
||||||
<PillButton color="danger" onClick={setupOrConfigureEncryption}>
|
<PillButton color="danger" onClick={setupOrConfigureEncryption}>
|
||||||
Enter Encryption Key
|
Enter Encryption Key
|
||||||
|
|||||||
@@ -36,12 +36,16 @@ import { fireAndForget } from "../../lib/fireAndForget";
|
|||||||
import { ErrorBoundary } from "../ErrorBoundary";
|
import { ErrorBoundary } from "../ErrorBoundary";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { Hotkey } from "./Hotkey";
|
import { Hotkey } from "./Hotkey";
|
||||||
|
import { IconButton } from "./IconButton";
|
||||||
|
import type { SeparatorAction } from "./Separator";
|
||||||
import { Separator } from "./Separator";
|
import { Separator } from "./Separator";
|
||||||
|
|
||||||
export type DropdownItemSeparator = {
|
export type DropdownItemSeparator = {
|
||||||
type: "separator";
|
type: "separator";
|
||||||
label?: ReactNode;
|
label?: ReactNode;
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
|
/** A control shown beside the label, eg. revealing the labelled file on disk. */
|
||||||
|
action?: SeparatorAction;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DropdownItemContent = {
|
export type DropdownItemContent = {
|
||||||
@@ -66,6 +70,12 @@ export type DropdownItemDefault = {
|
|||||||
submenu?: DropdownItem[];
|
submenu?: DropdownItem[];
|
||||||
/** If true, submenu opens on click instead of hover */
|
/** If true, submenu opens on click instead of hover */
|
||||||
submenuOpenOnClick?: boolean;
|
submenuOpenOnClick?: boolean;
|
||||||
|
/**
|
||||||
|
* How the submenu opens. "row" (default) opens it from the row itself (hover, or click
|
||||||
|
* with submenuOpenOnClick). "button" keeps the row selectable via onSelect and renders
|
||||||
|
* a dedicated button on the right that opens the submenu.
|
||||||
|
*/
|
||||||
|
submenuTrigger?: "row" | "button";
|
||||||
icon?: IconProps["icon"];
|
icon?: IconProps["icon"];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -233,23 +243,40 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
|
|||||||
|
|
||||||
export interface ContextMenuProps {
|
export interface ContextMenuProps {
|
||||||
triggerPosition: { x: number; y: number } | null;
|
triggerPosition: { x: number; y: number } | null;
|
||||||
|
/**
|
||||||
|
* The box the menu belongs to, when the trigger has one.
|
||||||
|
*
|
||||||
|
* Placement aligns to the trigger's left or right edge depending on the room beside it, so a
|
||||||
|
* menu opened from an element wants its real rect. Without one the position is treated as a
|
||||||
|
* zero-width point, which is right for a menu opened at the cursor and wrong for one opened
|
||||||
|
* from a button.
|
||||||
|
*/
|
||||||
|
triggerRect?: Pick<DOMRect, "top" | "bottom" | "left" | "right">;
|
||||||
|
/**
|
||||||
|
* The element the menu belongs to, so a click on it doesn't count as a click outside.
|
||||||
|
*
|
||||||
|
* Without this the trigger gets both: the outside-click handler closes the menu on mousedown,
|
||||||
|
* then the trigger's own click opens it again, and pressing it looks like it does nothing.
|
||||||
|
*/
|
||||||
|
triggerRef?: RefObject<HTMLElement | null>;
|
||||||
className?: string;
|
className?: string;
|
||||||
items: DropdownProps["items"];
|
items: DropdownProps["items"];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ContextMenu = forwardRef<DropdownRef, ContextMenuProps>(function ContextMenu(
|
export const ContextMenu = forwardRef<DropdownRef, ContextMenuProps>(function ContextMenu(
|
||||||
{ triggerPosition, className, items, onClose },
|
{ triggerPosition, triggerRect, triggerRef, className, items, onClose },
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const triggerShape = useMemo(
|
const triggerShape = useMemo(
|
||||||
() => ({
|
() =>
|
||||||
top: triggerPosition?.y ?? 0,
|
triggerRect ?? {
|
||||||
bottom: triggerPosition?.y ?? 0,
|
top: triggerPosition?.y ?? 0,
|
||||||
left: triggerPosition?.x ?? 0,
|
bottom: triggerPosition?.y ?? 0,
|
||||||
right: triggerPosition?.x ?? 0,
|
left: triggerPosition?.x ?? 0,
|
||||||
}),
|
right: triggerPosition?.x ?? 0,
|
||||||
[triggerPosition],
|
},
|
||||||
|
[triggerPosition, triggerRect],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (triggerPosition == null) return null;
|
if (triggerPosition == null) return null;
|
||||||
@@ -259,9 +286,13 @@ export const ContextMenu = forwardRef<DropdownRef, ContextMenuProps>(function Co
|
|||||||
isOpen={true} // Always open because we return null if not
|
isOpen={true} // Always open because we return null if not
|
||||||
className={className}
|
className={className}
|
||||||
defaultSelectedIndex={null}
|
defaultSelectedIndex={null}
|
||||||
|
// A menu opened from an element points back at it. One opened at the cursor has nothing
|
||||||
|
// to point at, so it goes without.
|
||||||
|
showTriangle={triggerRect != null}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
items={items}
|
items={items}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
|
triggerRef={triggerRef}
|
||||||
triggerShape={triggerShape}
|
triggerShape={triggerShape}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -277,7 +308,9 @@ interface MenuProps {
|
|||||||
fullWidth?: boolean;
|
fullWidth?: boolean;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
items: DropdownItem[];
|
items: DropdownItem[];
|
||||||
triggerRef?: RefObject<HTMLButtonElement | null>;
|
// Any element, not just a button: a menu can be opened from anything, and this is only ever
|
||||||
|
// used to ask whether a click landed on the trigger
|
||||||
|
triggerRef?: RefObject<HTMLElement | null>;
|
||||||
isSubmenu?: boolean;
|
isSubmenu?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -479,9 +512,15 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!item.keepOpenOnSelect) handleCloseAll();
|
if (!item.keepOpenOnSelect) {
|
||||||
|
handleCloseAll();
|
||||||
|
} else if (isSubmenu) {
|
||||||
|
// Keep the parent menu open, but close this submenu — its items may no
|
||||||
|
// longer describe the row after the action (e.g. Pin → Unpin, Remove)
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[handleCloseAll, setSelectedIndex],
|
[handleCloseAll, handleClose, isSubmenu, setSelectedIndex],
|
||||||
);
|
);
|
||||||
|
|
||||||
useImperativeHandle(ref, () => {
|
useImperativeHandle(ref, () => {
|
||||||
@@ -606,7 +645,7 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
|
|||||||
const item = filteredItems[selectedIndex ?? -1];
|
const item = filteredItems[selectedIndex ?? -1];
|
||||||
if (!item || item.type === "separator" || item.type === "content") return;
|
if (!item || item.type === "separator" || item.type === "content") return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (item.submenu) {
|
if (item.submenu && item.submenuTrigger !== "button") {
|
||||||
const parent = document.activeElement as HTMLButtonElement;
|
const parent = document.activeElement as HTMLButtonElement;
|
||||||
if (parent) {
|
if (parent) {
|
||||||
setActiveSubmenu({ item, parent, viaKeyboard: true });
|
setActiveSubmenu({ item, parent, viaKeyboard: true });
|
||||||
@@ -625,9 +664,11 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
|
|||||||
clearTimeout(submenuTimeoutRef.current);
|
clearTimeout(submenuTimeoutRef.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.submenu && !item.submenuOpenOnClick) {
|
if (item.submenu && !item.submenuOpenOnClick && item.submenuTrigger !== "button") {
|
||||||
setActiveSubmenu({ item, parent });
|
setActiveSubmenu({ item, parent });
|
||||||
} else if (activeSubmenu) {
|
} else if (activeSubmenu && activeSubmenu.item !== item) {
|
||||||
|
// Hovering the row that owns the open submenu must not dismiss it — the
|
||||||
|
// pointer travels across the row on its way to a button-triggered submenu
|
||||||
submenuTimeoutRef.current = window.setTimeout(() => {
|
submenuTimeoutRef.current = window.setTimeout(() => {
|
||||||
const submenuEl = submenuRef.current;
|
const submenuEl = submenuRef.current;
|
||||||
if (!submenuEl || !activeSubmenu) {
|
if (!submenuEl || !activeSubmenu) {
|
||||||
@@ -753,6 +794,7 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
|
|||||||
// oxlint-disable-next-line no-array-index-key -- Nothing else available
|
// oxlint-disable-next-line no-array-index-key -- Nothing else available
|
||||||
key={i}
|
key={i}
|
||||||
className={classNames("my-1.5", item.label ? "ml-2" : null)}
|
className={classNames("my-1.5", item.label ? "ml-2" : null)}
|
||||||
|
action={item.action}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</Separator>
|
</Separator>
|
||||||
@@ -774,6 +816,7 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
|
|||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
onHover={handleItemHover}
|
onHover={handleItemHover}
|
||||||
|
onOpenSubmenu={(item, el) => setActiveSubmenu({ item, parent: el })}
|
||||||
// oxlint-disable-next-line no-array-index-key -- It's fine
|
// oxlint-disable-next-line no-array-index-key -- It's fine
|
||||||
key={i}
|
key={i}
|
||||||
item={item}
|
item={item}
|
||||||
@@ -845,6 +888,7 @@ interface MenuItemProps {
|
|||||||
onSelect: (item: DropdownItemDefault, el?: HTMLButtonElement) => Promise<void>;
|
onSelect: (item: DropdownItemDefault, el?: HTMLButtonElement) => Promise<void>;
|
||||||
onFocus: (item: DropdownItemDefault) => void;
|
onFocus: (item: DropdownItemDefault) => void;
|
||||||
onHover: (item: DropdownItemDefault, el: HTMLButtonElement) => void;
|
onHover: (item: DropdownItemDefault, el: HTMLButtonElement) => void;
|
||||||
|
onOpenSubmenu: (item: DropdownItemDefault, el: HTMLButtonElement) => void;
|
||||||
focused: boolean;
|
focused: boolean;
|
||||||
isParentOfActiveSubmenu?: boolean;
|
isParentOfActiveSubmenu?: boolean;
|
||||||
}
|
}
|
||||||
@@ -856,6 +900,7 @@ function MenuItem({
|
|||||||
onHover,
|
onHover,
|
||||||
item,
|
item,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onOpenSubmenu,
|
||||||
isParentOfActiveSubmenu,
|
isParentOfActiveSubmenu,
|
||||||
...props
|
...props
|
||||||
}: MenuItemProps) {
|
}: MenuItemProps) {
|
||||||
@@ -891,19 +936,22 @@ function MenuItem({
|
|||||||
e.currentTarget.focus();
|
e.currentTarget.focus();
|
||||||
};
|
};
|
||||||
|
|
||||||
const rightSlot = item.submenu ? (
|
const hasButtonSubmenu = item.submenu != null && item.submenuTrigger === "button";
|
||||||
<Icon icon="chevron_right" color="secondary" />
|
|
||||||
) : (
|
|
||||||
(item.rightSlot ?? <Hotkey variant="text" action={item.hotKeyAction ?? null} />)
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
const rightSlot =
|
||||||
|
item.submenu && !hasButtonSubmenu ? (
|
||||||
|
<Icon icon="chevron_right" color="secondary" />
|
||||||
|
) : (
|
||||||
|
(item.rightSlot ?? <Hotkey variant="text" action={item.hotKeyAction ?? null} />)
|
||||||
|
);
|
||||||
|
|
||||||
|
const button = (
|
||||||
<Button
|
<Button
|
||||||
ref={initRef}
|
ref={initRef}
|
||||||
size="sm"
|
size="sm"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
onMouseEnter={handleMouseEnter}
|
onMouseEnter={hasButtonSubmenu ? undefined : handleMouseEnter}
|
||||||
onMouseLeave={(e) => e.currentTarget.blur()}
|
onMouseLeave={hasButtonSubmenu ? undefined : (e) => e.currentTarget.blur()}
|
||||||
disabled={item.disabled}
|
disabled={item.disabled}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
@@ -924,6 +972,7 @@ function MenuItem({
|
|||||||
"min-w-32 outline-hidden px-2 mx-1.5 flex whitespace-nowrap",
|
"min-w-32 outline-hidden px-2 mx-1.5 flex whitespace-nowrap",
|
||||||
"focus:bg-surface-highlight focus:text rounded-sm focus:outline-hidden focus-visible:outline-1",
|
"focus:bg-surface-highlight focus:text rounded-sm focus:outline-hidden focus-visible:outline-1",
|
||||||
isParentOfActiveSubmenu && "bg-surface-highlight text rounded-sm",
|
isParentOfActiveSubmenu && "bg-surface-highlight text rounded-sm",
|
||||||
|
hasButtonSubmenu && "pr-8",
|
||||||
item.color === "danger" && "text-danger!",
|
item.color === "danger" && "text-danger!",
|
||||||
item.color === "primary" && "text-primary!",
|
item.color === "primary" && "text-primary!",
|
||||||
item.color === "success" && "text-success!",
|
item.color === "success" && "text-success!",
|
||||||
@@ -936,6 +985,52 @@ function MenuItem({
|
|||||||
<div className={classNames("truncate min-w-20")}>{item.label}</div>
|
<div className={classNames("truncate min-w-20")}>{item.label}</div>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!hasButtonSubmenu) {
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The submenu trigger overlays the row as a sibling (not a child) because the row is
|
||||||
|
// itself a button and buttons cannot nest. Hover handling lives on this wrapper so the
|
||||||
|
// row keeps its focus highlight while the mouse is over the trigger.
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative grid group/menuitem"
|
||||||
|
onMouseEnter={() => {
|
||||||
|
const el = buttonRef.current;
|
||||||
|
if (el == null) return;
|
||||||
|
onHover(item, el);
|
||||||
|
el.focus();
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => buttonRef.current?.blur()}
|
||||||
|
>
|
||||||
|
{button}
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
"absolute right-1.5 inset-y-0 flex items-center",
|
||||||
|
"opacity-0 group-hover/menuitem:opacity-100 group-focus-within/menuitem:opacity-100",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
color="custom"
|
||||||
|
size="2xs"
|
||||||
|
tabIndex={-1}
|
||||||
|
icon="ellipsis_vertical"
|
||||||
|
iconColor="secondary"
|
||||||
|
title="More actions"
|
||||||
|
className="h-full! w-7!"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
// Prevent the trigger from stealing focus, which would unhighlight the row
|
||||||
|
e.preventDefault();
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onOpenSubmenu(item, e.currentTarget);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MenuItemHotKeyProps {
|
interface MenuItemHotKeyProps {
|
||||||
|
|||||||
@@ -121,6 +121,24 @@
|
|||||||
@apply cursor-default;
|
@apply cursor-default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The chevron on a tag that opens a menu. The tag itself is the button, so this is
|
||||||
|
only the affordance that says so. */
|
||||||
|
.tag-action {
|
||||||
|
@apply inline-flex items-center align-middle ml-1;
|
||||||
|
@apply text-text-subtlest;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
@apply w-[0.9em] h-[0.9em];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A preview of the value a tag stands for. Sized before the image loads, so arriving
|
||||||
|
never changes the line's height. */
|
||||||
|
.tag-thumbnail {
|
||||||
|
@apply inline-block align-middle mr-1 rounded-xs;
|
||||||
|
@apply w-[1.1em] h-[1.1em] object-cover bg-surface-highlight;
|
||||||
|
}
|
||||||
|
|
||||||
.fn {
|
.fn {
|
||||||
@apply inline-block;
|
@apply inline-block;
|
||||||
.fn-inner {
|
.fn-inner {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { emacs } from "@replit/codemirror-emacs";
|
|||||||
import { vim } from "@replit/codemirror-vim";
|
import { vim } from "@replit/codemirror-vim";
|
||||||
|
|
||||||
import { vscodeKeymap } from "@replit/codemirror-vscode-keymap";
|
import { vscodeKeymap } from "@replit/codemirror-vscode-keymap";
|
||||||
|
import { debounce } from "@yaakapp-internal/lib";
|
||||||
import type { EditorKeymap } from "@yaakapp-internal/models";
|
import type { EditorKeymap } from "@yaakapp-internal/models";
|
||||||
import { settingsAtom } from "@yaakapp-internal/models";
|
import { settingsAtom } from "@yaakapp-internal/models";
|
||||||
import type { EditorLanguage, TemplateFunction } from "@yaakapp-internal/plugins";
|
import type { EditorLanguage, TemplateFunction } from "@yaakapp-internal/plugins";
|
||||||
@@ -15,7 +16,6 @@ import { HStack } from "@yaakapp-internal/ui";
|
|||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import type { GraphQLSchema } from "graphql";
|
import type { GraphQLSchema } from "graphql";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { md5 } from "js-md5";
|
|
||||||
import type { ReactNode, RefObject } from "react";
|
import type { ReactNode, RefObject } from "react";
|
||||||
import {
|
import {
|
||||||
Children,
|
Children,
|
||||||
@@ -33,6 +33,7 @@ import { useEnvironmentVariables } from "../../../hooks/useEnvironmentVariables"
|
|||||||
import { eventMatchesHotkey } from "../../../hooks/useHotKey";
|
import { eventMatchesHotkey } from "../../../hooks/useHotKey";
|
||||||
import { useRequestEditor } from "../../../hooks/useRequestEditor";
|
import { useRequestEditor } from "../../../hooks/useRequestEditor";
|
||||||
import { useTemplateFunctionCompletionOptions } from "../../../hooks/useTemplateFunctions";
|
import { useTemplateFunctionCompletionOptions } from "../../../hooks/useTemplateFunctions";
|
||||||
|
import { docFingerprint } from "../../../lib/docFingerprint";
|
||||||
import { editEnvironment } from "../../../lib/editEnvironment";
|
import { editEnvironment } from "../../../lib/editEnvironment";
|
||||||
import { tryFormatJson, tryFormatXml } from "../../../lib/formatters";
|
import { tryFormatJson, tryFormatXml } from "../../../lib/formatters";
|
||||||
import { jotaiStore } from "../../../lib/jotai";
|
import { jotaiStore } from "../../../lib/jotai";
|
||||||
@@ -42,6 +43,7 @@ import { IconButton } from "../IconButton";
|
|||||||
import "./Editor.css";
|
import "./Editor.css";
|
||||||
import {
|
import {
|
||||||
baseExtensions,
|
baseExtensions,
|
||||||
|
editableExtensions,
|
||||||
getLanguageExtension,
|
getLanguageExtension,
|
||||||
multiLineExtensions,
|
multiLineExtensions,
|
||||||
readonlyExtensions,
|
readonlyExtensions,
|
||||||
@@ -288,7 +290,7 @@ function EditorInner({
|
|||||||
function configureReadOnly() {
|
function configureReadOnly() {
|
||||||
if (cm.current === null) return;
|
if (cm.current === null) return;
|
||||||
const current = readOnlyCompartment.current.get(cm.current.view.state) ?? emptyExtension;
|
const current = readOnlyCompartment.current.get(cm.current.view.state) ?? emptyExtension;
|
||||||
const next = readOnly ? readonlyExtensions : emptyExtension;
|
const next = readOnly ? readonlyExtensions : editableExtensions;
|
||||||
// PERF: This is expensive with hundreds of editors on screen, so only do it when necessary
|
// PERF: This is expensive with hundreds of editors on screen, so only do it when necessary
|
||||||
if (current === next) return;
|
if (current === next) return;
|
||||||
|
|
||||||
@@ -381,6 +383,7 @@ function EditorInner({
|
|||||||
const initEditorRef = useCallback(
|
const initEditorRef = useCallback(
|
||||||
function initEditorRef(container: HTMLDivElement | null) {
|
function initEditorRef(container: HTMLDivElement | null) {
|
||||||
if (container === null) {
|
if (container === null) {
|
||||||
|
flushCachedEditorState(stateKey);
|
||||||
cm.current?.view.destroy();
|
cm.current?.view.destroy();
|
||||||
cm.current = null;
|
cm.current = null;
|
||||||
return;
|
return;
|
||||||
@@ -410,7 +413,7 @@ function EditorInner({
|
|||||||
keymapCompartment.current.of(
|
keymapCompartment.current.of(
|
||||||
keymapExtensions[settings.editorKeymap] ?? keymapExtensions.default,
|
keymapExtensions[settings.editorKeymap] ?? keymapExtensions.default,
|
||||||
),
|
),
|
||||||
readOnlyCompartment.current.of(readOnly ? readonlyExtensions : emptyExtension),
|
readOnlyCompartment.current.of(readOnly ? readonlyExtensions : editableExtensions),
|
||||||
...getExtensions({
|
...getExtensions({
|
||||||
container,
|
container,
|
||||||
singleLine,
|
singleLine,
|
||||||
@@ -430,7 +433,7 @@ function EditorInner({
|
|||||||
: []),
|
: []),
|
||||||
];
|
];
|
||||||
|
|
||||||
const cachedJsonState = getCachedEditorState(defaultValue ?? "", stateKey);
|
const cachedJsonState = getCachedEditorState(defaultValue ?? "", stateKey, !!readOnly);
|
||||||
|
|
||||||
const doc = `${defaultValue ?? ""}`;
|
const doc = `${defaultValue ?? ""}`;
|
||||||
const config: EditorStateConfig = { extensions, doc };
|
const config: EditorStateConfig = { extensions, doc };
|
||||||
@@ -639,7 +642,7 @@ function getExtensions({
|
|||||||
onChange.current?.(update.state.doc.toString());
|
onChange.current?.(update.state.doc.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
saveCachedEditorState(stateKey, update.state);
|
saveCachedEditorStateDebounced(stateKey, update.state);
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -652,13 +655,53 @@ const placeholderElFromText = (text: string | undefined) => {
|
|||||||
return el;
|
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) {
|
function saveCachedEditorState(stateKey: string | null, state: EditorState | null) {
|
||||||
if (!stateKey || state == null) return;
|
if (!stateKey || state == null) return;
|
||||||
const stateObj = state.toJSON(stateFields);
|
const stateObj = state.toJSON(stateFields);
|
||||||
|
|
||||||
// Save state in sessionStorage by removing doc and saving the hash of it instead.
|
// 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.
|
// This will be checked on restore and put back in if it matches.
|
||||||
stateObj.docHash = md5(stateObj.doc);
|
// 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;
|
stateObj.doc = undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -668,7 +711,7 @@ function saveCachedEditorState(stateKey: string | null, state: EditorState | nul
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCachedEditorState(doc: string, stateKey: string | null) {
|
function getCachedEditorState(doc: string, stateKey: string | null, readOnly: boolean) {
|
||||||
if (stateKey == null) return;
|
if (stateKey == null) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -678,7 +721,7 @@ function getCachedEditorState(doc: string, stateKey: string | null) {
|
|||||||
const { docHash, ...state } = JSON.parse(stateStr);
|
const { docHash, ...state } = JSON.parse(stateStr);
|
||||||
|
|
||||||
// Ensure the doc matches the one that was used to save the state
|
// Ensure the doc matches the one that was used to save the state
|
||||||
if (docHash !== md5(doc)) {
|
if (docHash !== docFingerprint(doc, { exact: !readOnly })) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ import { pluralizeCount } from "../../../lib/pluralize";
|
|||||||
import { showGraphQLDocExplorerAtom } from "../../graphql/graphqlAtoms";
|
import { showGraphQLDocExplorerAtom } from "../../graphql/graphqlAtoms";
|
||||||
import type { EditorProps } from "./Editor";
|
import type { EditorProps } from "./Editor";
|
||||||
import { jsonParseLinter } from "./json-lint";
|
import { jsonParseLinter } from "./json-lint";
|
||||||
|
import { largeValues, mediaValues } from "./largeValues";
|
||||||
import { pairs } from "./pairs/extension";
|
import { pairs } from "./pairs/extension";
|
||||||
import { searchMatchCount } from "./searchMatchCount";
|
import { searchMatchCount } from "./searchMatchCount";
|
||||||
import { text } from "./text/extension";
|
import { text } from "./text/extension";
|
||||||
@@ -258,8 +259,19 @@ export const baseExtensions = [
|
|||||||
export const readonlyExtensions = [
|
export const readonlyExtensions = [
|
||||||
EditorState.readOnly.of(true),
|
EditorState.readOnly.of(true),
|
||||||
EditorView.contentAttributes.of({ tabindex: "-1" }),
|
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 }) => [
|
export const multiLineExtensions = ({ hideGutter }: { hideGutter?: boolean }) => [
|
||||||
search({ top: true }),
|
search({ top: true }),
|
||||||
searchMatchCount(),
|
searchMatchCount(),
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import type { CompletionContext } from "@codemirror/autocomplete";
|
import type { Completion, CompletionContext } from "@codemirror/autocomplete";
|
||||||
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
|
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
|
||||||
import { defaultBoost } from "./twig/completion";
|
import { defaultBoost } from "./twig/completion";
|
||||||
|
|
||||||
|
export type GenericCompletion = GenericCompletionOption & {
|
||||||
|
apply?: Completion["apply"];
|
||||||
|
};
|
||||||
|
|
||||||
export interface GenericCompletionConfig {
|
export interface GenericCompletionConfig {
|
||||||
minMatch?: number;
|
minMatch?: number;
|
||||||
options: GenericCompletionOption[];
|
options: GenericCompletion[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,9 +1,29 @@
|
|||||||
import type { Diagnostic } from "@codemirror/lint";
|
import type { Diagnostic } from "@codemirror/lint";
|
||||||
import type { EditorView } from "@codemirror/view";
|
import type { EditorView } from "@codemirror/view";
|
||||||
import { parse as jsonLintParse } from "@prantlf/jsonlint";
|
import { type ParseError, parse, printParseErrorCode } from "jsonc-parser";
|
||||||
|
|
||||||
const TEMPLATE_SYNTAX_REGEX = /\$\{\[[\s\S]*?]}/g;
|
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 {
|
interface JsonLintOptions {
|
||||||
allowComments?: boolean;
|
allowComments?: boolean;
|
||||||
allowTrailingCommas?: boolean;
|
allowTrailingCommas?: boolean;
|
||||||
@@ -11,34 +31,28 @@ interface JsonLintOptions {
|
|||||||
|
|
||||||
export function jsonParseLinter(options?: JsonLintOptions) {
|
export function jsonParseLinter(options?: JsonLintOptions) {
|
||||||
return (view: EditorView): Diagnostic[] => {
|
return (view: EditorView): Diagnostic[] => {
|
||||||
try {
|
const doc = view.state.doc.toString();
|
||||||
const doc = view.state.doc.toString();
|
// We need lint to not break on stuff like {"foo:" ${[ ... ]}} so we'll replace all template
|
||||||
// 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.
|
||||||
// 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 escapedDoc = doc.replace(TEMPLATE_SYNTAX_REGEX, (m) => "1".repeat(m.length));
|
|
||||||
jsonLintParse(escapedDoc, {
|
|
||||||
mode: (options?.allowComments ?? true) ? "cjson" : "json",
|
|
||||||
ignoreTrailingCommas: options?.allowTrailingCommas ?? false,
|
|
||||||
});
|
|
||||||
// oxlint-disable-next-line no-explicit-any
|
|
||||||
} catch (err: any) {
|
|
||||||
if (!("location" in err)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// const line = location?.start?.line;
|
const errors: ParseError[] = [];
|
||||||
// const column = location?.start?.column;
|
parse(escapedDoc, errors, {
|
||||||
if (err.location.start.offset) {
|
allowTrailingComma: options?.allowTrailingCommas ?? false,
|
||||||
return [
|
disallowComments: !(options?.allowComments ?? true),
|
||||||
{
|
});
|
||||||
from: err.location.start.offset,
|
|
||||||
to: err.location.start.offset,
|
// Later errors are mostly consequences of the first one, so only that one is shown
|
||||||
severity: "error",
|
const error = errors[0];
|
||||||
message: err.message,
|
if (error == null) return [];
|
||||||
},
|
|
||||||
];
|
return [
|
||||||
}
|
{
|
||||||
}
|
from: error.offset,
|
||||||
return [];
|
to: error.offset + error.length,
|
||||||
|
severity: "error",
|
||||||
|
message: MESSAGES[printParseErrorCode(error.error)] ?? "Invalid JSON",
|
||||||
|
},
|
||||||
|
];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,500 @@
|
|||||||
|
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 {
|
||||||
|
COLLAPSE_MEDIA_CHARS,
|
||||||
|
COLLAPSE_TOKEN_CHARS,
|
||||||
|
collapseDecorations,
|
||||||
|
largeValues,
|
||||||
|
MAX_VISIBLE_LINE_CHARS,
|
||||||
|
} from "./largeValues";
|
||||||
|
import type { SniffedValue } from "./sniffValue";
|
||||||
|
|
||||||
|
const BIG = "A".repeat(1_000_000);
|
||||||
|
|
||||||
|
/** With a grammar, so tokens can be collapsed individually */
|
||||||
|
const jsonState = (doc: string) => EditorState.create({ doc, extensions: [jsonc(), largeValues] });
|
||||||
|
|
||||||
|
/** Without a grammar, so only the column rule applies */
|
||||||
|
const plainState = (doc: string) => EditorState.create({ doc, extensions: largeValues });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The decorations as if the whole document were on screen.
|
||||||
|
*
|
||||||
|
* In the editor the plugin passes the viewport instead, which is the same call with narrower
|
||||||
|
* ranges — the rules themselves don't know the difference.
|
||||||
|
*/
|
||||||
|
function decorationsFor(state: EditorState) {
|
||||||
|
return collapseDecorations(state, [{ from: 0, to: state.doc.length }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapsedRanges(state: EditorState) {
|
||||||
|
const ranges: { from: number; to: number }[] = [];
|
||||||
|
const iter = decorationsFor(state).iter();
|
||||||
|
while (iter.value != null) {
|
||||||
|
ranges.push({ from: iter.from, to: iter.to });
|
||||||
|
iter.next();
|
||||||
|
}
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How much of each line is still rendered */
|
||||||
|
function visibleLineLengths(state: EditorState) {
|
||||||
|
const hidden = collapsedRanges(state);
|
||||||
|
const lengths: number[] = [];
|
||||||
|
for (let n = 1; n <= state.doc.lines; n++) {
|
||||||
|
const line = state.doc.line(n);
|
||||||
|
const covered = hidden
|
||||||
|
.filter((h) => h.from >= line.from && h.to <= line.to)
|
||||||
|
.reduce((sum, h) => sum + (h.to - h.from), 0);
|
||||||
|
lengths.push(line.length - covered);
|
||||||
|
}
|
||||||
|
return lengths;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("collapsing", () => {
|
||||||
|
test("leaves an ordinary body alone", () => {
|
||||||
|
expect(collapsedRanges(jsonState('{"hello":"world"}'))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("leaves a large body of short lines alone", () => {
|
||||||
|
const doc = Array.from({ length: 20_000 }, (_, i) => ` { "id": ${i} },`).join("\n");
|
||||||
|
expect(doc.length).toBeGreaterThan(MAX_VISIBLE_LINE_CHARS);
|
||||||
|
expect(collapsedRanges(jsonState(doc))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("leaves a line just under the column limit alone", () => {
|
||||||
|
expect(collapsedRanges(plainState("x".repeat(MAX_VISIBLE_LINE_CHARS)))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("never renders more than the column limit per line", () => {
|
||||||
|
for (const state of [jsonState(`{"image":"${BIG}"}`), plainState(BIG)]) {
|
||||||
|
for (const length of visibleLineLengths(state)) {
|
||||||
|
expect(length).toBeLessThanOrEqual(MAX_VISIBLE_LINE_CHARS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps the document text intact", () => {
|
||||||
|
const doc = `{"image":"${BIG}"}`;
|
||||||
|
expect(jsonState(doc).sliceDoc()).toBe(doc);
|
||||||
|
expect(jsonState(doc).doc.length).toBe(doc.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("token collapsing, with a grammar", () => {
|
||||||
|
test("hides the whole value, leaving its quotes visible", () => {
|
||||||
|
const doc = `{"name":"a.png","image":"${BIG}","size":12}`;
|
||||||
|
const state = jsonState(doc);
|
||||||
|
const ranges = collapsedRanges(state);
|
||||||
|
|
||||||
|
expect(ranges).toHaveLength(1);
|
||||||
|
// Exactly the text between the quotes, so the line reads as `"image":"<tag>"`
|
||||||
|
expect(state.sliceDoc(ranges[0]!.from, ranges[0]!.to)).toBe(BIG);
|
||||||
|
expect(doc[ranges[0]!.from - 1]).toBe('"');
|
||||||
|
expect(doc[ranges[0]!.to]).toBe('"');
|
||||||
|
|
||||||
|
// Everything after the value is still rendered, unlike a plain column cut
|
||||||
|
expect(doc.slice(ranges[0]!.to)).toContain('"size":12}');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Small enough that the parse always finishes inside PARSE_TIMEOUT_MS, even on a slow
|
||||||
|
// machine. With a bigger body this falls back to the column cut, which is by design but
|
||||||
|
// makes the assertion depend on how fast the runner is.
|
||||||
|
test("keeps every key visible in a minified body with several large values", () => {
|
||||||
|
const chunk = "B".repeat(20_000);
|
||||||
|
const doc = `{${["a", "b", "c", "d", "e"].map((k) => `"${k}":"${chunk}"`).join(",")}}`;
|
||||||
|
const state = jsonState(doc);
|
||||||
|
const ranges = collapsedRanges(state);
|
||||||
|
|
||||||
|
expect(ranges).toHaveLength(5);
|
||||||
|
for (const key of ["a", "b", "c", "d", "e"]) {
|
||||||
|
// No collapse swallows the key
|
||||||
|
const at = doc.indexOf(`"${key}":`);
|
||||||
|
expect(ranges.some((r) => r.from <= at && r.to > at)).toBe(false);
|
||||||
|
}
|
||||||
|
expect(visibleLineLengths(state)[0]).toBeLessThanOrEqual(MAX_VISIBLE_LINE_CHARS);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("collapses a value on a pretty-printed line", () => {
|
||||||
|
const doc = `{\n "name": "a.png",\n "image": "${BIG}"\n}`;
|
||||||
|
const state = jsonState(doc);
|
||||||
|
const ranges = collapsedRanges(state);
|
||||||
|
|
||||||
|
expect(ranges).toHaveLength(1);
|
||||||
|
expect(state.sliceDoc(ranges[0]!.from, ranges[0]!.to)).toBe(BIG);
|
||||||
|
// Only the long line is touched, and all that is left of it is ` "image": ""`
|
||||||
|
expect(visibleLineLengths(state)).toEqual([1, 18, 13, 1]);
|
||||||
|
expect(state.doc.line(2).text).toBe(' "name": "a.png",');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ignores tokens under the collapse threshold", () => {
|
||||||
|
// Under the threshold once the surrounding quotes are counted
|
||||||
|
const short = "C".repeat(COLLAPSE_TOKEN_CHARS - 10);
|
||||||
|
const doc = `{${Array.from({ length: 4 }, (_, i) => `"k${i}":"${short}"`).join(",")}}`;
|
||||||
|
expect(doc.length).toBeGreaterThan(MAX_VISIBLE_LINE_CHARS);
|
||||||
|
|
||||||
|
// Nothing is big enough to collapse on its own, so the column rule takes over
|
||||||
|
const ranges = collapsedRanges(jsonState(doc));
|
||||||
|
expect(ranges).toHaveLength(1);
|
||||||
|
expect(ranges[0]!.to).toBe(doc.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("column collapsing, without a grammar", () => {
|
||||||
|
test("collapses everything past the limit", () => {
|
||||||
|
const ranges = collapsedRanges(plainState(BIG));
|
||||||
|
expect(ranges).toHaveLength(1);
|
||||||
|
expect(ranges[0]!.from).toBe(MAX_VISIBLE_LINE_CHARS);
|
||||||
|
expect(ranges[0]!.to).toBe(BIG.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles a long line of many short tokens", () => {
|
||||||
|
// A single-line CSV row: no token is long enough to collapse on its own
|
||||||
|
const row = Array.from({ length: 40_000 }, (_, i) => `value ${i}`).join(", ");
|
||||||
|
const ranges = collapsedRanges(plainState(row));
|
||||||
|
expect(ranges).toHaveLength(1);
|
||||||
|
expect(ranges[0]!.from).toBe(MAX_VISIBLE_LINE_CHARS);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("collapses each long line independently", () => {
|
||||||
|
const doc = `${BIG}\nshort\n${BIG}`;
|
||||||
|
const ranges = collapsedRanges(plainState(doc));
|
||||||
|
expect(ranges).toHaveLength(2);
|
||||||
|
for (const length of visibleLineLengths(plainState(doc))) {
|
||||||
|
expect(length).toBeLessThanOrEqual(MAX_VISIBLE_LINE_CHARS);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("never hides a line break", () => {
|
||||||
|
const doc = `${BIG}\nshort`;
|
||||||
|
const state = plainState(doc);
|
||||||
|
for (const { from, to } of collapsedRanges(state)) {
|
||||||
|
expect(state.sliceDoc(from, to)).not.toContain("\n");
|
||||||
|
}
|
||||||
|
expect(state.doc.lines).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("undelimited tokens", () => {
|
||||||
|
// The text grammar parses a whole line as one token. Collapsing it whole would leave the
|
||||||
|
// line with nothing on it but a tag, so the column cut handles it instead.
|
||||||
|
const textState = (doc: string) => EditorState.create({ doc, extensions: [text(), largeValues] });
|
||||||
|
|
||||||
|
test("falls back to the column cut for a long line of plain text", () => {
|
||||||
|
const doc = Array.from({ length: 90_000 }, (_, i) => `word${i}`).join(" ");
|
||||||
|
const ranges = collapsedRanges(textState(doc));
|
||||||
|
|
||||||
|
expect(ranges).toHaveLength(1);
|
||||||
|
expect(ranges[0]!.from).toBe(MAX_VISIBLE_LINE_CHARS);
|
||||||
|
expect(ranges[0]!.to).toBe(doc.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("leaves the start of the line readable", () => {
|
||||||
|
const doc = `IMPORTANT-PREFIX ${"z".repeat(500_000)}`;
|
||||||
|
const state = textState(doc);
|
||||||
|
const ranges = collapsedRanges(state);
|
||||||
|
|
||||||
|
expect(ranges[0]!.from).toBe(MAX_VISIBLE_LINE_CHARS);
|
||||||
|
expect(state.sliceDoc(0, 16)).toBe("IMPORTANT-PREFIX");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sniffing the collapsed value", () => {
|
||||||
|
/** The widget standing in for each collapse, in document order */
|
||||||
|
function widgets(state: EditorState) {
|
||||||
|
const found: { valueFrom: number; sniffed: SniffedValue | null }[] = [];
|
||||||
|
const iter = decorationsFor(state).iter();
|
||||||
|
while (iter.value != null) {
|
||||||
|
found.push(iter.value.spec.widget);
|
||||||
|
iter.next();
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A base64 value that starts with a PNG signature and runs on well past the limits */
|
||||||
|
function pngBase64(length = 1_000_000) {
|
||||||
|
const bytes = new Uint8Array(length);
|
||||||
|
bytes.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||||
|
let binary = "";
|
||||||
|
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("names the format of a base64 value inside JSON", () => {
|
||||||
|
const state = jsonState(`{"image":"${pngBase64()}"}`);
|
||||||
|
expect(widgets(state)[0]?.sniffed).toMatchObject({ mime: "image/png", label: "PNG" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("names the format of a data URI", () => {
|
||||||
|
const doc = `{"image":"data:image/jpeg;base64,${BIG}"}`;
|
||||||
|
expect(widgets(jsonState(doc))[0]?.sniffed).toMatchObject({ label: "JPEG" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("names nothing when the value is just text", () => {
|
||||||
|
expect(widgets(jsonState(`{"image":"${BIG}"}`))[0]?.sniffed).toBeNull();
|
||||||
|
expect(widgets(plainState(BIG))[0]?.sniffed).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reads a column cut from the start of its line, not from the cut", () => {
|
||||||
|
// A body that is nothing but one base64 blob: the tail is hidden, but the value it
|
||||||
|
// belongs to begins at the start of the line, which is where the signature is
|
||||||
|
const state = plainState(pngBase64());
|
||||||
|
const [widget] = widgets(state);
|
||||||
|
|
||||||
|
expect(widget?.valueFrom).toBe(0);
|
||||||
|
expect(widget?.sniffed).toMatchObject({ label: "PNG" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reads a token collapse from the value itself", () => {
|
||||||
|
const doc = `{"image":"${pngBase64()}"}`;
|
||||||
|
const [widget] = widgets(jsonState(doc));
|
||||||
|
|
||||||
|
// Just past the opening quote, so the signature is the first thing it sees
|
||||||
|
expect(widget?.valueFrom).toBe(doc.indexOf('"', doc.indexOf("image") + 6) + 1);
|
||||||
|
expect(widget?.sniffed).toMatchObject({ label: "PNG" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("finds a run that starts partway through a line, and names it", () => {
|
||||||
|
// No grammar here, and the value is not the whole line. The run is found by its alphabet,
|
||||||
|
// so the prefix stays on screen and the blob is still named.
|
||||||
|
const prefix = "some prefix text ";
|
||||||
|
const state = plainState(`${prefix}${pngBase64()}`);
|
||||||
|
const [widget] = widgets(state);
|
||||||
|
|
||||||
|
expect(widget?.valueFrom).toBe(prefix.length);
|
||||||
|
expect(widget?.sniffed).toMatchObject({ label: "PNG" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("recomputing", () => {
|
||||||
|
test("updates when the document changes", () => {
|
||||||
|
const state = plainState('{"image":"short"}');
|
||||||
|
expect(collapsedRanges(state)).toEqual([]);
|
||||||
|
|
||||||
|
const next = state.update({
|
||||||
|
changes: { from: 0, to: state.doc.length, insert: BIG },
|
||||||
|
}).state;
|
||||||
|
expect(collapsedRanges(next)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("media collapsing, below the length rules", () => {
|
||||||
|
/** A real PNG signature, at a size that no length rule would touch */
|
||||||
|
function png(bytes: number) {
|
||||||
|
const b = new Uint8Array(bytes);
|
||||||
|
b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||||
|
let binary = "";
|
||||||
|
for (const byte of b) binary += String.fromCharCode(byte);
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Well under every length threshold, so only the media rule can collapse it */
|
||||||
|
const SMALL = png(3_000);
|
||||||
|
|
||||||
|
test("collapses a small image on a short line", () => {
|
||||||
|
expect(SMALL.length).toBeLessThan(MAX_VISIBLE_LINE_CHARS);
|
||||||
|
expect(SMALL.length).toBeLessThan(COLLAPSE_TOKEN_CHARS);
|
||||||
|
|
||||||
|
const state = jsonState(`{\n "avatar": "${SMALL}"\n}`);
|
||||||
|
const ranges = collapsedRanges(state);
|
||||||
|
|
||||||
|
expect(ranges).toHaveLength(1);
|
||||||
|
expect(state.sliceDoc(ranges[0]!.from, ranges[0]!.to)).toBe(SMALL);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("names it, so the tag can say what it is", () => {
|
||||||
|
const state = jsonState(`{"avatar":"${SMALL}"}`);
|
||||||
|
const iter = decorationsFor(state).iter();
|
||||||
|
expect(iter.value?.spec.widget.sniffed).toMatchObject({ mime: "image/png", label: "PNG" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("leaves a value of the same size alone when nothing recognises it", () => {
|
||||||
|
// The only difference from the case above is what the bytes turn out to be
|
||||||
|
const prose = "x".repeat(SMALL.length);
|
||||||
|
expect(collapsedRanges(jsonState(`{"note":"${prose}"}`))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("leaves anything under the media threshold alone, recognised or not", () => {
|
||||||
|
const tiny = png(400);
|
||||||
|
expect(tiny.length).toBeLessThan(COLLAPSE_MEDIA_CHARS);
|
||||||
|
expect(collapsedRanges(jsonState(`{"icon":"${tiny}"}`))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("leaves text alone even when its first bytes match a signature", () => {
|
||||||
|
// `Qk0` decodes to `BM`, the whole of the BMP signature. Two bytes come up by chance often
|
||||||
|
// enough that the sniff alone must not be allowed to hide something.
|
||||||
|
const prose = `Qk0 ${"the quick brown fox jumps over the lazy dog. ".repeat(40)}`;
|
||||||
|
expect(prose.length).toBeGreaterThan(COLLAPSE_MEDIA_CHARS);
|
||||||
|
expect(collapsedRanges(jsonState(`{"note":"${prose}"}`))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("still collapses it once it is long enough to be a rendering problem", () => {
|
||||||
|
// Past the length rule the sniff no longer decides anything, so this is hidden for its size
|
||||||
|
const prose = "Qk0 " + "words and more words ".repeat(COLLAPSE_TOKEN_CHARS);
|
||||||
|
expect(collapsedRanges(jsonState(`{"note":"${prose}"}`))).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("collapses a data URI on a short line", () => {
|
||||||
|
const state = jsonState(`{"avatar":"data:image/jpeg;base64,${SMALL}"}`);
|
||||||
|
expect(collapsedRanges(state)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("still leaves an ordinary document completely untouched", () => {
|
||||||
|
const doc = Array.from({ length: 5_000 }, (_, i) => ` { "id": ${i}, "name": "item" },`).join(
|
||||||
|
"\n",
|
||||||
|
);
|
||||||
|
expect(collapsedRanges(jsonState(doc))).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("media rule only, for a document being edited", () => {
|
||||||
|
/** What an editable editor gets: rule 2 alone */
|
||||||
|
function editableRanges(state: EditorState) {
|
||||||
|
const ranges: { from: number; to: number }[] = [];
|
||||||
|
const iter = collapseDecorations(state, [{ from: 0, to: state.doc.length }], "media").iter();
|
||||||
|
while (iter.value != null) {
|
||||||
|
ranges.push({ from: iter.from, to: iter.to });
|
||||||
|
iter.next();
|
||||||
|
}
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
function png(bytes: number) {
|
||||||
|
const b = new Uint8Array(bytes);
|
||||||
|
b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||||
|
let binary = "";
|
||||||
|
for (const byte of b) binary += String.fromCharCode(byte);
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("collapses a pasted image, the one thing it is for", () => {
|
||||||
|
const value = png(3_000);
|
||||||
|
const state = jsonState(`{\n "avatar": "${value}"\n}`);
|
||||||
|
|
||||||
|
expect(editableRanges(state)).toHaveLength(1);
|
||||||
|
expect(state.sliceDoc(editableRanges(state)[0]!.from, editableRanges(state)[0]!.to)).toBe(
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("leaves a long value alone when nothing can name it", () => {
|
||||||
|
// Rule 1 would take this on a stalling line. Editing it is plausible, so it stays.
|
||||||
|
const doc = `{"blob":"${"Z".repeat(COLLAPSE_TOKEN_CHARS * 3)}"}`;
|
||||||
|
expect(doc.length).toBeGreaterThan(MAX_VISIBLE_LINE_CHARS);
|
||||||
|
expect(editableRanges(jsonState(doc))).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("never cuts at the column limit, which would strand text out of reach", () => {
|
||||||
|
// Rule 3 territory: a minified body with no value big enough to collapse on its own
|
||||||
|
const doc = `[${Array.from({ length: 4_000 }, (_, i) => `"word-${i}"`).join(",")}]`;
|
||||||
|
expect(doc.length).toBeGreaterThan(MAX_VISIBLE_LINE_CHARS);
|
||||||
|
expect(editableRanges(jsonState(doc))).toEqual([]);
|
||||||
|
// The read-only rules still cut it
|
||||||
|
expect(collapsedRanges(jsonState(doc))).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("never swallows a template tag, collapsing only the run beside it", () => {
|
||||||
|
// A brace is not in the base64 alphabet, so the run starts after the tag and the tag stays
|
||||||
|
// on screen where it can still be read and edited
|
||||||
|
const tag = "{{ image }}";
|
||||||
|
const doc = `{"avatar":"${tag}${png(3_000)}"}`;
|
||||||
|
const ranges = editableRanges(jsonState(doc));
|
||||||
|
|
||||||
|
expect(ranges).toHaveLength(1);
|
||||||
|
expect(ranges[0]!.from).toBe(doc.indexOf(tag) + tag.length);
|
||||||
|
expect(doc.slice(ranges[0]!.from, ranges[0]!.to)).not.toContain("{");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("finds a data URI whole, header and all", () => {
|
||||||
|
const value = `data:image/jpeg;base64,${png(3_000)}`;
|
||||||
|
const doc = `{"avatar":"${value}"}`;
|
||||||
|
const ranges = editableRanges(jsonState(doc));
|
||||||
|
|
||||||
|
expect(ranges).toHaveLength(1);
|
||||||
|
expect(doc.slice(ranges[0]!.from, ranges[0]!.to)).toBe(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hides no line break, so the plugin may provide it from the viewport", () => {
|
||||||
|
const state = jsonState(`{\n "a": "${png(3_000)}",\n "b": 1\n}`);
|
||||||
|
for (const { from, to } of editableRanges(state)) {
|
||||||
|
expect(state.sliceDoc(from, to)).not.toContain("\n");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("templated fields, where the grammar is an overlay", () => {
|
||||||
|
// Every editable field mixes its language with twig, which mounts the base language as an
|
||||||
|
// overlay. Overlays are not traversed by `Tree.iterate`, so a rule that walks the tree sees
|
||||||
|
// one enormous Text node and finds nothing. Rule 2 reads the text instead, and this is the
|
||||||
|
// case that has to keep working: an image pasted into a JSON request body.
|
||||||
|
const twigState = (doc: string) =>
|
||||||
|
EditorState.create({
|
||||||
|
doc,
|
||||||
|
extensions: [
|
||||||
|
twig({
|
||||||
|
base: jsonc(),
|
||||||
|
environmentVariables: [],
|
||||||
|
completionOptions: [],
|
||||||
|
onClickVariable: () => {},
|
||||||
|
onClickMissingVariable: () => {},
|
||||||
|
onClickPathParameter: () => {},
|
||||||
|
extraExtensions: [],
|
||||||
|
}),
|
||||||
|
largeValues,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
function png(bytes: number) {
|
||||||
|
const b = new Uint8Array(bytes);
|
||||||
|
b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||||
|
let binary = "";
|
||||||
|
for (const byte of b) binary += String.fromCharCode(byte);
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("collapses a pasted image in a templated body", () => {
|
||||||
|
const value = png(3_000);
|
||||||
|
const state = twigState(`{\n "avatar": "${value}"\n}`);
|
||||||
|
const ranges: { from: number; to: number }[] = [];
|
||||||
|
const iter = collapseDecorations(state, [{ from: 0, to: state.doc.length }], "media").iter();
|
||||||
|
while (iter.value != null) {
|
||||||
|
ranges.push({ from: iter.from, to: iter.to });
|
||||||
|
iter.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(ranges).toHaveLength(1);
|
||||||
|
expect(state.sliceDoc(ranges[0]!.from, ranges[0]!.to)).toBe(value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("viewport scoping", () => {
|
||||||
|
const doc = () => {
|
||||||
|
const value = "A".repeat(COLLAPSE_TOKEN_CHARS * 2);
|
||||||
|
return `{\n${["a", "b", "c"].map((k) => ` "${k}": "${value}"`).join(",\n")}\n}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
test("decorates only the ranges it is given", () => {
|
||||||
|
const state = jsonState(doc());
|
||||||
|
const secondLine = state.doc.line(3);
|
||||||
|
|
||||||
|
const all = collapseDecorations(state, [{ from: 0, to: state.doc.length }]);
|
||||||
|
const one = collapseDecorations(state, [{ from: secondLine.from, to: secondLine.to }]);
|
||||||
|
|
||||||
|
expect(all.size).toBe(3);
|
||||||
|
expect(one.size).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("covers a line the range only partly overlaps", () => {
|
||||||
|
// The viewport can start mid-line; the collapse still has to span the whole value
|
||||||
|
const state = jsonState(doc());
|
||||||
|
const line = state.doc.line(2);
|
||||||
|
const partial = collapseDecorations(state, [{ from: line.from + 5, to: line.from + 6 }]);
|
||||||
|
|
||||||
|
expect(partial.size).toBe(1);
|
||||||
|
const iter = partial.iter();
|
||||||
|
expect(iter.to).toBeGreaterThan(line.from + 6);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,584 @@
|
|||||||
|
import { ensureSyntaxTree, syntaxTree } from "@codemirror/language";
|
||||||
|
import { formatSize } from "@yaakapp-internal/lib/formatSize";
|
||||||
|
import type { EditorState, Extension, Range } from "@codemirror/state";
|
||||||
|
import type { Tree as SyntaxTree } from "@lezer/common";
|
||||||
|
import type { DecorationSet, ViewUpdate } from "@codemirror/view";
|
||||||
|
import { Decoration, EditorView, ViewPlugin, WidgetType } from "@codemirror/view";
|
||||||
|
import { fireAndForget } from "../../../lib/fireAndForget";
|
||||||
|
import type { SniffedValue } from "./sniffValue";
|
||||||
|
import { isEncodedRun, SNIFF_HEAD_CHARS, sniffValue } from "./sniffValue";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How much of a line may be rendered before the rest is collapsed.
|
||||||
|
*
|
||||||
|
* VS Code draws nothing past column 10,000 (`editor.stopRenderingLineAfter`) for the same
|
||||||
|
* reason. It can afford to be blunt about it because it doesn't soft wrap by default; we
|
||||||
|
* collapse to a placeholder that can be opened instead.
|
||||||
|
*/
|
||||||
|
export const MAX_VISIBLE_LINE_CHARS = 10_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A quoted value longer than this is collapsed whole, whatever it turns out to hold. Long
|
||||||
|
* enough that nothing a person might actually read is ever hidden on length alone.
|
||||||
|
*/
|
||||||
|
export const COLLAPSE_TOKEN_CHARS = 5_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A quoted value longer than this is collapsed too, but only when {@link sniffValue} can say
|
||||||
|
* what it is. Encoded media is never worth reading, so once we can name it and offer a viewer,
|
||||||
|
* a tag beats a wall of base64 well below the length that would make it a rendering problem —
|
||||||
|
* this covers the avatars and icons that make up most base64 in real payloads. Anything we
|
||||||
|
* can't identify stays visible until it's big enough to be a problem on its own.
|
||||||
|
*/
|
||||||
|
export const COLLAPSE_MEDIA_CHARS = 1_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapses what can't be read: over-long lines, which stall layout, and encoded media, which
|
||||||
|
* is only noise on screen.
|
||||||
|
*
|
||||||
|
* Layout cost tracks the length of the longest line, not the size of the document. A 1 MB
|
||||||
|
* response of ordinary multi-line JSON renders fine, while the same 1 MB on a single line stalls
|
||||||
|
* the UI, because soft wrap has to measure the whole line end to end to find its break points.
|
||||||
|
* Measured in WKWebView, the engine macOS ships: 221 ms per render pass at 1 MB and 968 ms at
|
||||||
|
* 3 MB, against 51 ms and 138 ms once collapsed, with soft wrap left on.
|
||||||
|
*
|
||||||
|
* Three rules:
|
||||||
|
*
|
||||||
|
* 1. Collapse quoted values over {@link COLLAPSE_TOKEN_CHARS} whole, so a base64 string reads
|
||||||
|
* as `"image": "PNG · 999.8 KB"` with its key intact. A minified body with several large
|
||||||
|
* values keeps every one of its keys. Needs a grammar to find the value.
|
||||||
|
* 2. Collapse quoted values over {@link COLLAPSE_MEDIA_CHARS} that sniff as a known format,
|
||||||
|
* which is what catches an ordinary embedded image long before it is a rendering problem.
|
||||||
|
* 3. On a line still over {@link MAX_VISIBLE_LINE_CHARS}, collapse whatever is left past the
|
||||||
|
* column limit. This needs no grammar, so it covers plain text, undelimited tokens, and any
|
||||||
|
* line that is simply long.
|
||||||
|
*
|
||||||
|
* Only what the viewport covers is examined, and only lines long enough to hold a collapse are
|
||||||
|
* looked at, so a document of ordinary short lines costs a length check per visible line and
|
||||||
|
* nothing else — no grammar is consulted and no tree is walked.
|
||||||
|
*
|
||||||
|
* Nothing leaves the document. Copy, filter and save all still see the full text, and the tag
|
||||||
|
* itself is a button, opening a menu that views, copies or saves exactly what it stands for.
|
||||||
|
*
|
||||||
|
* The value's own head says what it is — a `data:` URI names its media type, and raw base64
|
||||||
|
* gives its format up in the first dozen bytes — so the tag can name the format and open the
|
||||||
|
* value in the matching viewer without any of it being read. See {@link sniffValue}.
|
||||||
|
*
|
||||||
|
* An editable document gets rule 2 alone, as {@link mediaValues}. Encoded media is never typed
|
||||||
|
* by hand: it arrives pasted, and the only edit anyone makes to it is replacing it wholesale,
|
||||||
|
* which the tag already supports — {@link EditorView.atomicRanges} makes it delete as one unit.
|
||||||
|
* So collapsing it hides nothing anyone was going to read. The other two rules stay out, since
|
||||||
|
* they hide text by length alone, without being able to say what it is: under rule 3 a minified
|
||||||
|
* body would become uneditable past the column limit, which really would be editing text you
|
||||||
|
* can't see.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which of the three rules to apply.
|
||||||
|
*
|
||||||
|
* `media` is the subset safe for a document being edited: collapse only what we can name.
|
||||||
|
*/
|
||||||
|
export type CollapseRules = "all" | "media";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A hidden range, the value it belongs to, and what that value turned out to be.
|
||||||
|
*
|
||||||
|
* The hidden range and the value differ under the column rule, where the value starts at the
|
||||||
|
* beginning of the line but only what runs past the limit is hidden. The tag stands in for
|
||||||
|
* [from, to) and copies exactly that; the viewer needs the whole value, [valueFrom, to).
|
||||||
|
*/
|
||||||
|
interface Collapse {
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
valueFrom: number;
|
||||||
|
sniffed: SniffedValue | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lucide's `chevron-down`, inlined because the widget builds its DOM synchronously and
|
||||||
|
* rendering React here would leave it empty while CodeMirror measures line heights.
|
||||||
|
*/
|
||||||
|
const CHEVRON_DOWN =
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" ' +
|
||||||
|
'stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" ' +
|
||||||
|
'aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks the tag as the thing that opens the menu.
|
||||||
|
*
|
||||||
|
* The whole tag is the target rather than the chevron alone: there is only one action, and a
|
||||||
|
* label you can't click next to a hit area a few pixels wide is a worse button than the tag
|
||||||
|
* itself. The chevron stays as the affordance that says so.
|
||||||
|
*
|
||||||
|
* A span with a role rather than a real button, because a button's box model makes the line
|
||||||
|
* taller — the one thing this extension exists to keep from happening.
|
||||||
|
*/
|
||||||
|
function makeTagButton(el: HTMLElement, title: string, onOpen: () => void) {
|
||||||
|
el.role = "button";
|
||||||
|
el.ariaHasPopup = "menu";
|
||||||
|
el.tabIndex = 0;
|
||||||
|
el.title = title;
|
||||||
|
el.ariaLabel = title;
|
||||||
|
|
||||||
|
// Keep the editor from moving the cursor when the tag is pressed
|
||||||
|
el.addEventListener("mousedown", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
});
|
||||||
|
el.addEventListener("click", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onOpen();
|
||||||
|
});
|
||||||
|
el.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key !== "Enter" && e.key !== " ") return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onOpen();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class LargeValueWidget extends WidgetType {
|
||||||
|
constructor(
|
||||||
|
private readonly from: number,
|
||||||
|
private readonly to: number,
|
||||||
|
private readonly valueFrom: number,
|
||||||
|
private readonly sniffed: SniffedValue | null,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
eq(other: LargeValueWidget) {
|
||||||
|
return (
|
||||||
|
other.from === this.from &&
|
||||||
|
other.to === this.to &&
|
||||||
|
other.valueFrom === this.valueFrom &&
|
||||||
|
other.sniffed?.mime === this.sniffed?.mime
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
toDOM(view: EditorView) {
|
||||||
|
const length = this.to - this.from;
|
||||||
|
|
||||||
|
const el = document.createElement("span");
|
||||||
|
// The same neutral tag styling a path parameter uses. The theme scope matters: these
|
||||||
|
// tokens resolve against the tag palette, not the editor's ambient one, where a
|
||||||
|
// tag-sized border is meant to be near invisible.
|
||||||
|
el.className = "x-theme-templateTag x-theme-templateTag--secondary template-tag";
|
||||||
|
|
||||||
|
const thumbnail = buildThumbnail(view, this.valueFrom, this.to, this.sniffed);
|
||||||
|
if (thumbnail != null) {
|
||||||
|
el.appendChild(thumbnail);
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = document.createElement("span");
|
||||||
|
// A named type reads as a stand-in for the value, so it only needs to say what and how big.
|
||||||
|
// Without a name there is nothing to show but the fact that something is missing.
|
||||||
|
label.textContent =
|
||||||
|
this.sniffed == null
|
||||||
|
? `${formatSize(length)} hidden…`
|
||||||
|
: `${this.sniffed.label} · ${formatSize(length)}`;
|
||||||
|
el.appendChild(label);
|
||||||
|
|
||||||
|
const chevron = document.createElement("span");
|
||||||
|
chevron.className = "tag-action";
|
||||||
|
chevron.ariaHidden = "true";
|
||||||
|
chevron.innerHTML = CHEVRON_DOWN;
|
||||||
|
el.appendChild(chevron);
|
||||||
|
|
||||||
|
makeTagButton(
|
||||||
|
el,
|
||||||
|
this.sniffed == null ? "Value actions" : `${this.sniffed.label} actions`,
|
||||||
|
() => fireAndForget(this.openMenu(view, el)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy, view and save, in a menu opened against the button.
|
||||||
|
*
|
||||||
|
* Everything the menu needs is pulled in on click. The editor loads on every response, and
|
||||||
|
* neither the React menu nor the viewers behind the dialog are worth carrying until someone
|
||||||
|
* asks for them.
|
||||||
|
*/
|
||||||
|
private async openMenu(view: EditorView, tag: HTMLElement) {
|
||||||
|
const [{ toggleContextMenu }, { showLargeValueDialog }, { encodingLabel, largeValueActions }] =
|
||||||
|
await Promise.all([
|
||||||
|
import("../../../lib/contextMenu"),
|
||||||
|
import("../../LargeValueDialog"),
|
||||||
|
import("../../../lib/largeValue"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { sniffed } = this;
|
||||||
|
// Sliced when an action runs rather than now, so opening the menu never touches the value
|
||||||
|
const value = () => view.state.sliceDoc(this.valueFrom, this.to);
|
||||||
|
const head = view.state.sliceDoc(
|
||||||
|
this.valueFrom,
|
||||||
|
Math.min(this.valueFrom + SNIFF_HEAD_CHARS, this.to),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The whole tag, so the menu can align to whichever edge has room beside it
|
||||||
|
const rect = tag.getBoundingClientRect();
|
||||||
|
toggleContextMenu({
|
||||||
|
id: "large-value",
|
||||||
|
triggerPosition: { x: rect.left, y: rect.bottom },
|
||||||
|
triggerRect: rect,
|
||||||
|
triggerEl: tag,
|
||||||
|
items: [
|
||||||
|
{ type: "separator", label: encodingLabel(head, sniffed, this.to - this.from) },
|
||||||
|
...largeValueActions({
|
||||||
|
value,
|
||||||
|
sniffed,
|
||||||
|
// Exactly what the tag stands in for, which under the column rule is only the tail
|
||||||
|
copyText: () => view.state.sliceDoc(this.from, this.to),
|
||||||
|
onView: () => showLargeValueDialog({ text: value(), sniffed }),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ignoreEvent() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How much encoded image a tag will preview. Roughly 3 MB of file, base64 being 4 bytes to 3.
|
||||||
|
*
|
||||||
|
* The decode runs on WebKit's image thread, but the bitmap it produces is sized by the image's
|
||||||
|
* own dimensions, not by the box we draw it in — a 4000×3000 photo costs 48 MB however small the
|
||||||
|
* thumbnail. So there has to be a limit, even though encoded size is only a proxy for the one
|
||||||
|
* that matters: a well-compressed photo can decode larger than a lossless screenshot twice its
|
||||||
|
* file size. This is set high enough to cover ordinary screenshots and wallpapers, since a tag
|
||||||
|
* that previews some images and not others is worse than one that previews none. Only widgets in
|
||||||
|
* the viewport are ever built, so a screenful is the most that decode at once.
|
||||||
|
*/
|
||||||
|
const THUMBNAIL_MAX_CHARS = 4_000_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A preview of the value, at a size fixed before it loads.
|
||||||
|
*
|
||||||
|
* The box is a fixed square from the moment it's inserted, so the image arriving never changes
|
||||||
|
* the line's height or the tag's width. A growing line box is the layout cost this whole
|
||||||
|
* extension exists to avoid, and an image of unknown dimensions is the classic way to cause one.
|
||||||
|
*
|
||||||
|
* The src is the value's own text handed straight to the decoder as a data URI. Decoding the
|
||||||
|
* base64 ourselves first would mean walking megabytes on the main thread, which is the one
|
||||||
|
* thing that must not happen here.
|
||||||
|
*/
|
||||||
|
function buildThumbnail(
|
||||||
|
view: EditorView,
|
||||||
|
valueFrom: number,
|
||||||
|
to: number,
|
||||||
|
sniffed: SniffedValue | null,
|
||||||
|
): HTMLElement | null {
|
||||||
|
if (sniffed == null || !sniffed.mime.startsWith("image/")) return null;
|
||||||
|
if (sniffed.encoding !== "base64") return null;
|
||||||
|
if (to - valueFrom > THUMBNAIL_MAX_CHARS) return null;
|
||||||
|
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.className = "tag-thumbnail";
|
||||||
|
img.alt = "";
|
||||||
|
img.ariaHidden = "true";
|
||||||
|
img.decoding = "async";
|
||||||
|
// A magic number can be wrong, and a data URI can lie. Drop the box rather than leave a
|
||||||
|
// broken-image glyph sitting in the tag.
|
||||||
|
img.addEventListener("error", () => img.remove());
|
||||||
|
// Read the document and build the data URI after the widget is measured and on screen
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!img.isConnected) return;
|
||||||
|
const payload = view.state.sliceDoc(valueFrom + sniffed.offset, to);
|
||||||
|
img.src = `data:${sniffed.mime};base64,${payload}`;
|
||||||
|
});
|
||||||
|
return img;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Line {
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The lines the given ranges touch, in document order and each listed once.
|
||||||
|
*
|
||||||
|
* Only lines long enough to hold a collapse are returned. That check is what keeps this free on
|
||||||
|
* ordinary documents: no grammar is consulted and no tree is walked for a screen of short lines.
|
||||||
|
*/
|
||||||
|
function collapsibleLines(state: EditorState, ranges: readonly { from: number; to: number }[]) {
|
||||||
|
const lines: Line[] = [];
|
||||||
|
let lastFrom = -1;
|
||||||
|
|
||||||
|
for (const range of ranges) {
|
||||||
|
let pos = range.from;
|
||||||
|
for (;;) {
|
||||||
|
const line = state.doc.lineAt(pos);
|
||||||
|
if (line.from > lastFrom) {
|
||||||
|
lastFrom = line.from;
|
||||||
|
if (line.to - line.from >= COLLAPSE_MEDIA_CHARS) {
|
||||||
|
lines.push({ from: line.from, to: line.to });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (line.to >= range.to) break;
|
||||||
|
pos = line.to + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long to spend parsing before falling back to the column rule.
|
||||||
|
*
|
||||||
|
* The initial parse is budgeted by time, so it stops partway through a document with several
|
||||||
|
* large values, and we'd only find the first one. Parsing the rest of a 1 MB body costs about
|
||||||
|
* 12 ms, against the 171 ms of layout it saves.
|
||||||
|
*/
|
||||||
|
const PARSE_TIMEOUT_MS = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The parsed tree covering these lines.
|
||||||
|
*
|
||||||
|
* Parsing is only ever forced for a line long enough to stall layout, where finding the values
|
||||||
|
* inside it is what saves the frame. For everything else we take whatever the background parse
|
||||||
|
* has reached and decorate again when it advances — a forced parse runs from the top of the
|
||||||
|
* document, so making one happen on every scroll would cost far more than the tags are worth.
|
||||||
|
*/
|
||||||
|
function treeForLines(state: EditorState, lines: Line[]): SyntaxTree {
|
||||||
|
const last = lines[lines.length - 1];
|
||||||
|
const stalls = lines.some((l) => l.to - l.from > MAX_VISIBLE_LINE_CHARS);
|
||||||
|
if (last == null || !stalls) {
|
||||||
|
return syntaxTree(state);
|
||||||
|
}
|
||||||
|
return ensureSyntaxTree(state, last.to, PARSE_TIMEOUT_MS) ?? syntaxTree(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUOTES = ['"', "'", "`"];
|
||||||
|
|
||||||
|
/** A run of base64, long enough to be worth looking at. Nothing else can be encoded media. */
|
||||||
|
const ENCODED_RUN = new RegExp(`[A-Za-z0-9+/=_-]{${COLLAPSE_MEDIA_CHARS},}`, "g");
|
||||||
|
|
||||||
|
/** The header that turns a bare run into a data URI, when one sits right before it. */
|
||||||
|
const DATA_URI_HEAD = /data:[^,;\s"']*(?:;[^,;\s"']*)*;base64,$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encoded media on this line, found by reading the text rather than the grammar.
|
||||||
|
*
|
||||||
|
* Deliberately grammar-free. Every editable field in the app mixes its language with the twig
|
||||||
|
* parser, which mounts the base language as an *overlay*, and overlays are not traversed by
|
||||||
|
* `Tree.iterate` — so a rule that walks the tree finds one enormous `Text` node and nothing
|
||||||
|
* inside it. Rule 2 has to work in those fields above all, since that is where images get
|
||||||
|
* pasted, and a run of base64 is a text pattern anyway: no grammar can describe it better than
|
||||||
|
* the alphabet does.
|
||||||
|
*
|
||||||
|
* A `data:` header is picked up by looking backwards from the run, so the whole URI collapses
|
||||||
|
* as one thing and its declared type is what names it.
|
||||||
|
*/
|
||||||
|
function findMediaRuns(state: EditorState, line: Line): Collapse[] {
|
||||||
|
const text = state.sliceDoc(line.from, line.to);
|
||||||
|
const collapses: Collapse[] = [];
|
||||||
|
|
||||||
|
ENCODED_RUN.lastIndex = 0;
|
||||||
|
for (let m = ENCODED_RUN.exec(text); m != null; m = ENCODED_RUN.exec(text)) {
|
||||||
|
let start = m.index;
|
||||||
|
// A data URI's own header breaks the alphabet, so the run starts after it
|
||||||
|
const header = DATA_URI_HEAD.exec(text.slice(0, start));
|
||||||
|
if (header != null) {
|
||||||
|
start = header.index;
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueFrom = line.from + start;
|
||||||
|
const valueTo = line.from + m.index + m[0].length;
|
||||||
|
const head = state.sliceDoc(valueFrom, Math.min(valueFrom + SNIFF_HEAD_CHARS, valueTo));
|
||||||
|
const sniffed = sniffValue(head);
|
||||||
|
|
||||||
|
// Only hide what we can name. A short magic number matches by chance now and then, so the
|
||||||
|
// run itself has to look encoded too.
|
||||||
|
if (sniffed != null && sniffed.encoding === "base64" && isEncodedRun(head, sniffed.offset)) {
|
||||||
|
collapses.push({ from: valueFrom, to: valueTo, valueFrom, sniffed });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return collapses;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quoted values big enough to be a rendering problem on their own, whatever they hold.
|
||||||
|
*
|
||||||
|
* Needs the grammar, to find where the value starts and ends, and so only runs for a document
|
||||||
|
* nobody is editing — which is also the only place this rule applies.
|
||||||
|
*/
|
||||||
|
function findLargeTokens(state: EditorState, tree: SyntaxTree, line: Line): Collapse[] {
|
||||||
|
const collapses: Collapse[] = [];
|
||||||
|
|
||||||
|
tree.iterate({
|
||||||
|
from: line.from,
|
||||||
|
to: line.to,
|
||||||
|
enter: (node) => {
|
||||||
|
// A node this small can't contain anything worth collapsing, and neither can its children
|
||||||
|
if (node.to - node.from < COLLAPSE_TOKEN_CHARS) return false;
|
||||||
|
// Only leaves, so we collapse the string itself rather than the object holding it
|
||||||
|
if (node.node.firstChild != null) return true;
|
||||||
|
|
||||||
|
const from = Math.max(node.from, line.from);
|
||||||
|
const to = Math.min(node.to, line.to);
|
||||||
|
const open = state.sliceDoc(from, from + 1);
|
||||||
|
const quoted = to - from >= 2 && QUOTES.includes(open) && state.sliceDoc(to - 1, to) === open;
|
||||||
|
if (!quoted) return false;
|
||||||
|
|
||||||
|
// Everything inside the quotes. The whole value is hidden, so it is its own value range
|
||||||
|
const valueFrom = from + 1;
|
||||||
|
const valueTo = to - 1;
|
||||||
|
if (valueTo - valueFrom < COLLAPSE_TOKEN_CHARS) return false;
|
||||||
|
|
||||||
|
const head = state.sliceDoc(valueFrom, Math.min(valueFrom + SNIFF_HEAD_CHARS, valueTo));
|
||||||
|
collapses.push({ from: valueFrom, to: valueTo, valueFrom, sniffed: sniffValue(head) });
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return collapses;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Both rules' findings as one list, in document order, with nothing overlapping.
|
||||||
|
*
|
||||||
|
* A big quoted base64 value satisfies both rules, and two decorations over the same text is an
|
||||||
|
* error. The token wins where they collide, because its range stops at the quotes and so leaves
|
||||||
|
* the structure around it readable.
|
||||||
|
*/
|
||||||
|
function mergeCollapses(tokens: Collapse[], runs: Collapse[]): Collapse[] {
|
||||||
|
const merged = [...tokens];
|
||||||
|
for (const run of runs) {
|
||||||
|
if (!tokens.some((t) => run.from < t.to && t.from < run.to)) {
|
||||||
|
merged.push(run);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return merged.sort((a, b) => a.from - b.from);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the line runs past the column limit, counting only what is still visible after the
|
||||||
|
* token collapses, or -1 if it fits.
|
||||||
|
*/
|
||||||
|
function findColumnCut(line: Line, tokens: Collapse[]): number {
|
||||||
|
let visible = 0;
|
||||||
|
let pos = line.from;
|
||||||
|
|
||||||
|
for (const token of [...tokens, null]) {
|
||||||
|
const segmentEnd = token == null ? line.to : token.from;
|
||||||
|
if (segmentEnd > pos) {
|
||||||
|
if (visible + (segmentEnd - pos) > MAX_VISIBLE_LINE_CHARS) {
|
||||||
|
return pos + (MAX_VISIBLE_LINE_CHARS - visible);
|
||||||
|
}
|
||||||
|
visible += segmentEnd - pos;
|
||||||
|
}
|
||||||
|
if (token != null) {
|
||||||
|
pos = token.to;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapsesForLine(
|
||||||
|
state: EditorState,
|
||||||
|
tree: SyntaxTree,
|
||||||
|
line: Line,
|
||||||
|
rules: CollapseRules,
|
||||||
|
): Collapse[] {
|
||||||
|
const runs = findMediaRuns(state, line);
|
||||||
|
// Length alone only hides things on a line that would stall without it, and never in a
|
||||||
|
// document being edited, where hiding text by size would put it out of reach
|
||||||
|
const stalls = rules === "all" && line.to - line.from > MAX_VISIBLE_LINE_CHARS;
|
||||||
|
const tokens = mergeCollapses(stalls ? findLargeTokens(state, tree, line) : [], runs);
|
||||||
|
|
||||||
|
// Short enough to render, so there is nothing left to cut
|
||||||
|
if (!stalls) {
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cut = findColumnCut(line, tokens);
|
||||||
|
if (cut < 0) {
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cut always lands in a visible stretch, so it never splits a token collapse
|
||||||
|
const kept = tokens.filter((t) => t.to <= cut);
|
||||||
|
// Only the tail is hidden, but the value it belongs to runs from the start of the line — a
|
||||||
|
// body that is nothing but one base64 blob is still recognisable from there
|
||||||
|
const head = state.sliceDoc(line.from, Math.min(line.from + SNIFF_HEAD_CHARS, line.to));
|
||||||
|
kept.push({ from: cut, to: line.to, valueFrom: line.from, sniffed: sniffValue(head) });
|
||||||
|
return kept;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The collapses covering the given document ranges.
|
||||||
|
*
|
||||||
|
* Kept separate from the plugin so the rules can be exercised against a state directly, with no
|
||||||
|
* view and no DOM.
|
||||||
|
*/
|
||||||
|
export function collapseDecorations(
|
||||||
|
state: EditorState,
|
||||||
|
ranges: readonly { from: number; to: number }[],
|
||||||
|
rules: CollapseRules = "all",
|
||||||
|
): DecorationSet {
|
||||||
|
const lines = collapsibleLines(state, ranges);
|
||||||
|
if (lines.length === 0) {
|
||||||
|
return Decoration.none;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tree = treeForLines(state, lines);
|
||||||
|
const decorations: Range<Decoration>[] = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
for (const { from, to, valueFrom, sniffed } of collapsesForLine(state, tree, line, rules)) {
|
||||||
|
const widget = new LargeValueWidget(from, to, valueFrom, sniffed);
|
||||||
|
decorations.push(Decoration.replace({ widget }).range(from, to));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Decoration.set(decorations);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scoped to the viewport, so the cost is a screenful however big the document is.
|
||||||
|
*
|
||||||
|
* Recomputed when the document changes, when the viewport moves, and when background parsing
|
||||||
|
* advances — a value can only be found once the grammar has reached it.
|
||||||
|
*/
|
||||||
|
function collapsePlugin(rules: CollapseRules) {
|
||||||
|
return ViewPlugin.fromClass(
|
||||||
|
class {
|
||||||
|
decorations: DecorationSet;
|
||||||
|
|
||||||
|
constructor(view: EditorView) {
|
||||||
|
this.decorations = collapseDecorations(view.state, view.visibleRanges, rules);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(update: ViewUpdate) {
|
||||||
|
if (
|
||||||
|
update.docChanged ||
|
||||||
|
update.viewportChanged ||
|
||||||
|
syntaxTree(update.startState) !== syntaxTree(update.state)
|
||||||
|
) {
|
||||||
|
this.decorations = collapseDecorations(
|
||||||
|
update.view.state,
|
||||||
|
update.view.visibleRanges,
|
||||||
|
rules,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
decorations: (v) => v.decorations,
|
||||||
|
// Step the cursor over a placeholder instead of stranding it inside, and delete it whole
|
||||||
|
provide: (plugin) =>
|
||||||
|
EditorView.atomicRanges.of((view) => view.plugin(plugin)?.decorations ?? Decoration.none),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All three rules. For a document nobody is editing. */
|
||||||
|
export const largeValues: Extension = [collapsePlugin("all")];
|
||||||
|
|
||||||
|
/** Only what we can name, which is the part that is safe to hide while someone is editing. */
|
||||||
|
export const mediaValues: Extension = [collapsePlugin("media")];
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { describe, expect, test } from "vite-plus/test";
|
||||||
|
import { decodeBase64Prefix, labelForMime, SNIFF_HEAD_CHARS, sniffValue } from "./sniffValue";
|
||||||
|
|
||||||
|
/** A base64 value that starts with `magic` and runs on for a while, as a real one would */
|
||||||
|
function base64Of(magic: number[], length = 2_000): string {
|
||||||
|
const bytes = new Uint8Array(length);
|
||||||
|
bytes.set(magic);
|
||||||
|
for (let i = magic.length; i < length; i++) {
|
||||||
|
bytes[i] = i % 251;
|
||||||
|
}
|
||||||
|
let binary = "";
|
||||||
|
for (const byte of bytes) {
|
||||||
|
binary += String.fromCharCode(byte);
|
||||||
|
}
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One character, one byte. These are all ASCII signatures. */
|
||||||
|
function ascii(s: string): number[] {
|
||||||
|
const bytes: number[] = [];
|
||||||
|
for (let i = 0; i < s.length; i++) {
|
||||||
|
bytes.push(s.charCodeAt(i));
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Four bytes no signature looks at: a RIFF chunk length, or an ISO-BMFF box size */
|
||||||
|
const IGNORED = [0x00, 0x00, 0x00, 0x20];
|
||||||
|
|
||||||
|
const PNG = [0x89, ...ascii("PNG"), 0x0d, 0x0a, 0x1a, 0x0a];
|
||||||
|
const JPEG = [0xff, 0xd8, 0xff, 0xe0];
|
||||||
|
const PDF = ascii("%PDF-1.7");
|
||||||
|
|
||||||
|
describe("magic numbers", () => {
|
||||||
|
const cases: [string, number[], string, string][] = [
|
||||||
|
["PNG", PNG, "image/png", "PNG"],
|
||||||
|
["JPEG", JPEG, "image/jpeg", "JPEG"],
|
||||||
|
["GIF", ascii("GIF89a"), "image/gif", "GIF"],
|
||||||
|
["PDF", PDF, "application/pdf", "PDF"],
|
||||||
|
["WEBP", [...ascii("RIFF"), ...IGNORED, ...ascii("WEBP")], "image/webp", "WEBP"],
|
||||||
|
["WAV", [...ascii("RIFF"), ...IGNORED, ...ascii("WAVE")], "audio/wav", "WAV"],
|
||||||
|
["ZIP", [...ascii("PK"), 0x03, 0x04], "application/zip", "ZIP"],
|
||||||
|
["GZIP", [0x1f, 0x8b, 0x08], "application/gzip", "GZIP"],
|
||||||
|
["MP3", [...ascii("ID3"), 0x04], "audio/mpeg", "MP3"],
|
||||||
|
["OGG", ascii("OggS"), "audio/ogg", "OGG"],
|
||||||
|
["FLAC", ascii("fLaC"), "audio/flac", "FLAC"],
|
||||||
|
["WEBM", [0x1a, 0x45, 0xdf, 0xa3], "video/webm", "WEBM"],
|
||||||
|
["MP4", [...IGNORED, ...ascii("ftypisom")], "video/mp4", "MP4"],
|
||||||
|
["AVIF", [...IGNORED, ...ascii("ftypavif")], "image/avif", "AVIF"],
|
||||||
|
["HEIC", [...IGNORED, ...ascii("ftypheic")], "image/heic", "HEIC"],
|
||||||
|
["M4A", [...IGNORED, ...ascii("ftypM4A ")], "audio/mp4", "M4A"],
|
||||||
|
["MOV", [...IGNORED, ...ascii("ftypqt ")], "video/quicktime", "MOV"],
|
||||||
|
["BMP", ascii("BMxx"), "image/bmp", "BMP"],
|
||||||
|
["TIFF", [...ascii("II"), 0x2a, 0x00], "image/tiff", "TIFF"],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [name, magic, mime, label] of cases) {
|
||||||
|
test(`recognises ${name}`, () => {
|
||||||
|
expect(sniffValue(base64Of(magic))).toEqual({ mime, label, offset: 0, encoding: "base64" });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("recognises nothing in arbitrary base64", () => {
|
||||||
|
expect(sniffValue(btoa("just some text that happens to be encoded"))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recognises nothing in text that isn't base64", () => {
|
||||||
|
expect(sniffValue("the quick brown fox jumps over the lazy dog".repeat(10))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recognises nothing in a JWT-shaped value", () => {
|
||||||
|
expect(sniffValue(`${btoa('{"alg":"HS256"}')}.${btoa('{"sub":"1"}')}.c2ln`)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reads the url-safe alphabet", () => {
|
||||||
|
const urlSafe = base64Of(PNG).replaceAll("+", "-").replaceAll("/", "_");
|
||||||
|
expect(sniffValue(urlSafe)?.label).toBe("PNG");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ignores leading whitespace, and counts it in the offset", () => {
|
||||||
|
expect(sniffValue(` ${base64Of(PNG)}`)).toMatchObject({ label: "PNG", offset: 3 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("data URIs", () => {
|
||||||
|
test("takes the declared type over the bytes", () => {
|
||||||
|
const uri = `data:image/svg+xml;base64,${btoa("<svg/>")}`;
|
||||||
|
expect(sniffValue(uri)).toEqual({
|
||||||
|
mime: "image/svg+xml",
|
||||||
|
label: "SVG",
|
||||||
|
offset: "data:image/svg+xml;base64,".length,
|
||||||
|
encoding: "base64",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("points past the header, so the payload can be decoded from there", () => {
|
||||||
|
const payload = base64Of(PNG);
|
||||||
|
const uri = `data:image/png;base64,${payload}`;
|
||||||
|
const sniffed = sniffValue(uri)!;
|
||||||
|
expect(uri.slice(sniffed.offset, sniffed.offset + 8)).toBe(payload.slice(0, 8));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to the bytes when the declared type says nothing", () => {
|
||||||
|
const uri = `data:application/octet-stream;base64,${base64Of(PDF)}`;
|
||||||
|
expect(sniffValue(uri)).toMatchObject({ mime: "application/pdf", label: "PDF" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles a header with no type at all", () => {
|
||||||
|
expect(sniffValue(`data:;base64,${base64Of(JPEG)}`)).toMatchObject({ mime: "image/jpeg" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles extra parameters", () => {
|
||||||
|
const uri = `data:text/plain;charset=utf-8;base64,${btoa("hello")}`;
|
||||||
|
expect(sniffValue(uri)).toMatchObject({ mime: "text/plain", encoding: "base64" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles a percent-encoded payload", () => {
|
||||||
|
expect(sniffValue("data:text/html,%3Ch1%3Ehi%3C/h1%3E")).toEqual({
|
||||||
|
mime: "text/html",
|
||||||
|
label: "HTML",
|
||||||
|
offset: "data:text/html,".length,
|
||||||
|
encoding: "percent",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("defaults a bare percent-encoded payload to text", () => {
|
||||||
|
expect(sniffValue("data:,hello%20there")).toMatchObject({
|
||||||
|
mime: "text/plain",
|
||||||
|
encoding: "percent",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("decoding only the head", () => {
|
||||||
|
test("reads no more of the value than the bytes asked for", () => {
|
||||||
|
const big = base64Of(PNG, 3_000_000);
|
||||||
|
// Truncating to the head must not change what is found, which is the whole point:
|
||||||
|
// callers only ever hand it a slice
|
||||||
|
expect(sniffValue(big.slice(0, SNIFF_HEAD_CHARS))).toEqual(sniffValue(big));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("decodes whole 4-character groups only", () => {
|
||||||
|
// 12 bytes needs 16 characters; a 15-character slice yields the 12 bytes of 3 whole groups
|
||||||
|
expect(decodeBase64Prefix(base64Of(PNG), 0, 12)).toHaveLength(12);
|
||||||
|
expect(decodeBase64Prefix(base64Of(PNG).slice(0, 15), 0, 12)).toHaveLength(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("declines a value too short to hold a group", () => {
|
||||||
|
expect(decodeBase64Prefix("abc", 0, 12)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("declines text outside the alphabet", () => {
|
||||||
|
expect(decodeBase64Prefix("hello world, not base64!", 0, 12)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("labels", () => {
|
||||||
|
test.each([
|
||||||
|
["image/png", "PNG"],
|
||||||
|
["image/svg+xml", "SVG"],
|
||||||
|
["text/plain", "TEXT"],
|
||||||
|
["application/vnd.ms-excel", "MS-EXCEL"],
|
||||||
|
["image/x-icon", "ICON"],
|
||||||
|
["application/octet-stream", "APPLICATION"],
|
||||||
|
["font/woff2", "WOFF2"],
|
||||||
|
["application/vnd.openxmlformats-officedocument.wordprocessingml.document", "DOCUMENT"],
|
||||||
|
])("%s reads as %s", (mime, label) => {
|
||||||
|
expect(labelForMime(mime)).toBe(label);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
/**
|
||||||
|
* What a collapsed value turns out to be.
|
||||||
|
*
|
||||||
|
* Sniffing reads the head of the value only. A `data:` URI states its own media type, and raw
|
||||||
|
* base64 gives up its type in the first few bytes, so neither needs the rest — which is the
|
||||||
|
* point, since the whole reason these values are collapsed is that touching all of one costs
|
||||||
|
* enough to stall the UI.
|
||||||
|
*/
|
||||||
|
export interface SniffedValue {
|
||||||
|
/** Full media type, e.g. `image/png`. What the dialog routes on. */
|
||||||
|
mime: string;
|
||||||
|
/** A word for the tag, e.g. `PNG`. */
|
||||||
|
label: string;
|
||||||
|
/** Where the encoded payload starts in the value, past any `data:` header. */
|
||||||
|
offset: number;
|
||||||
|
encoding: "base64" | "percent";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How much of a value {@link sniffValue} needs. Enough for a `data:` header of realistic
|
||||||
|
* length, and far more than the 24 base64 characters the magic numbers are read from.
|
||||||
|
*/
|
||||||
|
export const SNIFF_HEAD_CHARS = 256;
|
||||||
|
|
||||||
|
/** Bytes the longest signature needs: `RIFF????WEBP`, and an ISO-BMFF brand at offset 8. */
|
||||||
|
const SNIFF_BYTES = 12;
|
||||||
|
|
||||||
|
/** `data:[<mime>][;<param>…],` — the payload follows the comma. */
|
||||||
|
const DATA_URI = /^data:([^,;]*)((?:;[^,;]*)*),/;
|
||||||
|
|
||||||
|
/** Media types that name no format, so the bytes are worth a look even when one is declared. */
|
||||||
|
const UNINFORMATIVE = ["", "application/octet-stream", "binary/octet-stream"];
|
||||||
|
|
||||||
|
interface Signature {
|
||||||
|
label: string;
|
||||||
|
mime: string;
|
||||||
|
offset?: number;
|
||||||
|
/** Byte values, where `null` matches anything */
|
||||||
|
magic: readonly (number | null)[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A magic number written as the ASCII it reads as, with `?` for a byte that varies.
|
||||||
|
*
|
||||||
|
* Indexed rather than iterated, because a signature is a sequence of bytes: one character here
|
||||||
|
* is one byte, and splitting into code points would be the wrong unit for that.
|
||||||
|
*/
|
||||||
|
function ascii(pattern: string): (number | null)[] {
|
||||||
|
const bytes: (number | null)[] = [];
|
||||||
|
for (let i = 0; i < pattern.length; i++) {
|
||||||
|
bytes.push(pattern[i] === "?" ? null : pattern.charCodeAt(i));
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIGNATURES: readonly Signature[] = [
|
||||||
|
{ label: "PNG", mime: "image/png", magic: [0x89, ...ascii("PNG"), 0x0d, 0x0a, 0x1a, 0x0a] },
|
||||||
|
{ label: "JPEG", mime: "image/jpeg", magic: [0xff, 0xd8, 0xff] },
|
||||||
|
{ label: "GIF", mime: "image/gif", magic: ascii("GIF8") },
|
||||||
|
{ label: "BMP", mime: "image/bmp", magic: ascii("BM") },
|
||||||
|
{ label: "WEBP", mime: "image/webp", magic: ascii("RIFF????WEBP") },
|
||||||
|
{ label: "TIFF", mime: "image/tiff", magic: [...ascii("II"), 0x2a, 0x00] },
|
||||||
|
{ label: "TIFF", mime: "image/tiff", magic: [...ascii("MM"), 0x00, 0x2a] },
|
||||||
|
{ label: "ICO", mime: "image/x-icon", magic: [0x00, 0x00, 0x01, 0x00] },
|
||||||
|
{ label: "PDF", mime: "application/pdf", magic: ascii("%PDF-") },
|
||||||
|
{ label: "WAV", mime: "audio/wav", magic: ascii("RIFF????WAVE") },
|
||||||
|
{ label: "AVI", mime: "video/x-msvideo", magic: ascii("RIFF????AVI ") },
|
||||||
|
{ label: "MP3", mime: "audio/mpeg", magic: ascii("ID3") },
|
||||||
|
// A bare MPEG frame header: 11 sync bits, then a layer III / II / I version pair
|
||||||
|
{ label: "MP3", mime: "audio/mpeg", magic: [0xff, 0xfb] },
|
||||||
|
{ label: "MP3", mime: "audio/mpeg", magic: [0xff, 0xf3] },
|
||||||
|
{ label: "MP3", mime: "audio/mpeg", magic: [0xff, 0xf2] },
|
||||||
|
{ label: "OGG", mime: "audio/ogg", magic: ascii("OggS") },
|
||||||
|
{ label: "FLAC", mime: "audio/flac", magic: ascii("fLaC") },
|
||||||
|
{ label: "WEBM", mime: "video/webm", magic: [0x1a, 0x45, 0xdf, 0xa3] },
|
||||||
|
{ label: "ZIP", mime: "application/zip", magic: [...ascii("PK"), 0x03, 0x04] },
|
||||||
|
{ label: "GZIP", mime: "application/gzip", magic: [0x1f, 0x8b] },
|
||||||
|
{ label: "7Z", mime: "application/x-7z-compressed", magic: [...ascii("7z"), 0xbc, 0xaf, 0x27] },
|
||||||
|
{ label: "RAR", mime: "application/vnd.rar", magic: ascii("Rar!") },
|
||||||
|
{ label: "GLTF", mime: "model/gltf-binary", magic: ascii("glTF") },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ISO base media files all start `????ftyp`, so the format is in the brand that follows rather
|
||||||
|
* than in the signature itself. Anything unlisted is some flavour of MP4.
|
||||||
|
*/
|
||||||
|
const ISO_BRANDS: Record<string, { mime: string; label: string }> = {
|
||||||
|
avif: { mime: "image/avif", label: "AVIF" },
|
||||||
|
avis: { mime: "image/avif", label: "AVIF" },
|
||||||
|
heic: { mime: "image/heic", label: "HEIC" },
|
||||||
|
heix: { mime: "image/heic", label: "HEIC" },
|
||||||
|
hevc: { mime: "image/heic", label: "HEIC" },
|
||||||
|
mif1: { mime: "image/heif", label: "HEIF" },
|
||||||
|
msf1: { mime: "image/heif", label: "HEIF" },
|
||||||
|
"M4A ": { mime: "audio/mp4", label: "M4A" },
|
||||||
|
"qt ": { mime: "video/quicktime", label: "MOV" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function matches(bytes: Uint8Array, { magic, offset = 0 }: Signature): boolean {
|
||||||
|
if (bytes.length < offset + magic.length) return false;
|
||||||
|
return magic.every((b, i) => b == null || bytes[offset + i] === b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAscii(bytes: Uint8Array, from: number, length: number): string {
|
||||||
|
return String.fromCharCode(...bytes.subarray(from, from + length));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The format the first bytes of a file identify it as, or null if they identify nothing. */
|
||||||
|
export function sniffBytes(bytes: Uint8Array): { mime: string; label: string } | null {
|
||||||
|
if (readAscii(bytes, 4, 4) === "ftyp") {
|
||||||
|
return ISO_BRANDS[readAscii(bytes, 8, 4)] ?? { mime: "video/mp4", label: "MP4" };
|
||||||
|
}
|
||||||
|
const sig = SIGNATURES.find((s) => matches(bytes, s));
|
||||||
|
return sig == null ? null : { mime: sig.mime, label: sig.label };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first `bytes` bytes of a base64 payload, or null if it isn't base64 after all.
|
||||||
|
*
|
||||||
|
* Only the characters those bytes need are decoded. The trailing partial group is dropped
|
||||||
|
* rather than padded, since `atob` rejects a group of one or two characters outright and we
|
||||||
|
* have no use for the byte a three-character group would add.
|
||||||
|
*/
|
||||||
|
export function decodeBase64Prefix(text: string, offset: number, bytes: number): Uint8Array | null {
|
||||||
|
const wanted = Math.ceil(bytes / 3) * 4;
|
||||||
|
let b64 = text.slice(offset, offset + wanted);
|
||||||
|
b64 = b64.slice(0, b64.length - (b64.length % 4));
|
||||||
|
if (b64.length === 0) return null;
|
||||||
|
|
||||||
|
// The URL-safe alphabet stands for the same bytes
|
||||||
|
b64 = b64.replaceAll("-", "+").replaceAll("_", "/");
|
||||||
|
if (!/^[A-Za-z0-9+/]+$/.test(b64)) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const binary = atob(b64);
|
||||||
|
const out = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) {
|
||||||
|
out[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a value is, from its head alone, or null if nothing recognises it.
|
||||||
|
*
|
||||||
|
* `head` need only be the first {@link SNIFF_HEAD_CHARS} characters — pass more and the rest is
|
||||||
|
* ignored. The offsets in the result are relative to the start of the whole value.
|
||||||
|
*/
|
||||||
|
export function sniffValue(head: string): SniffedValue | null {
|
||||||
|
const lead = head.length - head.trimStart().length;
|
||||||
|
const value = head.slice(lead);
|
||||||
|
|
||||||
|
const dataUri = DATA_URI.exec(value);
|
||||||
|
if (dataUri != null) {
|
||||||
|
const declared = dataUri[1]!.toLowerCase();
|
||||||
|
const offset = lead + dataUri[0].length;
|
||||||
|
if (!dataUri[2]!.split(";").includes("base64")) {
|
||||||
|
// A percent-encoded payload states its own type or is text by definition
|
||||||
|
const mime = declared === "" ? "text/plain" : declared;
|
||||||
|
return { mime, label: labelForMime(mime), offset, encoding: "percent" };
|
||||||
|
}
|
||||||
|
if (!UNINFORMATIVE.includes(declared)) {
|
||||||
|
return { mime: declared, label: labelForMime(declared), offset, encoding: "base64" };
|
||||||
|
}
|
||||||
|
return sniffBase64At(head, offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sniffBase64At(head, lead);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sniffBase64At(head: string, offset: number): SniffedValue | null {
|
||||||
|
const bytes = decodeBase64Prefix(head, offset, SNIFF_BYTES);
|
||||||
|
const sniffed = bytes == null ? null : sniffBytes(bytes);
|
||||||
|
return sniffed == null ? null : { ...sniffed, offset, encoding: "base64" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The shortest run of base64 that {@link isEncodedRun} will accept as convincing */
|
||||||
|
const ENCODED_RUN_CHARS = 64;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a value is one unbroken run of base64, rather than text that merely opens like it.
|
||||||
|
*
|
||||||
|
* A magic number is only a few bytes, so short ones match by chance — `BM` is two, which comes
|
||||||
|
* up about once in every 65,000 values. That was harmless while a value had to be long enough
|
||||||
|
* to hurt rendering before anything was hidden, and the sniff only chose the label. It is not
|
||||||
|
* harmless when the sniff itself decides to hide something, so that rule asks for this as well:
|
||||||
|
* prose leaves the alphabet within a few characters, and an encoded blob never does.
|
||||||
|
*/
|
||||||
|
export function isEncodedRun(head: string, offset: number): boolean {
|
||||||
|
const run = head.slice(offset);
|
||||||
|
return run.length >= ENCODED_RUN_CHARS && /^[A-Za-z0-9+/\-_]+={0,2}$/.test(run);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subtypes whose own name makes a poor label */
|
||||||
|
const LABEL_OVERRIDES: Record<string, string> = {
|
||||||
|
"text/plain": "TEXT",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A word short enough for a tag. The distinguishing part of a subtype is its last dotted
|
||||||
|
* segment before any `+suffix`, so `image/svg+xml` reads as SVG and `application/vnd.ms-excel`
|
||||||
|
* as MS-EXCEL. Anything still too long falls back to the top-level type.
|
||||||
|
*/
|
||||||
|
export function labelForMime(mime: string): string {
|
||||||
|
const override = LABEL_OVERRIDES[mime];
|
||||||
|
if (override != null) {
|
||||||
|
return override;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [type, subtype] = mime.split("/");
|
||||||
|
const word = (subtype ?? "").split("+")[0]!.split(".").pop()!.replace(/^x-/, "");
|
||||||
|
if (word.length > 0 && word.length <= 10 && word !== "octet-stream") {
|
||||||
|
return word.toUpperCase();
|
||||||
|
}
|
||||||
|
return (type ?? mime).toUpperCase();
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { Completion } from "@codemirror/autocomplete";
|
||||||
|
import { EditorState, type TransactionSpec } from "@codemirror/state";
|
||||||
|
import type { EditorView } from "@codemirror/view";
|
||||||
|
import { describe, expect, test } from "vite-plus/test";
|
||||||
|
import { applyUrlCompletion, getUrlCompletionConfig } from "./completion";
|
||||||
|
|
||||||
|
describe("applyUrlCompletion", () => {
|
||||||
|
test("consumes an existing protocol suffix and preserves the rest of the URL", () => {
|
||||||
|
expect(applyCompletion("http://rickandmortyapi.com/api/character", "http://", 4)).toBe(
|
||||||
|
"http://rickandmortyapi.com/api/character",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("inserts a protocol when there is no existing suffix", () => {
|
||||||
|
expect(applyCompletion("htt", "http://", 3)).toBe("http://");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("replaces the full URL when accepting a saved URL", () => {
|
||||||
|
expect(applyCompletion("htt://old.example/path", "https://new.example/api", 3)).toBe(
|
||||||
|
"https://new.example/api",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getUrlCompletionConfig", () => {
|
||||||
|
test("always includes protocols alongside saved URL options", () => {
|
||||||
|
const config = getUrlCompletionConfig([{ label: "https://example.com" }]);
|
||||||
|
|
||||||
|
expect(config.options.map((option) => option.label)).toEqual([
|
||||||
|
"http://",
|
||||||
|
"https://",
|
||||||
|
"https://example.com",
|
||||||
|
]);
|
||||||
|
expect(config.options.every((option) => option.apply === applyUrlCompletion)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function applyCompletion(document: string, label: string, cursor: number) {
|
||||||
|
let state = EditorState.create({ doc: document, selection: { anchor: cursor } });
|
||||||
|
const view = {
|
||||||
|
state,
|
||||||
|
dispatch: (spec: TransactionSpec) => {
|
||||||
|
state = state.update(spec).state;
|
||||||
|
},
|
||||||
|
} as unknown as EditorView;
|
||||||
|
|
||||||
|
applyUrlCompletion(view, { label } satisfies Completion, 0, cursor);
|
||||||
|
return state.doc.toString();
|
||||||
|
}
|
||||||
@@ -1,9 +1,51 @@
|
|||||||
import { genericCompletion } from "../genericCompletion";
|
import { insertCompletionText, pickedCompletion, type Completion } from "@codemirror/autocomplete";
|
||||||
|
import type { EditorView } from "@codemirror/view";
|
||||||
|
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
|
||||||
|
import {
|
||||||
|
genericCompletion,
|
||||||
|
type GenericCompletion,
|
||||||
|
type GenericCompletionConfig,
|
||||||
|
} from "../genericCompletion";
|
||||||
|
|
||||||
export const completions = genericCompletion({
|
const protocolOptions: GenericCompletionOption[] = [
|
||||||
options: [
|
{ label: "http://", type: "constant" },
|
||||||
{ label: "http://", type: "constant" },
|
{ label: "https://", type: "constant" },
|
||||||
{ label: "https://", type: "constant" },
|
];
|
||||||
],
|
|
||||||
minMatch: 1,
|
export function getUrlCompletionConfig(
|
||||||
});
|
options: GenericCompletionOption[],
|
||||||
|
minMatch = 3,
|
||||||
|
): GenericCompletionConfig {
|
||||||
|
const urlOptions = [
|
||||||
|
...protocolOptions,
|
||||||
|
...options.filter(
|
||||||
|
(option) => !protocolOptions.some((protocol) => protocol.label === option.label),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
minMatch,
|
||||||
|
options: urlOptions.map<GenericCompletion>((option) => ({
|
||||||
|
...option,
|
||||||
|
apply: applyUrlCompletion,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyUrlCompletion(
|
||||||
|
view: EditorView,
|
||||||
|
completion: Completion,
|
||||||
|
from: number,
|
||||||
|
to: number,
|
||||||
|
) {
|
||||||
|
const isProtocol = /^https?:\/\/$/.test(completion.label);
|
||||||
|
const replaceTo = isProtocol
|
||||||
|
? to + (view.state.sliceDoc(to, to + 3) === "://" ? 3 : 0)
|
||||||
|
: view.state.doc.length;
|
||||||
|
|
||||||
|
view.dispatch({
|
||||||
|
...insertCompletionText(view.state, completion.label, from, replaceTo),
|
||||||
|
annotations: pickedCompletion.of(completion),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const completions = genericCompletion(getUrlCompletionConfig([], 1));
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, expect, test, vi } from "vite-plus/test";
|
||||||
|
import type { HotkeyAction } from "../../hooks/useHotKey";
|
||||||
|
import { HotkeyList } from "./HotkeyList";
|
||||||
|
|
||||||
|
vi.mock("./Hotkey", () => ({
|
||||||
|
Hotkey: ({ action }: { action: HotkeyAction }) =>
|
||||||
|
action === "sidebar.selected.move" ? null : <span>{action}</span>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./HotkeyLabel", () => ({
|
||||||
|
HotkeyLabel: ({ action }: { action: HotkeyAction }) => <span>{action}</span>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("HotkeyList", () => {
|
||||||
|
test("keeps a grid cell for actions without a shortcut", () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<HotkeyList hotkeys={["sidebar.selected.move", "request.send"]} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(markup).toContain(
|
||||||
|
'<span>sidebar.selected.move</span><div class="ml-4"></div><span>request.send</span>',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,7 +18,10 @@ export const HotkeyList = ({ hotkeys, bottomSlot, className }: Props) => {
|
|||||||
{hotkeys.map((hotkey) => (
|
{hotkeys.map((hotkey) => (
|
||||||
<Fragment key={hotkey}>
|
<Fragment key={hotkey}>
|
||||||
<HotkeyLabel className="truncate" action={hotkey} />
|
<HotkeyLabel className="truncate" action={hotkey} />
|
||||||
<Hotkey className="ml-4" action={hotkey} />
|
{/* Keep this grid cell when Hotkey renders nothing so later rows stay aligned. */}
|
||||||
|
<div className="ml-4">
|
||||||
|
<Hotkey action={hotkey} />
|
||||||
|
</div>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
{bottomSlot}
|
{bottomSlot}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { Button } from "./Button";
|
|||||||
import type { DropdownItem } from "./Dropdown";
|
import type { DropdownItem } from "./Dropdown";
|
||||||
import { Dropdown } from "./Dropdown";
|
import { Dropdown } from "./Dropdown";
|
||||||
import type { EditorProps } from "./Editor/Editor";
|
import type { EditorProps } from "./Editor/Editor";
|
||||||
|
import type { GenericCompletion } from "./Editor/genericCompletion";
|
||||||
import { Editor } from "./Editor/LazyEditor";
|
import { Editor } from "./Editor/LazyEditor";
|
||||||
import { IconButton } from "./IconButton";
|
import { IconButton } from "./IconButton";
|
||||||
import { IconTooltip } from "./IconTooltip";
|
import { IconTooltip } from "./IconTooltip";
|
||||||
@@ -63,6 +64,13 @@ export type InputProps = Pick<
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
rightSlot?: ReactNode;
|
rightSlot?: ReactNode;
|
||||||
|
/**
|
||||||
|
* Show a chevron that opens the `autocomplete` options as a menu, so a value can be picked
|
||||||
|
* without knowing what to type. Opt-in, because plenty of inputs pass `autocomplete` without
|
||||||
|
* wanting the affordance (the URL bar completes from history, and a chevron there would be
|
||||||
|
* noise). Renders nothing when there are no options to show.
|
||||||
|
*/
|
||||||
|
showOptionsPicker?: boolean;
|
||||||
size?: "2xs" | "xs" | "sm" | "md" | "auto";
|
size?: "2xs" | "xs" | "sm" | "md" | "auto";
|
||||||
stateKey: EditorProps["stateKey"];
|
stateKey: EditorProps["stateKey"];
|
||||||
extraExtensions?: EditorProps["extraExtensions"];
|
extraExtensions?: EditorProps["extraExtensions"];
|
||||||
@@ -115,6 +123,7 @@ function BaseInput({
|
|||||||
readOnly,
|
readOnly,
|
||||||
required,
|
required,
|
||||||
rightSlot,
|
rightSlot,
|
||||||
|
showOptionsPicker,
|
||||||
size = "md",
|
size = "md",
|
||||||
stateKey,
|
stateKey,
|
||||||
tint,
|
tint,
|
||||||
@@ -156,6 +165,17 @@ function BaseInput({
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Replace the whole value with a picked option. Dispatching runs it through the editor's
|
||||||
|
// onChange, same as typing, so callers see one consistent change path.
|
||||||
|
const insertOption = useCallback((value: string) => {
|
||||||
|
const view = editorRef.current;
|
||||||
|
if (view == null) return;
|
||||||
|
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value } });
|
||||||
|
view.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pickerOptions = showOptionsPicker && !disabled ? (props.autocomplete?.options ?? []) : [];
|
||||||
|
|
||||||
const setEditorRef = useCallback(
|
const setEditorRef = useCallback(
|
||||||
(h: EditorView | null) => {
|
(h: EditorView | null) => {
|
||||||
editorRef.current = h;
|
editorRef.current = h;
|
||||||
@@ -350,12 +370,55 @@ function BaseInput({
|
|||||||
onClick={() => setObscured((o) => !o)}
|
onClick={() => setObscured((o) => !o)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{pickerOptions.length > 0 && (
|
||||||
|
<OptionsPickerDropdown options={pickerOptions} onSelect={insertOption} />
|
||||||
|
)}
|
||||||
{rightSlot}
|
{rightSlot}
|
||||||
</HStack>
|
</HStack>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chevron that opens the input's autocomplete options as a menu, for browsing values rather than
|
||||||
|
* typing them. Shows `label` but inserts `apply` when set, so a long value can be listed under a
|
||||||
|
* short name (e.g. a User-Agent).
|
||||||
|
*
|
||||||
|
* Skipped in the tab order: once focus is in the input, inline autocomplete offers the same
|
||||||
|
* options, so this would only add a stop between fields. Hidden when the nearest container is
|
||||||
|
* narrow and the input needs the width more — written as a `max` query so it stays visible for
|
||||||
|
* inputs that have no container ancestor at all.
|
||||||
|
*/
|
||||||
|
function OptionsPickerDropdown({
|
||||||
|
options,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
options: GenericCompletion[];
|
||||||
|
onSelect: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
const items = useMemo<DropdownItem[]>(
|
||||||
|
() =>
|
||||||
|
options.map((o) => ({
|
||||||
|
label: o.label,
|
||||||
|
onSelect: () => onSelect(typeof o.apply === "string" ? o.apply : o.label),
|
||||||
|
})),
|
||||||
|
[options, onSelect],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown items={items}>
|
||||||
|
<IconButton
|
||||||
|
tabIndex={-1}
|
||||||
|
iconSize="sm"
|
||||||
|
size="xs"
|
||||||
|
icon="chevron_down"
|
||||||
|
title="Show suggestions"
|
||||||
|
className="mr-0.5 h-auto! my-0.5 text-text-subtlest @max-[24rem]:hidden"
|
||||||
|
/>
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function validateRequire(v: string) {
|
function validateRequire(v: string) {
|
||||||
return v.length > 0;
|
return v.length > 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
} from "@dnd-kit/core";
|
} from "@dnd-kit/core";
|
||||||
import { basename } from "@tauri-apps/api/path";
|
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { WrappedEnvironmentVariable } from "../../hooks/useEnvironmentVariables";
|
import type { WrappedEnvironmentVariable } from "../../hooks/useEnvironmentVariables";
|
||||||
@@ -34,12 +33,22 @@ import { Input } from "./Input";
|
|||||||
import { ensurePairId } from "./PairEditor.util";
|
import { ensurePairId } from "./PairEditor.util";
|
||||||
import type { RadioDropdownItem } from "./RadioDropdown";
|
import type { RadioDropdownItem } from "./RadioDropdown";
|
||||||
import { RadioDropdown } from "./RadioDropdown";
|
import { RadioDropdown } from "./RadioDropdown";
|
||||||
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
|
|
||||||
export interface PairEditorHandle {
|
export interface PairEditorHandle {
|
||||||
|
/**
|
||||||
|
* Focus a row's name field once it's able to take focus. Focus can't land immediately when the
|
||||||
|
* row isn't mounted yet or the editor is hidden — eg. sitting in a tab that's still becoming
|
||||||
|
* active — so this retries for up to ~1s. A newer focus request cancels a pending one.
|
||||||
|
*/
|
||||||
focusName(id: string): void;
|
focusName(id: string): void;
|
||||||
|
/** Focus a row's value field. See {@link PairEditorHandle.focusName} for timing. */
|
||||||
focusValue(id: string): void;
|
focusValue(id: string): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** ~1s at 60fps, plenty for a tab switch to land without spinning forever if it never does */
|
||||||
|
const MAX_FOCUS_ATTEMPTS = 60;
|
||||||
|
|
||||||
export type PairEditorProps = {
|
export type PairEditorProps = {
|
||||||
allowFileValues?: boolean;
|
allowFileValues?: boolean;
|
||||||
allowMultilineValues?: boolean;
|
allowMultilineValues?: boolean;
|
||||||
@@ -53,7 +62,7 @@ export type PairEditorProps = {
|
|||||||
nameValidate?: InputProps["validate"];
|
nameValidate?: InputProps["validate"];
|
||||||
noScroll?: boolean;
|
noScroll?: boolean;
|
||||||
onChange: (pairs: PairWithId[]) => void;
|
onChange: (pairs: PairWithId[]) => void;
|
||||||
pairs: Pair[];
|
pairs: EditablePair[];
|
||||||
stateKey: InputProps["stateKey"];
|
stateKey: InputProps["stateKey"];
|
||||||
setRef?: (n: PairEditorHandle) => void;
|
setRef?: (n: PairEditorHandle) => void;
|
||||||
valueAutocomplete?: (name: string) => GenericCompletionConfig | undefined;
|
valueAutocomplete?: (name: string) => GenericCompletionConfig | undefined;
|
||||||
@@ -72,13 +81,35 @@ export type Pair = {
|
|||||||
contentType?: string;
|
contentType?: string;
|
||||||
filename?: string;
|
filename?: string;
|
||||||
isFile?: boolean;
|
isFile?: boolean;
|
||||||
readOnlyName?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PairWithId = Pair & {
|
export type PairWithId = Pair & {
|
||||||
id: string;
|
id: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pair as handed to the editor. Adds behaviour that only the editor cares about, so the plain
|
||||||
|
* `Pair` stays the shape that gets written to models.
|
||||||
|
*/
|
||||||
|
export type EditablePair = Pair & {
|
||||||
|
/**
|
||||||
|
* When set, name edits are held until the field blurs and then committed through this, instead
|
||||||
|
* of calling `onChange` on every keystroke. Return false to reject the new name, which reverts
|
||||||
|
* the field. For names that can't be written directly, like a URL path placeholder that lives
|
||||||
|
* in the URL itself.
|
||||||
|
*/
|
||||||
|
commitName?: (name: string) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type EditablePairWithId = EditablePair & {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Strip the editor-only fields, so they can never reach a model write */
|
||||||
|
function toPairData({ commitName: _commitName, ...pair }: EditablePairWithId): PairWithId {
|
||||||
|
return pair;
|
||||||
|
}
|
||||||
|
|
||||||
/** Max number of pairs to show before prompting the user to reveal the rest */
|
/** Max number of pairs to show before prompting the user to reveal the rest */
|
||||||
const MAX_INITIAL_PAIRS = 30;
|
const MAX_INITIAL_PAIRS = 30;
|
||||||
|
|
||||||
@@ -106,8 +137,8 @@ export function PairEditor({
|
|||||||
setRef,
|
setRef,
|
||||||
}: PairEditorProps) {
|
}: PairEditorProps) {
|
||||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||||
const [isDragging, setIsDragging] = useState<PairWithId | null>(null);
|
const [isDragging, setIsDragging] = useState<EditablePairWithId | null>(null);
|
||||||
const [pairs, setPairs] = useState<PairWithId[]>([]);
|
const [pairs, setPairs] = useState<EditablePairWithId[]>([]);
|
||||||
const [showAll, toggleShowAll] = useToggle(false);
|
const [showAll, toggleShowAll] = useToggle(false);
|
||||||
// NOTE: Use local force update key because we trigger an effect on forceUpdateKey change. If
|
// NOTE: Use local force update key because we trigger an effect on forceUpdateKey change. If
|
||||||
// we simply pass forceUpdateKey to the editor, the data set by useEffect will be stale.
|
// we simply pass forceUpdateKey to the editor, the data set by useEffect will be stale.
|
||||||
@@ -115,16 +146,38 @@ export function PairEditor({
|
|||||||
|
|
||||||
const rowsRef = useRef<Record<string, RowHandle | null>>({});
|
const rowsRef = useRef<Record<string, RowHandle | null>>({});
|
||||||
|
|
||||||
|
const pendingFocusFrame = useRef<number | null>(null);
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (pendingFocusFrame.current != null) cancelAnimationFrame(pendingFocusFrame.current);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const focusWhenReady = useCallback((id: string, field: "name" | "value") => {
|
||||||
|
if (pendingFocusFrame.current != null) cancelAnimationFrame(pendingFocusFrame.current);
|
||||||
|
|
||||||
|
let attemptsLeft = MAX_FOCUS_ATTEMPTS;
|
||||||
|
const attempt = () => {
|
||||||
|
pendingFocusFrame.current = null;
|
||||||
|
const row = rowsRef.current[id];
|
||||||
|
const landed = field === "name" ? row?.focusName() : row?.focusValue();
|
||||||
|
if (landed || --attemptsLeft <= 0) return;
|
||||||
|
pendingFocusFrame.current = requestAnimationFrame(attempt);
|
||||||
|
};
|
||||||
|
attempt();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handle = useMemo<PairEditorHandle>(
|
const handle = useMemo<PairEditorHandle>(
|
||||||
() => ({
|
() => ({
|
||||||
focusName(id: string) {
|
focusName(id: string) {
|
||||||
rowsRef.current[id]?.focusName();
|
focusWhenReady(id, "name");
|
||||||
},
|
},
|
||||||
focusValue(id: string) {
|
focusValue(id: string) {
|
||||||
rowsRef.current[id]?.focusValue();
|
focusWhenReady(id, "value");
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[],
|
[focusWhenReady],
|
||||||
);
|
);
|
||||||
|
|
||||||
const initPairEditorRow = useCallback(
|
const initPairEditorRow = useCallback(
|
||||||
@@ -147,7 +200,7 @@ export function PairEditor({
|
|||||||
// oxlint-disable-next-line react-hooks/exhaustive-deps -- Only care about forceUpdateKey
|
// oxlint-disable-next-line react-hooks/exhaustive-deps -- Only care about forceUpdateKey
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Remove empty headers on initial render and ensure they all have valid ids (pairs didn't use to have IDs)
|
// Remove empty headers on initial render and ensure they all have valid ids (pairs didn't use to have IDs)
|
||||||
const newPairs: PairWithId[] = [];
|
const newPairs: EditablePairWithId[] = [];
|
||||||
for (let i = 0; i < originalPairs.length; i++) {
|
for (let i = 0; i < originalPairs.length; i++) {
|
||||||
const p = originalPairs[i];
|
const p = originalPairs[i];
|
||||||
if (!p) continue; // Make TS happy
|
if (!p) continue; // Make TS happy
|
||||||
@@ -155,6 +208,21 @@ export function PairEditor({
|
|||||||
newPairs.push(ensurePairId(p));
|
newPairs.push(ensurePairId(p));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When the reset holds the exact same rows (eg. renaming a URL path placeholder, which keeps
|
||||||
|
// every row id), swap the data in without rebuilding the row editors. Unfocused inputs re-seed
|
||||||
|
// themselves when `defaultValue` changes, and a rebuild would drop the user's focus and
|
||||||
|
// selection — like tabbing from a placeholder's name into its value.
|
||||||
|
const trailingPair = pairs[pairs.length - 1];
|
||||||
|
const sameRows =
|
||||||
|
trailingPair != null &&
|
||||||
|
isPairEmpty(trailingPair) &&
|
||||||
|
pairs.length === newPairs.length + 1 &&
|
||||||
|
newPairs.every((p, i) => p.id === pairs[i]?.id);
|
||||||
|
if (sameRows) {
|
||||||
|
setPairs([...newPairs, trailingPair]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Add empty last pair if there is none
|
// Add empty last pair if there is none
|
||||||
const lastPair = newPairs[newPairs.length - 1];
|
const lastPair = newPairs[newPairs.length - 1];
|
||||||
if (lastPair == null || !isPairEmpty(lastPair)) {
|
if (lastPair == null || !isPairEmpty(lastPair)) {
|
||||||
@@ -166,10 +234,10 @@ export function PairEditor({
|
|||||||
}, [forceUpdateKey]);
|
}, [forceUpdateKey]);
|
||||||
|
|
||||||
const setPairsAndSave = useCallback(
|
const setPairsAndSave = useCallback(
|
||||||
(fn: (pairs: PairWithId[]) => PairWithId[]) => {
|
(fn: (pairs: EditablePairWithId[]) => EditablePairWithId[]) => {
|
||||||
setPairs((oldPairs) => {
|
setPairs((oldPairs) => {
|
||||||
const pairs = fn(oldPairs);
|
const pairs = fn(oldPairs);
|
||||||
onChange(pairs);
|
onChange(pairs.map(toPairData));
|
||||||
return pairs;
|
return pairs;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -177,7 +245,7 @@ export function PairEditor({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleChange = useCallback(
|
const handleChange = useCallback(
|
||||||
(pair: PairWithId) =>
|
(pair: EditablePairWithId) =>
|
||||||
setPairsAndSave((pairs) => pairs.map((p) => (pair.id !== p.id ? p : pair))),
|
setPairsAndSave((pairs) => pairs.map((p) => (pair.id !== p.id ? p : pair))),
|
||||||
[setPairsAndSave],
|
[setPairsAndSave],
|
||||||
);
|
);
|
||||||
@@ -362,14 +430,14 @@ export function PairEditor({
|
|||||||
|
|
||||||
type PairEditorRowProps = {
|
type PairEditorRowProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
pair: PairWithId;
|
pair: EditablePairWithId;
|
||||||
forceFocusNamePairId?: string | null;
|
forceFocusNamePairId?: string | null;
|
||||||
forceFocusValuePairId?: string | null;
|
forceFocusValuePairId?: string | null;
|
||||||
onChange?: (pair: PairWithId) => void;
|
onChange?: (pair: EditablePairWithId) => void;
|
||||||
onDelete?: (pair: PairWithId, focusPrevious: boolean) => void;
|
onDelete?: (pair: EditablePairWithId, focusPrevious: boolean) => void;
|
||||||
onFocusName?: (pair: PairWithId) => void;
|
onFocusName?: (pair: EditablePairWithId) => void;
|
||||||
onFocusValue?: (pair: PairWithId) => void;
|
onFocusValue?: (pair: EditablePairWithId) => void;
|
||||||
onSubmit?: (pair: PairWithId) => void;
|
onSubmit?: (pair: EditablePairWithId) => void;
|
||||||
isLast?: boolean;
|
isLast?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
disableDrag?: boolean;
|
disableDrag?: boolean;
|
||||||
@@ -397,8 +465,8 @@ type PairEditorRowProps = {
|
|||||||
>;
|
>;
|
||||||
|
|
||||||
interface RowHandle {
|
interface RowHandle {
|
||||||
focusName(): void;
|
focusName(): boolean;
|
||||||
focusValue(): void;
|
focusValue(): boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PairEditorRow({
|
export function PairEditorRow({
|
||||||
@@ -436,9 +504,11 @@ export function PairEditorRow({
|
|||||||
const handle = useRef<RowHandle>({
|
const handle = useRef<RowHandle>({
|
||||||
focusName() {
|
focusName() {
|
||||||
nameInputRef.current?.focus();
|
nameInputRef.current?.focus();
|
||||||
|
return nameInputRef.current?.isFocused() ?? false;
|
||||||
},
|
},
|
||||||
focusValue() {
|
focusValue() {
|
||||||
valueInputRef.current?.focus();
|
valueInputRef.current?.focus();
|
||||||
|
return valueInputRef.current?.isFocused() ?? false;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -471,11 +541,37 @@ export function PairEditorRow({
|
|||||||
[onChange, pair],
|
[onChange, pair],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The name being typed into a deferred-commit field, before it's committed or reverted
|
||||||
|
const pendingName = useRef<string | null>(null);
|
||||||
|
|
||||||
const handleChangeName = useMemo(
|
const handleChangeName = useMemo(
|
||||||
() => (name: string) => onChange?.({ ...pair, name }),
|
() => (name: string) => {
|
||||||
|
// Keep the edit local until commit. Writing on every keystroke would reset the editor from
|
||||||
|
// beneath the cursor, since the pairs are derived from the name being edited.
|
||||||
|
if (pair.commitName != null) pendingName.current = name;
|
||||||
|
else onChange?.({ ...pair, name });
|
||||||
|
},
|
||||||
[onChange, pair],
|
[onChange, pair],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const revertName = useCallback(() => {
|
||||||
|
const nameInput = nameInputRef.current;
|
||||||
|
if (nameInput != null) {
|
||||||
|
const changes = { from: 0, to: nameInput.value().length, insert: pair.name };
|
||||||
|
nameInput.dispatch({ changes });
|
||||||
|
}
|
||||||
|
pendingName.current = null;
|
||||||
|
}, [pair.name]);
|
||||||
|
|
||||||
|
const handleBlurName = useCallback(() => {
|
||||||
|
if (pair.commitName == null) return;
|
||||||
|
|
||||||
|
const name = pendingName.current;
|
||||||
|
pendingName.current = null;
|
||||||
|
if (name == null || name === pair.name) return;
|
||||||
|
if (!pair.commitName(name)) revertName();
|
||||||
|
}, [pair, revertName]);
|
||||||
|
|
||||||
const handleChangeValueText = useMemo(
|
const handleChangeValueText = useMemo(
|
||||||
() => (value: string) => onChange?.({ ...pair, value, isFile: false }),
|
() => (value: string) => onChange?.({ ...pair, value, isFile: false }),
|
||||||
[onChange, pair],
|
[onChange, pair],
|
||||||
@@ -551,6 +647,10 @@ export function PairEditorRow({
|
|||||||
[setDraggableRef, setDroppableRef],
|
[setDraggableRef, setDroppableRef],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Skip the trailing placeholder row. It exists to start a new pair, so a picker there would
|
||||||
|
// imply it's already a real row. `Input` handles the rest, including having no options to show.
|
||||||
|
const showOptionsPicker = !isLast;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={handleSetRef}
|
ref={handleSetRef}
|
||||||
@@ -596,7 +696,7 @@ export function PairEditorRow({
|
|||||||
stateKey={`name.${pair.id}.${stateKey}`}
|
stateKey={`name.${pair.id}.${stateKey}`}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
wrapLines={false}
|
wrapLines={false}
|
||||||
readOnly={pair.readOnlyName || isDraggingGlobal}
|
readOnly={isDraggingGlobal}
|
||||||
size="sm"
|
size="sm"
|
||||||
required={!isLast && !!pair.enabled && !!pair.value}
|
required={!isLast && !!pair.enabled && !!pair.value}
|
||||||
validate={nameValidate}
|
validate={nameValidate}
|
||||||
@@ -606,12 +706,14 @@ export function PairEditorRow({
|
|||||||
defaultValue={pair.name}
|
defaultValue={pair.name}
|
||||||
label="Name"
|
label="Name"
|
||||||
name={`name[${index}]`}
|
name={`name[${index}]`}
|
||||||
|
onBlur={handleBlurName}
|
||||||
onChange={handleChangeName}
|
onChange={handleChangeName}
|
||||||
onFocus={handleFocusName}
|
onFocus={handleFocusName}
|
||||||
placeholder={namePlaceholder ?? "name"}
|
placeholder={namePlaceholder ?? "name"}
|
||||||
autocomplete={nameAutocomplete}
|
autocomplete={nameAutocomplete}
|
||||||
autocompleteVariables={nameAutocompleteVariables}
|
autocompleteVariables={nameAutocompleteVariables}
|
||||||
autocompleteFunctions={nameAutocompleteFunctions}
|
autocompleteFunctions={nameAutocompleteFunctions}
|
||||||
|
showOptionsPicker={showOptionsPicker}
|
||||||
/>
|
/>
|
||||||
<div className="w-full grid grid-cols-[minmax(0,1fr)_auto] gap-1 items-center">
|
<div className="w-full grid grid-cols-[minmax(0,1fr)_auto] gap-1 items-center">
|
||||||
{pair.isFile ? (
|
{pair.isFile ? (
|
||||||
@@ -656,6 +758,7 @@ export function PairEditorRow({
|
|||||||
autocomplete={valueAutocomplete?.(pair.name)}
|
autocomplete={valueAutocomplete?.(pair.name)}
|
||||||
autocompleteFunctions={valueAutocompleteFunctions}
|
autocompleteFunctions={valueAutocompleteFunctions}
|
||||||
autocompleteVariables={valueAutocompleteVariablesFiltered}
|
autocompleteVariables={valueAutocompleteVariablesFiltered}
|
||||||
|
showOptionsPicker={showOptionsPicker}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -675,8 +778,10 @@ export function PairEditorRow({
|
|||||||
<IconButton
|
<IconButton
|
||||||
iconSize="sm"
|
iconSize="sm"
|
||||||
size="xs"
|
size="xs"
|
||||||
icon={isLast || disabled ? "empty" : "chevron_down"}
|
// Ellipsis rather than a chevron: the name and value fields now carry chevrons for
|
||||||
title="Select form data type"
|
// picking a suggestion, and this menu acts on the row instead of filling in a field.
|
||||||
|
icon={isLast || disabled ? "empty" : "ellipsis_vertical"}
|
||||||
|
title="More actions"
|
||||||
className="text-text-subtlest"
|
className="text-text-subtlest"
|
||||||
/>
|
/>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
@@ -746,7 +851,7 @@ function FileActionsDropdown({
|
|||||||
leftSlot: <Icon icon="file_code" />,
|
leftSlot: <Icon icon="file_code" />,
|
||||||
onSelect: async () => {
|
onSelect: async () => {
|
||||||
console.log("PAIR", pair);
|
console.log("PAIR", pair);
|
||||||
const defaultFilename = await basename(pair.value ?? "");
|
const defaultFilename = await platform.files.basename(pair.value ?? "");
|
||||||
const filename = await showPrompt({
|
const filename = await showPrompt({
|
||||||
id: "filename",
|
id: "filename",
|
||||||
title: "Override Filename",
|
title: "Override Filename",
|
||||||
@@ -800,7 +905,8 @@ function FileActionsDropdown({
|
|||||||
<IconButton
|
<IconButton
|
||||||
iconSize="sm"
|
iconSize="sm"
|
||||||
size="xs"
|
size="xs"
|
||||||
icon="chevron_down"
|
// Matches the plain row menu, so the actions column reads the same for file and text rows
|
||||||
|
icon="ellipsis_vertical"
|
||||||
title="Select form data type"
|
title="Select form data type"
|
||||||
className="text-text-subtlest"
|
className="text-text-subtlest"
|
||||||
/>
|
/>
|
||||||
@@ -808,7 +914,7 @@ function FileActionsDropdown({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyPair(): PairWithId {
|
function emptyPair(): EditablePairWithId {
|
||||||
return ensurePairId({ enabled: true, name: "", value: "" });
|
return ensurePairId({ enabled: true, name: "", value: "" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { generateId } from "../../lib/generateId";
|
import { generateId } from "../../lib/generateId";
|
||||||
import type { Pair, PairWithId } from "./PairEditor";
|
import type { Pair, PairWithId } from "./PairEditor";
|
||||||
|
|
||||||
export function ensurePairId(p: Pair): PairWithId {
|
// NOTE: Generic so callers keep whatever they passed in (eg. an EditablePair stays editable)
|
||||||
|
export function ensurePairId<T extends Pair>(p: T): T & PairWithId {
|
||||||
if (typeof p.id === "string") {
|
if (typeof p.id === "string") {
|
||||||
return p as PairWithId;
|
return p as T & PairWithId;
|
||||||
}
|
}
|
||||||
return { ...p, id: p.id ?? generateId() };
|
return { ...p, id: p.id ?? generateId() };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type } from "@tauri-apps/plugin-os";
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
import { HStack } from "@yaakapp-internal/ui";
|
import { HStack } from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import type { CSSProperties, ReactNode } from "react";
|
import type { CSSProperties, ReactNode } from "react";
|
||||||
@@ -66,7 +66,7 @@ export function Select<T extends string>({
|
|||||||
<Label htmlFor={id} visuallyHidden={hideLabel} className={labelClassName} help={help}>
|
<Label htmlFor={id} visuallyHidden={hideLabel} className={labelClassName} help={help}>
|
||||||
{label}
|
{label}
|
||||||
</Label>
|
</Label>
|
||||||
{type() === "macos" && !filterable ? (
|
{platform.osType() === "macos" && !filterable ? (
|
||||||
<HStack
|
<HStack
|
||||||
space={2}
|
space={2}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
|
|||||||
@@ -1,13 +1,31 @@
|
|||||||
import type { Color } from "@yaakapp-internal/plugins";
|
import type { Color } from "@yaakapp-internal/plugins";
|
||||||
|
import type { IconProps } from "@yaakapp-internal/ui";
|
||||||
|
import { IconButton } from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single control attached to a labelled separator, rendered between the label
|
||||||
|
* and the rule.
|
||||||
|
*
|
||||||
|
* Declared rather than passed as a node so the separator keeps ownership of the
|
||||||
|
* things that are easy to get wrong by hand: matching the label's colour, and
|
||||||
|
* staying out of the rule's way when the label is long.
|
||||||
|
*/
|
||||||
|
export interface SeparatorAction {
|
||||||
|
icon: IconProps["icon"];
|
||||||
|
/** Tooltip and accessible name. Required — the control is icon-only. */
|
||||||
|
title: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
orientation?: "horizontal" | "vertical";
|
orientation?: "horizontal" | "vertical";
|
||||||
dashed?: boolean;
|
dashed?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
color?: Color;
|
color?: Color;
|
||||||
|
action?: SeparatorAction;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Separator({
|
export function Separator({
|
||||||
@@ -16,15 +34,31 @@ export function Separator({
|
|||||||
dashed,
|
dashed,
|
||||||
orientation = "horizontal",
|
orientation = "horizontal",
|
||||||
children,
|
children,
|
||||||
|
action,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<div role="presentation" className={classNames(className, "flex items-center w-full")}>
|
<div role="presentation" className={classNames(className, "flex items-center w-full")}>
|
||||||
{children && (
|
{children && (
|
||||||
<div className="text-sm text-text-subtlest mr-2 whitespace-nowrap">{children}</div>
|
<div className="text-sm text-text-subtlest mr-2 whitespace-nowrap">{children}</div>
|
||||||
)}
|
)}
|
||||||
|
{action && (
|
||||||
|
<IconButton
|
||||||
|
size="2xs"
|
||||||
|
iconSize="xs"
|
||||||
|
className="shrink-0 mr-2 -ml-1"
|
||||||
|
// Forced, because the button itself sets `text-text` at full strength.
|
||||||
|
iconClassName="text-text-subtlest!"
|
||||||
|
icon={action.icon}
|
||||||
|
title={action.title}
|
||||||
|
onClick={action.onClick}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"opacity-60",
|
"opacity-60",
|
||||||
|
// Keep a stub of the line visible no matter how long the label is —
|
||||||
|
// `w-full` alone gets squeezed to nothing by a wide label.
|
||||||
|
orientation === "horizontal" && "min-w-8",
|
||||||
color == null && "border-border",
|
color == null && "border-border",
|
||||||
color === "primary" && "border-primary",
|
color === "primary" && "border-primary",
|
||||||
color === "secondary" && "border-secondary",
|
color === "secondary" && "border-secondary",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||||
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo } from "react";
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
@@ -11,6 +12,7 @@ import type { DropdownItem } from "../core/Dropdown";
|
|||||||
import { Dropdown } from "../core/Dropdown";
|
import { Dropdown } from "../core/Dropdown";
|
||||||
import type { EditorProps } from "../core/Editor/Editor";
|
import type { EditorProps } from "../core/Editor/Editor";
|
||||||
import { Editor } from "../core/Editor/LazyEditor";
|
import { Editor } from "../core/Editor/LazyEditor";
|
||||||
|
import { IconButton } from "../core/IconButton";
|
||||||
import type { RadioDropdownItem } from "../core/RadioDropdown";
|
import type { RadioDropdownItem } from "../core/RadioDropdown";
|
||||||
import { RadioDropdown } from "../core/RadioDropdown";
|
import { RadioDropdown } from "../core/RadioDropdown";
|
||||||
import { Banner, FormattedError, Icon } from "@yaakapp-internal/ui";
|
import { Banner, FormattedError, Icon } from "@yaakapp-internal/ui";
|
||||||
@@ -18,6 +20,7 @@ import { Separator } from "../core/Separator";
|
|||||||
import { tryFormatGraphql } from "../../lib/formatters";
|
import { tryFormatGraphql } from "../../lib/formatters";
|
||||||
import { parseGraphQLOperationNames } from "../../lib/graphqlOperationNames";
|
import { parseGraphQLOperationNames } from "../../lib/graphqlOperationNames";
|
||||||
import { normalizeGraphQLBody } from "../../lib/requestBodyConversion";
|
import { normalizeGraphQLBody } from "../../lib/requestBodyConversion";
|
||||||
|
import { revealInFinderText } from "../../lib/reveal";
|
||||||
import { showGraphQLDocExplorerAtom } from "./graphqlAtoms";
|
import { showGraphQLDocExplorerAtom } from "./graphqlAtoms";
|
||||||
|
|
||||||
type Props = Pick<EditorProps, "heightMode" | "className" | "forceUpdateKey"> & {
|
type Props = Pick<EditorProps, "heightMode" | "className" | "forceUpdateKey"> & {
|
||||||
@@ -28,6 +31,10 @@ type Props = Pick<EditorProps, "heightMode" | "className" | "forceUpdateKey"> &
|
|||||||
|
|
||||||
const OPERATION_NAME_NOT_SPECIFIED = "";
|
const OPERATION_NAME_NOT_SPECIFIED = "";
|
||||||
|
|
||||||
|
// How much of the end of a schema filename is pinned when middle-truncating it.
|
||||||
|
// Enough to keep the extension and a little of the name before it.
|
||||||
|
const FILE_NAME_TAIL_CHARS = 12;
|
||||||
|
|
||||||
export function GraphQLEditor(props: Props) {
|
export function GraphQLEditor(props: Props) {
|
||||||
// There's some weirdness with stale onChange being called when switching requests, so we'll
|
// There's some weirdness with stale onChange being called when switching requests, so we'll
|
||||||
// key on the request ID as a workaround for now.
|
// key on the request ID as a workaround for now.
|
||||||
@@ -38,9 +45,41 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
|||||||
const [autoIntrospectDisabled, setAutoIntrospectDisabled] = useLocalStorage<
|
const [autoIntrospectDisabled, setAutoIntrospectDisabled] = useLocalStorage<
|
||||||
Record<string, boolean>
|
Record<string, boolean>
|
||||||
>("graphQLAutoIntrospectDisabled", {});
|
>("graphQLAutoIntrospectDisabled", {});
|
||||||
const { schema, isLoading, error, refetch, clear } = useIntrospectGraphQL(baseRequest, {
|
const {
|
||||||
|
schema,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
clear,
|
||||||
|
loadFromFile,
|
||||||
|
reloadFromFile,
|
||||||
|
removeSchemaFile,
|
||||||
|
filePath,
|
||||||
|
} = useIntrospectGraphQL(baseRequest, {
|
||||||
disabled: autoIntrospectDisabled?.[baseRequest.id],
|
disabled: autoIntrospectDisabled?.[baseRequest.id],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Last path segment, for display only. The host owns real path semantics; this
|
||||||
|
// just needs something short enough to label the divider with.
|
||||||
|
const fileName = useMemo(() => filePath?.split(/[/\\]/).pop() || filePath, [filePath]);
|
||||||
|
|
||||||
|
// Selecting a file is all it takes — the request's source becomes that file,
|
||||||
|
// which is what keeps automatic introspection from overwriting it.
|
||||||
|
const handleLoadFromFile = useCallback(async () => {
|
||||||
|
const selected = await platform.dialog.open({
|
||||||
|
title: "Load GraphQL Schema",
|
||||||
|
multiple: false,
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
name: "GraphQL Schema",
|
||||||
|
extensions: ["graphql", "graphqls", "gql", "json"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (selected == null) return;
|
||||||
|
|
||||||
|
await loadFromFile(selected);
|
||||||
|
}, [loadFromFile]);
|
||||||
const [currentBody, setCurrentBody] = useStateWithDeps<{
|
const [currentBody, setCurrentBody] = useStateWithDeps<{
|
||||||
query: string;
|
query: string;
|
||||||
variables: string | undefined;
|
variables: string | undefined;
|
||||||
@@ -160,14 +199,37 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
|||||||
...((schema != null
|
...((schema != null
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
label: "Clear",
|
label: "Clear Schema",
|
||||||
onSelect: clear,
|
onSelect: clear,
|
||||||
color: "danger",
|
color: "danger",
|
||||||
leftSlot: <Icon icon="trash" />,
|
leftSlot: <Icon icon="trash" />,
|
||||||
},
|
},
|
||||||
{ type: "separator" },
|
|
||||||
]
|
]
|
||||||
: []) satisfies DropdownItem[]),
|
: []) satisfies DropdownItem[]),
|
||||||
|
{
|
||||||
|
// Labels the source actions below it, so the menu says where the
|
||||||
|
// schema came from without spending a row on it.
|
||||||
|
type: "separator",
|
||||||
|
hidden: schema == null && filePath == null,
|
||||||
|
label:
|
||||||
|
fileName == null || filePath == null ? undefined : (
|
||||||
|
// Middle truncation: the head shrinks and ellipsizes while the
|
||||||
|
// tail is pinned, so the extension always survives. Full path
|
||||||
|
// on hover.
|
||||||
|
<div className="flex min-w-0 max-w-[16rem] font-mono text-xs" title={filePath}>
|
||||||
|
<span className="truncate">{fileName.slice(0, -FILE_NAME_TAIL_CHARS)}</span>
|
||||||
|
<span className="shrink-0">{fileName.slice(-FILE_NAME_TAIL_CHARS)}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
action:
|
||||||
|
filePath == null
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
icon: "folder_symlink",
|
||||||
|
title: revealInFinderText,
|
||||||
|
onClick: () => platform.revealItemInDir(filePath),
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
hidden: !error,
|
hidden: !error,
|
||||||
label: (
|
label: (
|
||||||
@@ -210,25 +272,33 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
|||||||
type: "content",
|
type: "content",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
hidden: schema == null,
|
// One refresh action for either source: re-read the file, or
|
||||||
label: `${isDocOpen ? "Hide" : "Show"} Documentation`,
|
// re-introspect the server.
|
||||||
leftSlot: <Icon icon="book_open_text" />,
|
label: "Reload Schema",
|
||||||
onSelect: () => {
|
leftSlot: <Icon icon="refresh" spin={isLoading} />,
|
||||||
setGraphqlDocStateAtomValue((v) => ({
|
keepOpenOnSelect: true,
|
||||||
...v,
|
// Failures surface through the hook's error state either way.
|
||||||
[request.id]: isDocOpen ? undefined : null,
|
onSelect: async () => {
|
||||||
}));
|
if (filePath != null) await reloadFromFile();
|
||||||
|
else await refetch();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Introspect Schema",
|
label: filePath == null ? "Load Schema from File…" : "Load a Different File…",
|
||||||
leftSlot: <Icon icon="refresh" spin={isLoading} />,
|
leftSlot: <Icon icon="import" />,
|
||||||
keepOpenOnSelect: true,
|
onSelect: handleLoadFromFile,
|
||||||
onSelect: refetch,
|
|
||||||
},
|
},
|
||||||
{ type: "separator", label: "Setting" },
|
|
||||||
{
|
{
|
||||||
label: "Automatic Introspection",
|
hidden: filePath == null,
|
||||||
|
label: "Stop Using File",
|
||||||
|
leftSlot: <Icon icon="x" />,
|
||||||
|
onSelect: removeSchemaFile,
|
||||||
|
},
|
||||||
|
{ type: "separator", label: "Settings" },
|
||||||
|
{
|
||||||
|
// Governs both sources: re-introspecting the server, and
|
||||||
|
// re-reading the file when the request is opened.
|
||||||
|
label: filePath == null ? "Automatic Introspection" : "Automatic Reload",
|
||||||
keepOpenOnSelect: true,
|
keepOpenOnSelect: true,
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
setAutoIntrospectDisabled({
|
setAutoIntrospectDisabled({
|
||||||
@@ -261,6 +331,29 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
|||||||
</Dropdown>
|
</Dropdown>
|
||||||
)}
|
)}
|
||||||
</div>,
|
</div>,
|
||||||
|
// Sits after the schema control it depends on. Always rendered, disabled
|
||||||
|
// without a schema, so the row never changes shape.
|
||||||
|
<div key="documentation" className="opacity-100!">
|
||||||
|
<IconButton
|
||||||
|
size="sm"
|
||||||
|
variant="border"
|
||||||
|
icon="book_open_text"
|
||||||
|
disabled={schema == null}
|
||||||
|
title={
|
||||||
|
schema == null
|
||||||
|
? "Documentation unavailable without a schema"
|
||||||
|
: isDocOpen
|
||||||
|
? "Hide Documentation"
|
||||||
|
: "Show Documentation"
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
setGraphqlDocStateAtomValue((v) => ({
|
||||||
|
...v,
|
||||||
|
[request.id]: isDocOpen ? undefined : null,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
schema,
|
schema,
|
||||||
@@ -272,6 +365,11 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
|||||||
isLoading,
|
isLoading,
|
||||||
operationNames,
|
operationNames,
|
||||||
refetch,
|
refetch,
|
||||||
|
handleLoadFromFile,
|
||||||
|
reloadFromFile,
|
||||||
|
removeSchemaFile,
|
||||||
|
filePath,
|
||||||
|
fileName,
|
||||||
autoIntrospectDisabled,
|
autoIntrospectDisabled,
|
||||||
baseRequest.id,
|
baseRequest.id,
|
||||||
setGraphqlDocStateAtomValue,
|
setGraphqlDocStateAtomValue,
|
||||||
|
|||||||
@@ -1,26 +1,29 @@
|
|||||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
bodyPath?: string;
|
/** A URL for the body the host already stored. */
|
||||||
|
bodyUrl?: string;
|
||||||
data?: Uint8Array;
|
data?: Uint8Array;
|
||||||
|
mimeType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AudioViewer({ bodyPath, data }: Props) {
|
export function AudioViewer({ bodyUrl, data, mimeType }: Props) {
|
||||||
const [src, setSrc] = useState<string>();
|
const [src, setSrc] = useState<string>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (bodyPath) {
|
if (bodyUrl) {
|
||||||
setSrc(convertFileSrc(bodyPath));
|
setSrc(bodyUrl);
|
||||||
} else if (data) {
|
} else if (data) {
|
||||||
const blob = new Blob([new Uint8Array(data)], { type: "audio/mpeg" });
|
// The type matters here in a way it doesn't for an image: a media element goes by what
|
||||||
const url = URL.createObjectURL(blob);
|
// the blob declares rather than sniffing it, so an Ogg labelled as MP3 won't play
|
||||||
setSrc(url);
|
const blob = new Blob([new Uint8Array(data)], { type: mimeType ?? "audio/mpeg" });
|
||||||
return () => URL.revokeObjectURL(url);
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
setSrc(objectUrl);
|
||||||
|
return () => URL.revokeObjectURL(objectUrl);
|
||||||
} else {
|
} else {
|
||||||
setSrc(undefined);
|
setSrc(undefined);
|
||||||
}
|
}
|
||||||
}, [bodyPath, data]);
|
}, [bodyUrl, data, mimeType]);
|
||||||
|
|
||||||
// oxlint-disable-next-line jsx-a11y/media-has-caption
|
// oxlint-disable-next-line jsx-a11y/media-has-caption
|
||||||
return <audio className="w-full" controls src={src} />;
|
return <audio className="w-full" controls src={src} />;
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, expect, test, vi } from "vite-plus/test";
|
||||||
|
import { CsvViewerInner } from "./CsvViewer";
|
||||||
|
|
||||||
|
vi.mock("@yaakapp-internal/ui", () => ({
|
||||||
|
Table: ({ children }: { children: ReactNode }) => <table>{children}</table>,
|
||||||
|
TableBody: ({ children }: { children: ReactNode }) => <tbody>{children}</tbody>,
|
||||||
|
TableCell: ({ children }: { children: ReactNode }) => <td>{children}</td>,
|
||||||
|
TableHead: ({ children }: { children: ReactNode }) => <thead>{children}</thead>,
|
||||||
|
TableHeaderCell: ({ children }: { children: ReactNode }) => <th>{children}</th>,
|
||||||
|
TableRow: ({ children }: { children: ReactNode }) => <tr>{children}</tr>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("CsvViewer", () => {
|
||||||
|
test("renders columns that extend beyond the first row", () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<CsvViewerInner
|
||||||
|
text={[
|
||||||
|
"startDate,2026-02-03T00:00-03:00",
|
||||||
|
"endDate,2026-02-03T23:59:59-03:00",
|
||||||
|
"id,Fecha de inicio,Nombre,Estado,Perfil de puesto,ID de sucursal,Sucursal,Fecha de fin,ID de usuario",
|
||||||
|
"391118210,2026-02-03 12:58:55,atencion1,Disponible,ATD,3549,sucursal,2026-02-03 12:59:08,42041",
|
||||||
|
].join("\n")}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(markup).toContain("ID de usuario");
|
||||||
|
expect(markup).toContain("42041");
|
||||||
|
expect(markup.match(/<td>/g)).toHaveLength(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,27 +26,33 @@ export function CsvViewer({ text, className }: Props) {
|
|||||||
export function CsvViewerInner({ text, className }: { text: string | null; className?: string }) {
|
export function CsvViewerInner({ text, className }: { text: string | null; className?: string }) {
|
||||||
const parsed = useMemo(() => {
|
const parsed = useMemo(() => {
|
||||||
if (text == null) return null;
|
if (text == null) return null;
|
||||||
return Papa.parse<Record<string, string>>(text, { header: true, skipEmptyLines: true });
|
return Papa.parse<string[]>(text, { skipEmptyLines: true });
|
||||||
}, [text]);
|
}, [text]);
|
||||||
|
|
||||||
if (parsed === null) return null;
|
if (parsed === null) return null;
|
||||||
|
|
||||||
|
const header = parsed.data[0] ?? [];
|
||||||
|
const rows = parsed.data.slice(1);
|
||||||
|
const columnCount = parsed.data.reduce((count, row) => Math.max(count, row.length), 0);
|
||||||
|
const columnIndexes = Array.from({ length: columnCount }, (_, index) => index);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-auto h-full">
|
<div className="overflow-auto h-full">
|
||||||
<Table className={classNames(className, "text-sm")}>
|
<Table className={classNames(className, "text-sm")}>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
{parsed.meta.fields?.map((field) => (
|
{columnIndexes.map((columnIndex) => (
|
||||||
<TableHeaderCell key={field}>{field}</TableHeaderCell>
|
<TableHeaderCell key={columnIndex}>{header[columnIndex] ?? ""}</TableHeaderCell>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{parsed.data.map((row, i) => (
|
{rows.map((row, i) => (
|
||||||
// oxlint-disable-next-line react/no-array-index-key
|
// oxlint-disable-next-line react/no-array-index-key
|
||||||
<TableRow key={i}>
|
<TableRow key={i}>
|
||||||
{parsed.meta.fields?.map((key) => (
|
{row.map((cell, columnIndex) => (
|
||||||
<TableCell key={key}>{row[key] ?? ""}</TableCell>
|
// oxlint-disable-next-line react/no-array-index-key
|
||||||
|
<TableCell key={columnIndex}>{cell}</TableCell>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ function ActualEventStreamViewer({ response }: Props) {
|
|||||||
<SseSummaryFooter
|
<SseSummaryFooter
|
||||||
style={style}
|
style={style}
|
||||||
error={summary.error ? String(summary.error) : null}
|
error={summary.error ? String(summary.error) : null}
|
||||||
isLoading={summary.isLoading}
|
isLoading={summary.isLoading && summary.data == null}
|
||||||
onRenderMarkdownChange={renderMarkdownSetting.set}
|
onRenderMarkdownChange={renderMarkdownSetting.set}
|
||||||
renderMarkdown={renderMarkdown}
|
renderMarkdown={renderMarkdown}
|
||||||
resultKeyPath={summarySettings.resultKeyPath ?? ""}
|
resultKeyPath={summarySettings.resultKeyPath ?? ""}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||||
import { useMemo, useState } from "react";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useCallback } from "react";
|
||||||
import { useCopyHttpResponse } from "../../hooks/useCopyHttpResponse";
|
import { useCopyHttpResponse } from "../../hooks/useCopyHttpResponse";
|
||||||
import { useResponseBodyText } from "../../hooks/useResponseBodyText";
|
import { responseBodyTextQuery, useResponseBodyText } from "../../hooks/useResponseBodyText";
|
||||||
|
import { useResponseFilter } from "../../hooks/useResponseFilter";
|
||||||
import { useSaveResponse } from "../../hooks/useSaveResponse";
|
import { useSaveResponse } from "../../hooks/useSaveResponse";
|
||||||
import { languageFromContentType } from "../../lib/contentType";
|
import { languageFromContentType } from "../../lib/contentType";
|
||||||
import { getContentTypeFromHeaders } from "../../lib/model_util";
|
import { getContentTypeFromHeaders } from "../../lib/model_util";
|
||||||
@@ -52,30 +54,25 @@ interface HttpTextViewerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function HttpTextViewer({ response, text, language, pretty, className }: HttpTextViewerProps) {
|
function HttpTextViewer({ response, text, language, pretty, className }: HttpTextViewerProps) {
|
||||||
const [currentFilter, setCurrentFilter] = useState<string | null>(null);
|
const queryClient = useQueryClient();
|
||||||
const filteredBody = useResponseBodyText({ response, filter: currentFilter });
|
const filter = useResponseFilter({
|
||||||
|
stateKey: `response.body.${response.requestId}`,
|
||||||
|
// Shares the display query's cache entry, so the verdict costs no extra RPC
|
||||||
|
runFilter: useCallback(
|
||||||
|
(f: string) => queryClient.fetchQuery(responseBodyTextQuery({ response, filter: f })),
|
||||||
|
[queryClient, response],
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const filteredBody = useResponseBodyText({ response, filter: filter.appliedFilter });
|
||||||
const saveResponse = useSaveResponse(response);
|
const saveResponse = useSaveResponse(response);
|
||||||
const copyResponse = useCopyHttpResponse(response);
|
const copyResponse = useCopyHttpResponse(response);
|
||||||
const actionsDisabled = response.state !== "closed" && response.status >= 100;
|
const actionsDisabled = response.state !== "closed" && response.status >= 100;
|
||||||
|
|
||||||
const filterCallback = useMemo(
|
|
||||||
() => (filter: string) => {
|
|
||||||
setCurrentFilter(filter);
|
|
||||||
return {
|
|
||||||
data: filteredBody.data,
|
|
||||||
isPending: filteredBody.isPending,
|
|
||||||
error: !!filteredBody.error,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[filteredBody],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TextViewer
|
<TextViewer
|
||||||
text={text}
|
text={text}
|
||||||
language={language}
|
language={language}
|
||||||
stateKey={`response.body.${response.id}`}
|
stateKey={`response.body.${response.id}`}
|
||||||
filterStateKey={`response.body.${response.requestId}`}
|
|
||||||
pretty={pretty}
|
pretty={pretty}
|
||||||
className={className}
|
className={className}
|
||||||
footerActions={[
|
footerActions={[
|
||||||
@@ -98,7 +95,12 @@ function HttpTextViewer({ response, text, language, pretty, className }: HttpTex
|
|||||||
className="border !border-border-subtle"
|
className="border !border-border-subtle"
|
||||||
/>,
|
/>,
|
||||||
]}
|
]}
|
||||||
onFilter={filterCallback}
|
filter={filter}
|
||||||
|
filterResult={{
|
||||||
|
data: filteredBody.data,
|
||||||
|
isPending: filteredBody.isPending,
|
||||||
|
error: !!filteredBody.error,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,33 @@
|
|||||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
type Props = { className?: string } & (
|
type Props = { className?: string; mimeType?: string } & (
|
||||||
| {
|
| {
|
||||||
bodyPath: string;
|
/** A URL for the body the host already stored. */
|
||||||
|
bodyUrl: string;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
data: ArrayBuffer;
|
data: ArrayBuffer;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export function ImageViewer({ className, ...props }: Props) {
|
export function ImageViewer({ className, mimeType, ...props }: Props) {
|
||||||
const [src, setSrc] = useState<string>();
|
const [src, setSrc] = useState<string>();
|
||||||
const bodyPath = "bodyPath" in props ? props.bodyPath : null;
|
const bodyUrl = "bodyUrl" in props ? props.bodyUrl : null;
|
||||||
const data = "data" in props ? props.data : null;
|
const data = "data" in props ? props.data : null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (bodyPath != null) {
|
if (bodyUrl != null) {
|
||||||
setSrc(convertFileSrc(bodyPath));
|
setSrc(bodyUrl);
|
||||||
} else if (data != null) {
|
} else if (data != null) {
|
||||||
const blob = new Blob([data], { type: "image/png" });
|
const blob = new Blob([data], { type: mimeType ?? "image/png" });
|
||||||
const url = URL.createObjectURL(blob);
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
setSrc(url);
|
setSrc(objectUrl);
|
||||||
return () => URL.revokeObjectURL(url);
|
return () => URL.revokeObjectURL(objectUrl);
|
||||||
} else {
|
} else {
|
||||||
setSrc(undefined);
|
setSrc(undefined);
|
||||||
}
|
}
|
||||||
}, [bodyPath, data]);
|
}, [bodyUrl, data, mimeType]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import "react-pdf/dist/Page/TextLayer.css";
|
import "react-pdf/dist/Page/TextLayer.css";
|
||||||
import "react-pdf/dist/Page/AnnotationLayer.css";
|
import "react-pdf/dist/Page/AnnotationLayer.css";
|
||||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
|
||||||
import "./PdfViewer.css";
|
import "./PdfViewer.css";
|
||||||
import type { PDFDocumentProxy } from "pdfjs-dist";
|
import type { PDFDocumentProxy } from "pdfjs-dist";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useMemo, useRef, useState } from "react";
|
||||||
import { Document, Page } from "react-pdf";
|
import { Document, Page } from "react-pdf";
|
||||||
import { useContainerSize } from "@yaakapp-internal/ui";
|
import { useContainerSize } from "@yaakapp-internal/ui";
|
||||||
import { fireAndForget } from "../../lib/fireAndForget";
|
import { fireAndForget } from "../../lib/fireAndForget";
|
||||||
@@ -18,7 +17,8 @@ fireAndForget(
|
|||||||
);
|
);
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
bodyPath?: string;
|
/** A URL for the body the host already stored. */
|
||||||
|
bodyUrl?: string;
|
||||||
data?: Uint8Array;
|
data?: Uint8Array;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,29 +27,35 @@ const options = {
|
|||||||
standardFontDataUrl: "/standard_fonts/",
|
standardFontDataUrl: "/standard_fonts/",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PdfViewer({ bodyPath, data }: Props) {
|
export function PdfViewer({ bodyUrl, data }: Props) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [numPages, setNumPages] = useState<number>();
|
const [numPages, setNumPages] = useState<number>();
|
||||||
const [src, setSrc] = useState<string | { data: Uint8Array }>();
|
|
||||||
|
|
||||||
const { width: containerWidth } = useContainerSize(containerRef);
|
const { width: containerWidth } = useContainerSize(containerRef);
|
||||||
|
|
||||||
useEffect(() => {
|
// During render, not in an effect: an effect leaves the first paint with no file, and
|
||||||
if (bodyPath) {
|
// `Document` renders its "Failed to load PDF file" state for that frame before recovering
|
||||||
setSrc(convertFileSrc(bodyPath));
|
const src = useMemo(() => {
|
||||||
} else if (data) {
|
if (bodyUrl) {
|
||||||
|
return bodyUrl;
|
||||||
|
}
|
||||||
|
if (data) {
|
||||||
// Create a copy to avoid "Buffer is already detached" errors
|
// Create a copy to avoid "Buffer is already detached" errors
|
||||||
// This happens when the ArrayBuffer is transferred/detached elsewhere
|
// This happens when the ArrayBuffer is transferred/detached elsewhere
|
||||||
const dataCopy = new Uint8Array(data);
|
return { data: new Uint8Array(data) };
|
||||||
setSrc({ data: dataCopy });
|
|
||||||
} else {
|
|
||||||
setSrc(undefined);
|
|
||||||
}
|
}
|
||||||
}, [bodyPath, data]);
|
return undefined;
|
||||||
|
}, [bodyUrl, data]);
|
||||||
|
|
||||||
const onDocumentLoadSuccess = ({ numPages: nextNumPages }: PDFDocumentProxy): void => {
|
const onDocumentLoadSuccess = ({ numPages: nextNumPages }: PDFDocumentProxy): void => {
|
||||||
setNumPages(nextNumPages);
|
setNumPages(nextNumPages);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Nothing to show yet, rather than the failure state `Document` renders for an empty file
|
||||||
|
if (src == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="w-full h-full overflow-y-auto">
|
<div ref={containerRef} className="w-full h-full overflow-y-auto">
|
||||||
<Document
|
<Document
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { Icon } from "@yaakapp-internal/ui";
|
||||||
|
import type { RecentFilter } from "../../hooks/useRecentFilters";
|
||||||
|
import { Dropdown, type DropdownItem } from "../core/Dropdown";
|
||||||
|
import { IconButton } from "../core/IconButton";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
recentFilters: RecentFilter[];
|
||||||
|
activeFilter: string | null;
|
||||||
|
onSelect: (value: string) => void;
|
||||||
|
onRemove: (value: string) => void;
|
||||||
|
onTogglePin: (value: string) => void;
|
||||||
|
onClear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecentFiltersDropdown({
|
||||||
|
recentFilters,
|
||||||
|
activeFilter,
|
||||||
|
onSelect,
|
||||||
|
onRemove,
|
||||||
|
onTogglePin,
|
||||||
|
onClear,
|
||||||
|
}: Props) {
|
||||||
|
const pinned = recentFilters.filter((f) => f.pinned);
|
||||||
|
const unpinned = recentFilters.filter((f) => !f.pinned);
|
||||||
|
|
||||||
|
const toItem = (filter: RecentFilter): DropdownItem => ({
|
||||||
|
label: (
|
||||||
|
<div className="font-mono text-sm truncate max-w-sm" title={filter.value}>
|
||||||
|
{filter.value}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
leftSlot: <Icon icon={filter.value === activeFilter ? "check" : "empty"} />,
|
||||||
|
onSelect: () => onSelect(filter.value),
|
||||||
|
submenuTrigger: "button",
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: filter.pinned ? "Unpin" : "Pin",
|
||||||
|
icon: filter.pinned ? "unpin" : "pin",
|
||||||
|
keepOpenOnSelect: true,
|
||||||
|
onSelect: () => onTogglePin(filter.value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Remove",
|
||||||
|
icon: "trash",
|
||||||
|
color: "danger",
|
||||||
|
keepOpenOnSelect: true,
|
||||||
|
onSelect: () => onRemove(filter.value),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const items: DropdownItem[] = [];
|
||||||
|
|
||||||
|
if (recentFilters.length === 0) {
|
||||||
|
items.push({
|
||||||
|
type: "content",
|
||||||
|
label: (
|
||||||
|
<span className="block px-4 py-1 text-sm text-text-subtle">
|
||||||
|
Filters you use are remembered here
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pinned.length > 0) {
|
||||||
|
items.push({ type: "separator", label: "Pinned" }, ...pinned.map(toItem));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unpinned.length > 0) {
|
||||||
|
items.push({ type: "separator", label: "Recent" }, ...unpinned.map(toItem));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recentFilters.length > 0) {
|
||||||
|
items.push(
|
||||||
|
{ type: "separator" },
|
||||||
|
{
|
||||||
|
label: "Clear All",
|
||||||
|
leftSlot: <Icon icon="trash" />,
|
||||||
|
color: "danger",
|
||||||
|
onSelect: onClear,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown items={items}>
|
||||||
|
<IconButton
|
||||||
|
size="xs"
|
||||||
|
icon="filter"
|
||||||
|
title="Recent filters"
|
||||||
|
iconColor="secondary"
|
||||||
|
className="w-8 ml-0.5 mr-1 h-auto!"
|
||||||
|
/>
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
import classNames from "classnames";
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Children, useCallback, useMemo } from "react";
|
import { Children, useCallback, useMemo } from "react";
|
||||||
import { createGlobalState } from "react-use";
|
import { Banner, HStack, Icon, InlineCode } from "@yaakapp-internal/ui";
|
||||||
import { useDebouncedValue } from "@yaakapp-internal/ui";
|
|
||||||
import { useFormatText } from "../../hooks/useFormatText";
|
import { useFormatText } from "../../hooks/useFormatText";
|
||||||
|
import type { ResponseFilterApi } from "../../hooks/useResponseFilter";
|
||||||
|
import { Button } from "../core/Button";
|
||||||
import type { EditorProps } from "../core/Editor/Editor";
|
import type { EditorProps } from "../core/Editor/Editor";
|
||||||
import { hyperlink } from "../core/Editor/hyperlink/extension";
|
import { hyperlink } from "../core/Editor/hyperlink/extension";
|
||||||
import { Editor } from "../core/Editor/LazyEditor";
|
import { Editor } from "../core/Editor/LazyEditor";
|
||||||
import { IconButton } from "../core/IconButton";
|
import { IconButton } from "../core/IconButton";
|
||||||
import { Input } from "../core/Input";
|
import { Input } from "../core/Input";
|
||||||
|
import { RecentFiltersDropdown } from "./RecentFiltersDropdown";
|
||||||
|
|
||||||
const extraExtensions = [hyperlink];
|
const extraExtensions = [hyperlink];
|
||||||
|
|
||||||
@@ -16,57 +17,45 @@ interface Props {
|
|||||||
text: string;
|
text: string;
|
||||||
language: EditorProps["language"];
|
language: EditorProps["language"];
|
||||||
stateKey: string | null;
|
stateKey: string | null;
|
||||||
filterStateKey?: string | null;
|
|
||||||
pretty?: boolean;
|
pretty?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
footerActions?: ReactNode;
|
footerActions?: ReactNode;
|
||||||
onFilter?: (filter: string) => {
|
filter?: ResponseFilterApi;
|
||||||
|
filterResult?: {
|
||||||
data: string | null | undefined;
|
data: string | null | undefined;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
error: boolean;
|
error: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const useFilterText = createGlobalState<Record<string, string | null>>({});
|
|
||||||
|
|
||||||
export function TextViewer({
|
export function TextViewer({
|
||||||
language,
|
language,
|
||||||
text,
|
text,
|
||||||
stateKey,
|
stateKey,
|
||||||
filterStateKey,
|
|
||||||
pretty,
|
pretty,
|
||||||
className,
|
className,
|
||||||
footerActions,
|
footerActions,
|
||||||
onFilter,
|
filter,
|
||||||
|
filterResult,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const filterKey = filterStateKey ?? stateKey;
|
const canFilter =
|
||||||
const [filterTextMap, setFilterTextMap] = useFilterText();
|
filter != null && (language === "json" || language === "xml" || language === "html");
|
||||||
const filterText = filterKey ? (filterTextMap[filterKey] ?? null) : null;
|
const isSearching = filter?.isSearching ?? false;
|
||||||
const debouncedFilterText = useDebouncedValue(filterText);
|
const appliedFilter = filter?.appliedFilter ?? null;
|
||||||
const setFilterText = useCallback(
|
const resultError = filterResult?.error ?? false;
|
||||||
(v: string | null) => {
|
|
||||||
if (!filterKey) return;
|
const handleFilterKeyDown = useCallback(
|
||||||
setFilterTextMap((m) => ({ ...m, [filterKey]: v }));
|
(e: KeyboardEvent) => {
|
||||||
|
if (filter == null) return;
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
filter.toggleSearch();
|
||||||
|
} else if (e.key === "Enter" && filter.filterText != null) {
|
||||||
|
filter.applyFilter(filter.filterText);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[filterKey, setFilterTextMap],
|
[filter],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isSearching = filterText != null;
|
|
||||||
const filteredResponse =
|
|
||||||
onFilter && debouncedFilterText
|
|
||||||
? onFilter(debouncedFilterText)
|
|
||||||
: { data: null, isPending: false, error: false };
|
|
||||||
|
|
||||||
const toggleSearch = useCallback(() => {
|
|
||||||
if (isSearching) {
|
|
||||||
setFilterText(null);
|
|
||||||
} else {
|
|
||||||
setFilterText("");
|
|
||||||
}
|
|
||||||
}, [isSearching, setFilterText]);
|
|
||||||
|
|
||||||
const canFilter = onFilter && (language === "json" || language === "xml" || language === "html");
|
|
||||||
|
|
||||||
const actions = useMemo<ReactNode[]>(() => {
|
const actions = useMemo<ReactNode[]>(() => {
|
||||||
const nodes: ReactNode[] = isSearching ? [] : Children.toArray(footerActions);
|
const nodes: ReactNode[] = isSearching ? [] : Children.toArray(footerActions);
|
||||||
|
|
||||||
@@ -76,8 +65,8 @@ export function TextViewer({
|
|||||||
nodes.push(
|
nodes.push(
|
||||||
<div key="input" className="w-full opacity-100!">
|
<div key="input" className="w-full opacity-100!">
|
||||||
<Input
|
<Input
|
||||||
key={filterKey ?? "filter"}
|
key={filter.stateKey ?? "filter"}
|
||||||
validate={!filteredResponse.error}
|
validate={!resultError}
|
||||||
hideLabel
|
hideLabel
|
||||||
autoFocus
|
autoFocus
|
||||||
containerClassName="bg-surface"
|
containerClassName="bg-surface"
|
||||||
@@ -85,39 +74,62 @@ export function TextViewer({
|
|||||||
placeholder={language === "json" ? "JSONPath expression" : "XPath expression"}
|
placeholder={language === "json" ? "JSONPath expression" : "XPath expression"}
|
||||||
label="Filter expression"
|
label="Filter expression"
|
||||||
name="filter"
|
name="filter"
|
||||||
defaultValue={filterText}
|
defaultValue={filter.filterText}
|
||||||
onKeyDown={(e) => e.key === "Escape" && toggleSearch()}
|
forceUpdateKey={filter.filterUpdateKey}
|
||||||
onChange={setFilterText}
|
onKeyDown={handleFilterKeyDown}
|
||||||
stateKey={filterKey ? `filter.${filterKey}` : null}
|
onChange={filter.setFilterText}
|
||||||
|
stateKey={filter.stateKey ? `filter.${filter.stateKey}` : null}
|
||||||
|
leftSlot={
|
||||||
|
<div className="py-0.5 flex">
|
||||||
|
<RecentFiltersDropdown
|
||||||
|
recentFilters={filter.recentFilters}
|
||||||
|
activeFilter={filter.appliedFilter}
|
||||||
|
onSelect={filter.replaceFilter}
|
||||||
|
onRemove={filter.removeRecentFilter}
|
||||||
|
onTogglePin={filter.togglePinRecentFilter}
|
||||||
|
onClear={filter.clearRecentFilters}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
rightSlot={
|
||||||
|
<div className="py-0.5 flex">
|
||||||
|
<IconButton
|
||||||
|
size="xs"
|
||||||
|
icon="x"
|
||||||
|
title="Close filter"
|
||||||
|
iconColor="secondary"
|
||||||
|
onClick={filter.toggleSearch}
|
||||||
|
className="w-8 mr-0.5 h-auto!"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>,
|
</div>,
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
nodes.push(
|
||||||
|
<IconButton
|
||||||
|
key="icon"
|
||||||
|
size="sm"
|
||||||
|
isLoading={filterResult?.isPending ?? false}
|
||||||
|
icon="filter"
|
||||||
|
title="Filter response"
|
||||||
|
onClick={filter.toggleSearch}
|
||||||
|
className="border border-border-subtle!"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
nodes.push(
|
|
||||||
<IconButton
|
|
||||||
key="icon"
|
|
||||||
size="sm"
|
|
||||||
isLoading={filteredResponse.isPending}
|
|
||||||
icon={isSearching ? "x" : "filter"}
|
|
||||||
title={isSearching ? "Close filter" : "Filter response"}
|
|
||||||
onClick={toggleSearch}
|
|
||||||
className={classNames("border border-border-subtle!", isSearching && "opacity-100!")}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
return nodes;
|
return nodes;
|
||||||
}, [
|
}, [
|
||||||
canFilter,
|
canFilter,
|
||||||
footerActions,
|
footerActions,
|
||||||
filterKey,
|
filter,
|
||||||
filterText,
|
filterResult?.isPending,
|
||||||
filteredResponse.error,
|
resultError,
|
||||||
filteredResponse.isPending,
|
|
||||||
isSearching,
|
isSearching,
|
||||||
language,
|
language,
|
||||||
setFilterText,
|
handleFilterKeyDown,
|
||||||
toggleSearch,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const formattedBody = useFormatText({ text, language, pretty: pretty ?? false });
|
const formattedBody = useFormatText({ text, language, pretty: pretty ?? false });
|
||||||
@@ -126,11 +138,11 @@ export function TextViewer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
let body: string;
|
let body: string;
|
||||||
if (isSearching && filterText?.length > 0) {
|
if (appliedFilter) {
|
||||||
if (filteredResponse.error) {
|
if (resultError) {
|
||||||
body = "";
|
body = "";
|
||||||
} else {
|
} else {
|
||||||
body = filteredResponse.data != null ? filteredResponse.data : "";
|
body = filterResult?.data != null ? filterResult.data : "";
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
body = formattedBody;
|
body = formattedBody;
|
||||||
@@ -143,15 +155,61 @@ export function TextViewer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Editor
|
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full w-full">
|
||||||
readOnly
|
{appliedFilter && filter != null ? (
|
||||||
className={className}
|
<AppliedFilterBar
|
||||||
defaultValue={body}
|
filter={appliedFilter}
|
||||||
language={language}
|
error={resultError}
|
||||||
actions={actions}
|
onClear={() => filter.replaceFilter("")}
|
||||||
extraExtensions={extraExtensions}
|
/>
|
||||||
stateKey={stateKey}
|
) : (
|
||||||
/>
|
<span />
|
||||||
|
)}
|
||||||
|
<Editor
|
||||||
|
readOnly
|
||||||
|
className={className}
|
||||||
|
defaultValue={body}
|
||||||
|
language={language}
|
||||||
|
actions={actions}
|
||||||
|
extraExtensions={extraExtensions}
|
||||||
|
stateKey={stateKey}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows what's actually filtering the body, which the filter box below can't convey
|
||||||
|
* once it holds an edited expression that hasn't been applied yet.
|
||||||
|
*/
|
||||||
|
function AppliedFilterBar({
|
||||||
|
filter,
|
||||||
|
error,
|
||||||
|
onClear,
|
||||||
|
}: {
|
||||||
|
filter: string;
|
||||||
|
error: boolean;
|
||||||
|
onClear: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Banner color={error ? "danger" : "info"} className="py-1! mb-2! text-sm">
|
||||||
|
<HStack space={2} className="min-w-0">
|
||||||
|
<Icon icon="filter" size="xs" className="shrink-0 opacity-70" />
|
||||||
|
<span className="truncate min-w-0" title={filter}>
|
||||||
|
Response filtered by <InlineCode>{filter}</InlineCode>
|
||||||
|
{error && " (invalid expression)"}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
size="2xs"
|
||||||
|
variant="border"
|
||||||
|
color={error ? "danger" : "info"}
|
||||||
|
className="ml-auto shrink-0"
|
||||||
|
onClick={onClear}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
|
</Banner>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,28 @@
|
|||||||
import { convertFileSrc } from "@tauri-apps/api/core";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
bodyPath?: string;
|
/** A URL for the body the host already stored. */
|
||||||
|
bodyUrl?: string;
|
||||||
data?: Uint8Array;
|
data?: Uint8Array;
|
||||||
|
mimeType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VideoViewer({ bodyPath, data }: Props) {
|
export function VideoViewer({ bodyUrl, data, mimeType }: Props) {
|
||||||
const [src, setSrc] = useState<string>();
|
const [src, setSrc] = useState<string>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (bodyPath) {
|
if (bodyUrl) {
|
||||||
setSrc(convertFileSrc(bodyPath));
|
setSrc(bodyUrl);
|
||||||
} else if (data) {
|
} else if (data) {
|
||||||
const blob = new Blob([new Uint8Array(data)], { type: "video/mp4" });
|
// As in AudioViewer: a media element trusts the declared type instead of sniffing
|
||||||
const url = URL.createObjectURL(blob);
|
const blob = new Blob([new Uint8Array(data)], { type: mimeType ?? "video/mp4" });
|
||||||
setSrc(url);
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
return () => URL.revokeObjectURL(url);
|
setSrc(objectUrl);
|
||||||
|
return () => URL.revokeObjectURL(objectUrl);
|
||||||
} else {
|
} else {
|
||||||
setSrc(undefined);
|
setSrc(undefined);
|
||||||
}
|
}
|
||||||
}, [bodyPath, data]);
|
}, [bodyUrl, data, mimeType]);
|
||||||
|
|
||||||
// oxlint-disable-next-line jsx-a11y/media-has-caption
|
// oxlint-disable-next-line jsx-a11y/media-has-caption
|
||||||
return <video className="w-full" controls src={src} />;
|
return <video className="w-full" controls src={src} />;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Listen for settings changes, the re-compute theme
|
// Listen for settings changes, the re-compute theme
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
import type { ModelPayload } from "@yaakapp-internal/models";
|
import type { ModelPayload } from "@yaakapp-internal/models";
|
||||||
import { fireAndForget } from "./lib/fireAndForget";
|
import { fireAndForget } from "./lib/fireAndForget";
|
||||||
import { getSettings } from "./lib/settings";
|
import { getSettings } from "./lib/settings";
|
||||||
@@ -8,10 +8,12 @@ function setFontSizeOnDocument(fontSize: number) {
|
|||||||
document.documentElement.style.fontSize = `${fontSize}px`;
|
document.documentElement.style.fontSize = `${fontSize}px`;
|
||||||
}
|
}
|
||||||
|
|
||||||
listen<ModelPayload>("model_write", async (event) => {
|
platform.listen<ModelPayload[]>("model_writes", (payloads) => {
|
||||||
if (event.payload.change.type !== "upsert") return;
|
for (const payload of payloads) {
|
||||||
if (event.payload.model.model !== "settings") return;
|
if (payload.change.type !== "upsert") continue;
|
||||||
setFontSizeOnDocument(event.payload.model.interfaceFontSize);
|
if (payload.model.model !== "settings") continue;
|
||||||
}).catch(console.error);
|
setFontSizeOnDocument(payload.model.interfaceFontSize);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
fireAndForget(getSettings().then((settings) => setFontSizeOnDocument(settings.interfaceFontSize)));
|
fireAndForget(getSettings().then((settings) => setFontSizeOnDocument(settings.interfaceFontSize)));
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Listen for settings changes, the re-compute theme
|
// Listen for settings changes, the re-compute theme
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
import type { ModelPayload, Settings } from "@yaakapp-internal/models";
|
import type { ModelPayload, Settings } from "@yaakapp-internal/models";
|
||||||
import { fireAndForget } from "./lib/fireAndForget";
|
import { fireAndForget } from "./lib/fireAndForget";
|
||||||
import { getSettings } from "./lib/settings";
|
import { getSettings } from "./lib/settings";
|
||||||
@@ -12,10 +12,12 @@ function setFonts(settings: Settings) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
listen<ModelPayload>("model_write", async (event) => {
|
platform.listen<ModelPayload[]>("model_writes", (payloads) => {
|
||||||
if (event.payload.change.type !== "upsert") return;
|
for (const payload of payloads) {
|
||||||
if (event.payload.model.model !== "settings") return;
|
if (payload.change.type !== "upsert") continue;
|
||||||
setFonts(event.payload.model);
|
if (payload.model.model !== "settings") continue;
|
||||||
}).catch(console.error);
|
setFonts(payload.model);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
fireAndForget(getSettings().then((settings) => setFonts(settings)));
|
fireAndForget(getSettings().then((settings) => setFonts(settings)));
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
websocketRequestsAtom,
|
websocketRequestsAtom,
|
||||||
} from "@yaakapp-internal/models";
|
} from "@yaakapp-internal/models";
|
||||||
import { atom, useAtomValue } from "jotai";
|
import { atom, useAtomValue } from "jotai";
|
||||||
|
import { selectAtom } from "jotai/utils";
|
||||||
|
|
||||||
export const allRequestsAtom = atom((get) => [
|
export const allRequestsAtom = atom((get) => [
|
||||||
...get(httpRequestsAtom),
|
...get(httpRequestsAtom),
|
||||||
@@ -14,3 +15,26 @@ export const allRequestsAtom = atom((get) => [
|
|||||||
export function useAllRequests() {
|
export function useAllRequests() {
|
||||||
return useAtomValue(allRequestsAtom);
|
return useAtomValue(allRequestsAtom);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const stringArrayEqual = (a: string[], b: string[]) =>
|
||||||
|
a.length === b.length && a.every((v, i) => v === b[i]);
|
||||||
|
|
||||||
|
// Identity-stable derivations so subscribers don't recompute or re-render when
|
||||||
|
// unrelated request fields change (eg. every debounced edit of a request)
|
||||||
|
export const allRequestIdsAtom = selectAtom(
|
||||||
|
allRequestsAtom,
|
||||||
|
(requests) => requests.map((r) => r.id),
|
||||||
|
stringArrayEqual,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const allRequestUrlsAtom = selectAtom(
|
||||||
|
allRequestsAtom,
|
||||||
|
(requests) => {
|
||||||
|
const urls = new Set<string>();
|
||||||
|
for (const r of requests) {
|
||||||
|
if (r.url) urls.add(r.url);
|
||||||
|
}
|
||||||
|
return Array.from(urls);
|
||||||
|
},
|
||||||
|
stringArrayEqual,
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { event } from "@tauri-apps/api";
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
import { useFastMutation } from "./useFastMutation";
|
import { useFastMutation } from "./useFastMutation";
|
||||||
|
|
||||||
export function useCancelHttpResponse(id: string | null) {
|
export function useCancelHttpResponse(id: string | null) {
|
||||||
return useFastMutation<void>({
|
return useFastMutation<void>({
|
||||||
mutationKey: ["cancel_http_response", id],
|
mutationKey: ["cancel_http_response", id],
|
||||||
mutationFn: () => event.emit(`cancel_http_response_${id}`),
|
mutationFn: () => platform.emit(`cancel_http_response_${id}`),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ import { InlineCode } from "@yaakapp-internal/ui";
|
|||||||
import { showAlert } from "../lib/alert";
|
import { showAlert } from "../lib/alert";
|
||||||
import { appInfo } from "../lib/appInfo";
|
import { appInfo } from "../lib/appInfo";
|
||||||
import { minPromiseMillis } from "../lib/minPromiseMillis";
|
import { minPromiseMillis } from "../lib/minPromiseMillis";
|
||||||
import { invokeCmd } from "../lib/tauri";
|
import { rpc } from "../lib/rpc";
|
||||||
|
|
||||||
export function useCheckForUpdates() {
|
export function useCheckForUpdates() {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationKey: ["check_for_updates"],
|
mutationKey: ["check_for_updates"],
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const hasUpdate: boolean = await minPromiseMillis(invokeCmd("cmd_check_for_updates"), 500);
|
const hasUpdate: boolean = await minPromiseMillis(rpc("cmd_check_for_updates"), 500);
|
||||||
if (!hasUpdate) {
|
if (!hasUpdate) {
|
||||||
showAlert({
|
showAlert({
|
||||||
id: "no-updates",
|
id: "no-updates",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { invokeCmd } from "../lib/tauri";
|
import { rpc } from "../lib/rpc";
|
||||||
import { useFastMutation } from "./useFastMutation";
|
import { useFastMutation } from "./useFastMutation";
|
||||||
|
|
||||||
export function useDeleteGrpcConnections(requestId?: string) {
|
export function useDeleteGrpcConnections(requestId?: string) {
|
||||||
@@ -6,7 +6,7 @@ export function useDeleteGrpcConnections(requestId?: string) {
|
|||||||
mutationKey: ["delete_grpc_connections", requestId],
|
mutationKey: ["delete_grpc_connections", requestId],
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
if (requestId === undefined) return;
|
if (requestId === undefined) return;
|
||||||
await invokeCmd("cmd_delete_all_grpc_connections", { requestId });
|
await rpc("cmd_delete_all_grpc_connections", { requestId });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { invokeCmd } from "../lib/tauri";
|
import { rpc } from "../lib/rpc";
|
||||||
import { useFastMutation } from "./useFastMutation";
|
import { useFastMutation } from "./useFastMutation";
|
||||||
|
|
||||||
export function useDeleteHttpResponses(requestId?: string) {
|
export function useDeleteHttpResponses(requestId?: string) {
|
||||||
@@ -6,7 +6,7 @@ export function useDeleteHttpResponses(requestId?: string) {
|
|||||||
mutationKey: ["delete_http_responses", requestId],
|
mutationKey: ["delete_http_responses", requestId],
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
if (requestId === undefined) return;
|
if (requestId === undefined) return;
|
||||||
await invokeCmd("cmd_delete_all_http_responses", { requestId });
|
await rpc("cmd_delete_all_http_responses", { requestId });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { showAlert } from "../lib/alert";
|
|||||||
import { showConfirmDelete } from "../lib/confirm";
|
import { showConfirmDelete } from "../lib/confirm";
|
||||||
import { jotaiStore } from "../lib/jotai";
|
import { jotaiStore } from "../lib/jotai";
|
||||||
import { pluralizeCount } from "../lib/pluralize";
|
import { pluralizeCount } from "../lib/pluralize";
|
||||||
import { invokeCmd } from "../lib/tauri";
|
import { rpc } from "../lib/rpc";
|
||||||
import { activeWorkspaceIdAtom } from "./useActiveWorkspace";
|
import { activeWorkspaceIdAtom } from "./useActiveWorkspace";
|
||||||
import { useFastMutation } from "./useFastMutation";
|
import { useFastMutation } from "./useFastMutation";
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ export function useDeleteSendHistory() {
|
|||||||
if (!confirmed) return false;
|
if (!confirmed) return false;
|
||||||
|
|
||||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
||||||
await invokeCmd("cmd_delete_send_history", { workspaceId });
|
await rpc("cmd_delete_send_history", { workspaceId });
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type {
|
|||||||
GetFolderActionsResponse,
|
GetFolderActionsResponse,
|
||||||
} from "@yaakapp-internal/plugins";
|
} from "@yaakapp-internal/plugins";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { invokeCmd } from "../lib/tauri";
|
import { rpc } from "../lib/rpc";
|
||||||
import { usePluginsKey } from "./usePlugins";
|
import { usePluginsKey } from "./usePlugins";
|
||||||
|
|
||||||
export type CallableFolderAction = Pick<FolderAction, "label" | "icon"> & {
|
export type CallableFolderAction = Pick<FolderAction, "label" | "icon"> & {
|
||||||
@@ -30,7 +30,7 @@ export function useFolderActions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getFolderActions() {
|
export async function getFolderActions() {
|
||||||
const responses = await invokeCmd<GetFolderActionsResponse[]>("cmd_folder_actions");
|
const responses = await rpc<GetFolderActionsResponse[]>("cmd_folder_actions");
|
||||||
const actions = responses.flatMap((r) =>
|
const actions = responses.flatMap((r) =>
|
||||||
r.actions.map((a, i) => ({
|
r.actions.map((a, i) => ({
|
||||||
label: a.label,
|
label: a.label,
|
||||||
@@ -41,7 +41,7 @@ export async function getFolderActions() {
|
|||||||
pluginRefId: r.pluginRefId,
|
pluginRefId: r.pluginRefId,
|
||||||
args: { folder },
|
args: { folder },
|
||||||
};
|
};
|
||||||
await invokeCmd("cmd_call_folder_action", { req: payload });
|
await rpc("cmd_call_folder_action", { req: payload });
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { useKeyValue } from "./useKeyValue";
|
||||||
|
|
||||||
|
// The file a request's GraphQL schema is loaded from, or null when the schema
|
||||||
|
// comes from an introspection request.
|
||||||
|
//
|
||||||
|
// This is the *source*, not the schema. The introspection row it produces is a
|
||||||
|
// cache that expires on its own; this outlives it and regenerates it, the same
|
||||||
|
// way gRPC keeps its proto file list separate from a reflection result.
|
||||||
|
export function graphqlSchemaFileArgs(requestId: string | null) {
|
||||||
|
return {
|
||||||
|
namespace: "global" as const,
|
||||||
|
key: ["graphql_schema_file", requestId ?? "n/a"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGraphQLSchemaFile(requestId: string | null) {
|
||||||
|
return useKeyValue<string | null>({ ...graphqlSchemaFileArgs(requestId), fallback: null });
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { emit } from "@tauri-apps/api/event";
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
import type { GrpcConnection, GrpcRequest } from "@yaakapp-internal/models";
|
import type { GrpcConnection, GrpcRequest } from "@yaakapp-internal/models";
|
||||||
|
import { flushAllModelWrites } from "@yaakapp-internal/models";
|
||||||
import { jotaiStore } from "../lib/jotai";
|
import { jotaiStore } from "../lib/jotai";
|
||||||
import { minPromiseMillis } from "../lib/minPromiseMillis";
|
import { minPromiseMillis } from "../lib/minPromiseMillis";
|
||||||
import { invokeCmd } from "../lib/tauri";
|
import { rpc } from "../lib/rpc";
|
||||||
import { activeEnvironmentIdAtom, useActiveEnvironment } from "./useActiveEnvironment";
|
import { activeEnvironmentIdAtom, useActiveEnvironment } from "./useActiveEnvironment";
|
||||||
import { useDebouncedValue } from "@yaakapp-internal/ui";
|
import { useDebouncedValue } from "@yaakapp-internal/ui";
|
||||||
|
|
||||||
@@ -22,24 +23,30 @@ export function useGrpc(
|
|||||||
|
|
||||||
const go = useMutation<void, string>({
|
const go = useMutation<void, string>({
|
||||||
mutationKey: ["grpc_go", conn?.id],
|
mutationKey: ["grpc_go", conn?.id],
|
||||||
mutationFn: () =>
|
mutationFn: async () => {
|
||||||
invokeCmd<void>("cmd_grpc_go", { requestId, environmentId: environment?.id, protoFiles }),
|
await flushAllModelWrites(); // The backend reads the request from the DB
|
||||||
|
return rpc<void>("cmd_grpc_go", {
|
||||||
|
requestId,
|
||||||
|
environmentId: environment?.id,
|
||||||
|
protoFiles,
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const send = useMutation({
|
const send = useMutation({
|
||||||
mutationKey: ["grpc_send", conn?.id],
|
mutationKey: ["grpc_send", conn?.id],
|
||||||
mutationFn: ({ message }: { message: string }) =>
|
mutationFn: ({ message }: { message: string }) =>
|
||||||
emit(`grpc_client_msg_${conn?.id ?? "none"}`, { Message: message }),
|
platform.emit(`grpc_client_msg_${conn?.id ?? "none"}`, { Message: message }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const cancel = useMutation({
|
const cancel = useMutation({
|
||||||
mutationKey: ["grpc_cancel", conn?.id ?? "n/a"],
|
mutationKey: ["grpc_cancel", conn?.id ?? "n/a"],
|
||||||
mutationFn: () => emit(`grpc_client_msg_${conn?.id ?? "none"}`, "Cancel"),
|
mutationFn: () => platform.emit(`grpc_client_msg_${conn?.id ?? "none"}`, "Cancel"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const commit = useMutation({
|
const commit = useMutation({
|
||||||
mutationKey: ["grpc_commit", conn?.id ?? "n/a"],
|
mutationKey: ["grpc_commit", conn?.id ?? "n/a"],
|
||||||
mutationFn: () => emit(`grpc_client_msg_${conn?.id ?? "none"}`, "Commit"),
|
mutationFn: () => platform.emit(`grpc_client_msg_${conn?.id ?? "none"}`, "Commit"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const debouncedUrl = useDebouncedValue<string>(req?.url ?? "", 1000);
|
const debouncedUrl = useDebouncedValue<string>(req?.url ?? "", 1000);
|
||||||
@@ -54,7 +61,7 @@ export function useGrpc(
|
|||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
const environmentId = jotaiStore.get(activeEnvironmentIdAtom);
|
const environmentId = jotaiStore.get(activeEnvironmentIdAtom);
|
||||||
return minPromiseMillis<ReflectResponseService[]>(
|
return minPromiseMillis<ReflectResponseService[]>(
|
||||||
invokeCmd("cmd_grpc_reflect", { requestId, protoFiles, environmentId }),
|
rpc("cmd_grpc_reflect", { requestId, protoFiles, environmentId }),
|
||||||
300,
|
300,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type {
|
|||||||
GrpcRequestAction,
|
GrpcRequestAction,
|
||||||
} from "@yaakapp-internal/plugins";
|
} from "@yaakapp-internal/plugins";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { invokeCmd } from "../lib/tauri";
|
import { rpc } from "../lib/rpc";
|
||||||
import { getGrpcProtoFiles } from "./useGrpcProtoFiles";
|
import { getGrpcProtoFiles } from "./useGrpcProtoFiles";
|
||||||
import { usePluginsKey } from "./usePlugins";
|
import { usePluginsKey } from "./usePlugins";
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ export function useGrpcRequestActions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getGrpcRequestActions() {
|
export async function getGrpcRequestActions() {
|
||||||
const responses = await invokeCmd<GetGrpcRequestActionsResponse[]>("cmd_grpc_request_actions");
|
const responses = await rpc<GetGrpcRequestActionsResponse[]>("cmd_grpc_request_actions");
|
||||||
|
|
||||||
return responses.flatMap((r) =>
|
return responses.flatMap((r) =>
|
||||||
r.actions.map((a, i) => ({
|
r.actions.map((a, i) => ({
|
||||||
@@ -46,7 +46,7 @@ export async function getGrpcRequestActions() {
|
|||||||
pluginRefId: r.pluginRefId,
|
pluginRefId: r.pluginRefId,
|
||||||
args: { grpcRequest, protoFiles },
|
args: { grpcRequest, protoFiles },
|
||||||
};
|
};
|
||||||
await invokeCmd("cmd_call_grpc_request_action", { req: payload });
|
await rpc("cmd_call_grpc_request_action", { req: payload });
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type } from "@tauri-apps/plugin-os";
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
import { debounce } from "@yaakapp-internal/lib";
|
import { debounce } from "@yaakapp-internal/lib";
|
||||||
import { settingsAtom } from "@yaakapp-internal/models";
|
import { settingsAtom } from "@yaakapp-internal/models";
|
||||||
import { atom, useAtomValue } from "jotai";
|
import { atom, useAtomValue } from "jotai";
|
||||||
@@ -102,7 +102,7 @@ const defaultHotkeysOther: Record<HotkeyAction, string[]> = {
|
|||||||
|
|
||||||
/** Get the default hotkeys for the current platform */
|
/** Get the default hotkeys for the current platform */
|
||||||
export const defaultHotkeys: Record<HotkeyAction, string[]> =
|
export const defaultHotkeys: Record<HotkeyAction, string[]> =
|
||||||
type() === "macos" ? defaultHotkeysMac : defaultHotkeysOther;
|
platform.osType() === "macos" ? defaultHotkeysMac : defaultHotkeysOther;
|
||||||
|
|
||||||
/** Atom that provides the effective hotkeys by merging defaults with user settings */
|
/** Atom that provides the effective hotkeys by merging defaults with user settings */
|
||||||
export const hotkeysAtom = atom((get) => {
|
export const hotkeysAtom = atom((get) => {
|
||||||
@@ -112,9 +112,12 @@ export const hotkeysAtom = atom((get) => {
|
|||||||
// Merge default hotkeys with custom hotkeys from settings
|
// Merge default hotkeys with custom hotkeys from settings
|
||||||
// Custom hotkeys override defaults for the same action
|
// Custom hotkeys override defaults for the same action
|
||||||
// An empty array means the hotkey is intentionally disabled
|
// An empty array means the hotkey is intentionally disabled
|
||||||
const merged: Record<HotkeyAction, string[]> = { ...defaultHotkeys };
|
const merged: Partial<Record<HotkeyAction, string[]>> = {};
|
||||||
|
for (const action of hotkeyActions) {
|
||||||
|
merged[action] = defaultHotkeys[action];
|
||||||
|
}
|
||||||
for (const [action, keys] of Object.entries(customHotkeys)) {
|
for (const [action, keys] of Object.entries(customHotkeys)) {
|
||||||
if (action in defaultHotkeys && Array.isArray(keys)) {
|
if (action in merged && Array.isArray(keys)) {
|
||||||
merged[action as HotkeyAction] = keys;
|
merged[action as HotkeyAction] = keys;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,7 +125,7 @@ export const hotkeysAtom = atom((get) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/** Helper function to get current hotkeys from the store */
|
/** Helper function to get current hotkeys from the store */
|
||||||
function getHotkeys(): Record<HotkeyAction, string[]> {
|
function getHotkeys(): Partial<Record<HotkeyAction, string[]>> {
|
||||||
return jotaiStore.get(hotkeysAtom);
|
return jotaiStore.get(hotkeysAtom);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,16 +168,25 @@ const layoutInsensitiveKeys = [
|
|||||||
"Space",
|
"Space",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Zoom is the browser's own on these keys, so the app has no such action there. */
|
||||||
|
const ZOOM_ACTIONS: HotkeyAction[] = ["app.zoom_in", "app.zoom_out", "app.zoom_reset"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The actions this host actually has. An action left out of here has no keys in
|
||||||
|
* `hotkeysAtom`, so it never matches and never claims the keystroke.
|
||||||
|
*/
|
||||||
export const hotkeyActions: HotkeyAction[] = (
|
export const hotkeyActions: HotkeyAction[] = (
|
||||||
Object.keys(defaultHotkeys) as (keyof typeof defaultHotkeys)[]
|
Object.keys(defaultHotkeys) as (keyof typeof defaultHotkeys)[]
|
||||||
).sort((a, b) => {
|
)
|
||||||
const scopeA = a.split(".")[0] || "";
|
.filter((a) => platform.capabilities.interfaceZoom || !ZOOM_ACTIONS.includes(a))
|
||||||
const scopeB = b.split(".")[0] || "";
|
.sort((a, b) => {
|
||||||
if (scopeA !== scopeB) {
|
const scopeA = a.split(".")[0] || "";
|
||||||
return scopeA.localeCompare(scopeB);
|
const scopeB = b.split(".")[0] || "";
|
||||||
}
|
if (scopeA !== scopeB) {
|
||||||
return hotkeyLabels[a].localeCompare(hotkeyLabels[b]);
|
return scopeA.localeCompare(scopeB);
|
||||||
});
|
}
|
||||||
|
return hotkeyLabels[a].localeCompare(hotkeyLabels[b]);
|
||||||
|
});
|
||||||
|
|
||||||
export type HotKeyOptions = {
|
export type HotKeyOptions = {
|
||||||
enable?: boolean | (() => boolean);
|
enable?: boolean | (() => boolean);
|
||||||
@@ -318,7 +330,7 @@ export function getHotkeyScope(action: HotkeyAction): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function formatHotkeyString(trigger: string): string[] {
|
export function formatHotkeyString(trigger: string): string[] {
|
||||||
const os = type();
|
const os = platform.osType();
|
||||||
const parts = trigger.split("+");
|
const parts = trigger.split("+");
|
||||||
const labelParts: string[] = [];
|
const labelParts: string[] = [];
|
||||||
|
|
||||||
@@ -333,7 +345,9 @@ export function formatHotkeyString(trigger: string): string[] {
|
|||||||
} else if (p === "Alt") {
|
} else if (p === "Alt") {
|
||||||
labelParts.push("⌥");
|
labelParts.push("⌥");
|
||||||
} else if (p === "Enter") {
|
} else if (p === "Enter") {
|
||||||
labelParts.push("↩");
|
// U+21A9 has an emoji presentation, which Chromium's font fallback picks
|
||||||
|
// (a blue glyph among monochrome ones). U+FE0E forces the text form.
|
||||||
|
labelParts.push("↩︎");
|
||||||
} else if (p === "Tab") {
|
} else if (p === "Tab") {
|
||||||
labelParts.push("⇥");
|
labelParts.push("⇥");
|
||||||
} else if (p === "Backspace") {
|
} else if (p === "Backspace") {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { GetHttpAuthenticationSummaryResponse } from "@yaakapp-internal/plu
|
|||||||
import { atom, useAtomValue } from "jotai";
|
import { atom, useAtomValue } from "jotai";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { jotaiStore } from "../lib/jotai";
|
import { jotaiStore } from "../lib/jotai";
|
||||||
import { invokeCmd } from "../lib/tauri";
|
import { rpc } from "../lib/rpc";
|
||||||
import { showErrorToast } from "../lib/toast";
|
import { showErrorToast } from "../lib/toast";
|
||||||
import { usePluginsKey } from "./usePlugins";
|
import { usePluginsKey } from "./usePlugins";
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ export function useSubscribeHttpAuthentication() {
|
|||||||
placeholderData: (prev) => prev, // Keep previous data on refetch
|
placeholderData: (prev) => prev, // Keep previous data on refetch
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
try {
|
||||||
const result = await invokeCmd<GetHttpAuthenticationSummaryResponse[]>(
|
const result = await rpc<GetHttpAuthenticationSummaryResponse[]>(
|
||||||
"cmd_get_http_authentication_summaries",
|
"cmd_get_http_authentication_summaries",
|
||||||
);
|
);
|
||||||
setNumResults(result.length);
|
setNumResults(result.length);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import type { GetHttpAuthenticationConfigResponse, JsonPrimitive } from "@yaakap
|
|||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { md5 } from "js-md5";
|
import { md5 } from "js-md5";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { invokeCmd } from "../lib/tauri";
|
import { rpc } from "../lib/rpc";
|
||||||
import { activeEnvironmentIdAtom } from "./useActiveEnvironment";
|
import { activeEnvironmentIdAtom } from "./useActiveEnvironment";
|
||||||
import { activeWorkspaceIdAtom } from "./useActiveWorkspace";
|
import { activeWorkspaceIdAtom } from "./useActiveWorkspace";
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ export function useHttpAuthenticationConfig(
|
|||||||
placeholderData: (prev) => prev, // Keep previous data on refetch
|
placeholderData: (prev) => prev, // Keep previous data on refetch
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (authName == null || authName === "inherit") return null;
|
if (authName == null || authName === "inherit") return null;
|
||||||
const config = await invokeCmd<GetHttpAuthenticationConfigResponse>(
|
const config = await rpc<GetHttpAuthenticationConfigResponse>(
|
||||||
"cmd_get_http_authentication_config",
|
"cmd_get_http_authentication_config",
|
||||||
{
|
{
|
||||||
authName,
|
authName,
|
||||||
@@ -65,7 +65,7 @@ export function useHttpAuthenticationConfig(
|
|||||||
call: async (
|
call: async (
|
||||||
model: HttpRequest | GrpcRequest | WebsocketRequest | Folder | Workspace,
|
model: HttpRequest | GrpcRequest | WebsocketRequest | Folder | Workspace,
|
||||||
) => {
|
) => {
|
||||||
await invokeCmd("cmd_call_http_authentication_action", {
|
await rpc("cmd_call_http_authentication_action", {
|
||||||
pluginRefId: config.pluginRefId,
|
pluginRefId: config.pluginRefId,
|
||||||
actionIndex: i,
|
actionIndex: i,
|
||||||
authName,
|
authName,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type {
|
|||||||
HttpRequestAction,
|
HttpRequestAction,
|
||||||
} from "@yaakapp-internal/plugins";
|
} from "@yaakapp-internal/plugins";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { invokeCmd } from "../lib/tauri";
|
import { rpc } from "../lib/rpc";
|
||||||
import { usePluginsKey } from "./usePlugins";
|
import { usePluginsKey } from "./usePlugins";
|
||||||
|
|
||||||
export type CallableHttpRequestAction = Pick<HttpRequestAction, "label" | "icon"> & {
|
export type CallableHttpRequestAction = Pick<HttpRequestAction, "label" | "icon"> & {
|
||||||
@@ -30,7 +30,7 @@ export function useHttpRequestActions() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getHttpRequestActions() {
|
export async function getHttpRequestActions() {
|
||||||
const responses = await invokeCmd<GetHttpRequestActionsResponse[]>("cmd_http_request_actions");
|
const responses = await rpc<GetHttpRequestActionsResponse[]>("cmd_http_request_actions");
|
||||||
const actions = responses.flatMap((r) =>
|
const actions = responses.flatMap((r) =>
|
||||||
r.actions.map((a, i) => ({
|
r.actions.map((a, i) => ({
|
||||||
label: a.label,
|
label: a.label,
|
||||||
@@ -41,7 +41,7 @@ export async function getHttpRequestActions() {
|
|||||||
pluginRefId: r.pluginRefId,
|
pluginRefId: r.pluginRefId,
|
||||||
args: { httpRequest },
|
args: { httpRequest },
|
||||||
};
|
};
|
||||||
await invokeCmd("cmd_call_http_request_action", { req: payload });
|
await rpc("cmd_call_http_request_action", { req: payload });
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||||
import { invokeCmd } from "../lib/tauri";
|
import { rpc } from "../lib/rpc";
|
||||||
|
|
||||||
export function useHttpRequestBody(response: HttpResponse | null) {
|
export function useHttpRequestBody(response: HttpResponse | null) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@@ -18,7 +18,7 @@ export async function getRequestBodyText(response: HttpResponse | null) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await invokeCmd<number[] | null>("cmd_http_request_body", {
|
const data = await rpc<number[] | null>("cmd_http_request_body", {
|
||||||
responseId: response.id,
|
responseId: response.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import type { HttpResponse, HttpResponseEvent } from "@yaakapp-internal/models";
|
import type { HttpResponse, HttpResponseEvent } from "@yaakapp-internal/models";
|
||||||
import {
|
import {
|
||||||
httpResponseEventsAtom,
|
httpResponseEventsAtom,
|
||||||
@@ -8,6 +7,7 @@ import {
|
|||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { fireAndForget } from "../lib/fireAndForget";
|
import { fireAndForget } from "../lib/fireAndForget";
|
||||||
|
import { rpc } from "../lib/rpc";
|
||||||
|
|
||||||
export function useHttpResponseEvents(response: HttpResponse | null) {
|
export function useHttpResponseEvents(response: HttpResponse | null) {
|
||||||
const allEvents = useAtomValue(httpResponseEventsAtom);
|
const allEvents = useAtomValue(httpResponseEventsAtom);
|
||||||
@@ -20,7 +20,7 @@ export function useHttpResponseEvents(response: HttpResponse | null) {
|
|||||||
|
|
||||||
// Fetch events from database, filtering out events from other responses and merging atomically
|
// Fetch events from database, filtering out events from other responses and merging atomically
|
||||||
fireAndForget(
|
fireAndForget(
|
||||||
invoke<HttpResponseEvent[]>("cmd_get_http_response_events", { responseId: response.id }).then(
|
rpc<HttpResponseEvent[]>("cmd_get_http_response_events", { responseId: response.id }).then(
|
||||||
(events) =>
|
(events) =>
|
||||||
mergeModelsInStore("http_response_event", events, (e) => e.responseId === response.id),
|
mergeModelsInStore("http_response_event", events, (e) => e.responseId === response.id),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
|
||||||
import { patchModelById } from "@yaakapp-internal/models";
|
|
||||||
import { createRequestAndNavigate } from "../lib/createRequestAndNavigate";
|
|
||||||
import { jotaiStore } from "../lib/jotai";
|
|
||||||
import { invokeCmd } from "../lib/tauri";
|
|
||||||
import { showToast } from "../lib/toast";
|
|
||||||
import { activeWorkspaceIdAtom } from "./useActiveWorkspace";
|
|
||||||
import { useFastMutation } from "./useFastMutation";
|
|
||||||
import { wasUpdatedExternally } from "./useRequestUpdateKey";
|
|
||||||
|
|
||||||
export function useImportCurl() {
|
|
||||||
return useFastMutation({
|
|
||||||
mutationKey: ["import_curl"],
|
|
||||||
mutationFn: async ({
|
|
||||||
overwriteRequestId,
|
|
||||||
command,
|
|
||||||
}: {
|
|
||||||
overwriteRequestId?: string;
|
|
||||||
command: string;
|
|
||||||
}) => {
|
|
||||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
|
||||||
const importedRequest: HttpRequest = await invokeCmd("cmd_curl_to_request", {
|
|
||||||
command,
|
|
||||||
workspaceId,
|
|
||||||
});
|
|
||||||
|
|
||||||
let verb: string;
|
|
||||||
if (overwriteRequestId == null) {
|
|
||||||
verb = "Created";
|
|
||||||
await createRequestAndNavigate(importedRequest);
|
|
||||||
} else {
|
|
||||||
verb = "Updated";
|
|
||||||
await patchModelById(importedRequest.model, overwriteRequestId, (r: HttpRequest) => ({
|
|
||||||
...importedRequest,
|
|
||||||
id: r.id,
|
|
||||||
createdAt: r.createdAt,
|
|
||||||
workspaceId: r.workspaceId,
|
|
||||||
folderId: r.folderId,
|
|
||||||
name: r.name,
|
|
||||||
sortPriority: r.sortPriority,
|
|
||||||
}));
|
|
||||||
|
|
||||||
setTimeout(() => wasUpdatedExternally(overwriteRequestId), 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
showToast({
|
|
||||||
color: "success",
|
|
||||||
message: `${verb} request from Curl`,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user