Compare commits

...
Author SHA1 Message Date
Gregory SchierandClaude Fable 5 50cccf1d25 fix(auth-oauth1): sign with the token secret when no access token is set (#611)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 15:27:40 -07:00
Gregory SchierandClaude Fable 5 0aae516f3a feat(importer-openapi): fill the OAuth redirect URI from an environment variable (#610)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 15:27:36 -07:00
Gregory SchierandClaude Fable 5 b7b8ae5f94 feat(settings): add HTTP version as an inherited request setting (#609)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 15:01:02 -07:00
Gregory SchierandClaude Fable 5 6777003b70 fix(updater): check for updates on Linux deb/rpm installs and prompt manual download (#604)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 19:43:54 -07:00
Gregory SchierandClaude Fable 5 426c6d5eb6 fix(plugins): render template function config values before plugins see them (#608)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 19:19:15 -07:00
Ngo Quoc Viet 068afe325e fix(importer-curl): keep an = inside --url-query and form values (#606) 2026-08-24 21:14:37 -07:00
Ngo Quoc Viet cef1129d4b fix(template-function-json): escape control characters in json.escape (#607) 2026-08-24 21:14:14 -07:00
Ngo Quoc Viet 9a7bcf73bb fix(auth-oauth1): use the signing key as the PLAINTEXT signature (#605) 2026-08-24 21:13:57 -07:00
Gregory SchierandClaude Opus 5 aa76d501f0 fix(appearance): detect the macOS system appearance via NSApp.effectiveAppearance (#603)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 00:04:26 -07:00
Gregory Schier ce8cb5e7bc fix(environments): avoid opening dropdown with editor 2026-08-20 11:14:36 -07:00
Gregory Schier 66c13d7e13 Port OpenAPI improvements from the integration branch (#602) 2026-08-20 08:42:42 -07:00
Ngo Quoc Viet 388dd8815f fix(copy-as): emit shell-safe arguments in the curl and gRPCurl exporters (#593) 2026-08-20 08:41:47 -07:00
Ngo Quoc Viet cb295b7102 fix(importer-curl): keep --data-urlencode whole and survive a stray percent (#597) 2026-08-20 08:41:03 -07:00
Gregory Schier 4e835cf7e8 Add OpenAPI import round-trip harness (#600) 2026-08-20 07:44:15 -07:00
Gregory Schier 2350689d7a Import OpenAPI security the way Yaak inherits it (#601) 2026-08-20 07:39:18 -07:00
Gregory Schier 4eebd606ef Fix OpenAPI importer issues found by spec review (#599) 2026-08-20 07:16:44 -07:00
Gregory Schier c4f96f3f11 Create environments for OpenAPI server URLs (#590) 2026-08-19 12:45:32 -07:00
Gregory Schier 71c217d3f0 Populate OAuth credential variables during OpenAPI import (#587) 2026-08-19 08:43:17 -07:00
Gregory Schier d89831a84d Preserve OpenAPI security requirement semantics (#586) 2026-08-19 08:35:12 -07:00
Gregory Schier 3332ae263f Align OpenAPI 3.2 URL expectations with base URL variables (#594) 2026-08-19 07:42:54 -07:00
Gregory Schier df1fd864b2 Support OpenAPI 3.2 QUERY and additional operations (#591) 2026-08-19 07:10:32 -07:00
Gregory Schier a2d54ca774 Preserve base URL variable when OpenAPI servers are omitted (#585) 2026-08-19 07:07:18 -07:00
Gregory Schier 36fec8b005 Render all columns in irregular CSV responses (#584) 2026-08-18 20:24:57 -07:00
Gregory Schier 115615d994 Let the web image workflow publish a version by hand
`latest` could only ever come from a release tag, and tagging also fires the
app and CLI releases. A dispatch that names a version now publishes it and
latest, which is how the first image gets out before a release exists.
2026-08-18 11:48:59 -07:00
Gregory Schier 538f782068 Serve the web client from yaak-web (#582) 2026-08-18 10:39:17 -07:00
Gregory Schier 3f202ff664 Fix macOS relaunch activation (#583) 2026-08-18 09:56:40 -07:00
Ngo Quoc Viet 131b7e5ab1 fix(importer-curl): read combined short flags as separate options (#577) 2026-08-18 09:09:04 -07:00
Gregory Schier 69083918f9 Add the browser send proxy and web sender (#572) 2026-08-18 08:28:01 -07:00
Gregory Schier 4b2dcf9a1a Open Settings in a dialog on the web build (#581) 2026-08-17 19:07:25 -07:00
Gregory Schier 569f552d79 Stop sending scope on the authorization code token request (#579) 2026-08-17 12:23:08 -07:00
Gregory Schier 33f32cccf6 Leave browser zoom to the browser (#578)
The web build bound Cmd/Ctrl +, - and 0 and then did nothing with them, so zoom appeared broken. Those keys are the browser's own page zoom, which scales the whole page and remembers it per site.

Adds an interfaceZoom capability: true on desktop, where the host zooms the webview, false in a browser. When false the app binds nothing and the hotkeys screen drops the three rows it can't configure.
2026-08-17 11:29:20 -07:00
Gregory Schier 7ca772347f Sweep orphaned response bodies at browser startup (#576) 2026-08-17 10:38:52 -07:00
Gregory Schier b89c448345 Fix cookie defaults and Timeline ordering, and give models a real Default (#573) 2026-08-17 09:14:17 -07:00
Gregory Schier 2021df112a Implement response deletes in the browser host instead of declining them (#574) 2026-08-17 09:02:18 -07:00
Gregory Schier 2d2a390bfd Use clang-18 from apt.llvm.org for the wasm build on 22.04 runners
clang-15 got past the C23 [[noreturn]] error but still fails compiling
sqlite-wasm-rs for wasm32: its stdint.h falls through to host glibc headers
(bits/libc-header-start.h not found). clang-18 handles wasm32 as freestanding
and compiles it (it is what ubuntu-24.04 uses). 22.04's repos stop at clang-15,
so install 18 from apt.llvm.org. Runners stay on 22.04 to keep the glibc floor.
2026-08-16 22:54:34 -07:00
Gregory Schier 5cce23566a Allow manual worktree setup 2026-08-16 22:32:33 -07:00
Gregory Schier e99f6d2bc7 Gate the titlebar inset on a windowChrome capability instead of osType (#570) 2026-08-16 22:21:38 -07:00
Gregory Schier bea58b16b4 Force text presentation for the Enter hotkey symbol (#569) 2026-08-16 22:21:04 -07:00
Gregory Schier 93fba4d9b4 Restore ubuntu-22.04 release runners; use clang-15 for the wasm build (#568) 2026-08-16 21:52:56 -07:00
Gregory Schier b9071eafe0 Revert "Guard app releases against missing artifacts"
This reverts commit 778c74c635.
2026-08-16 18:19:47 -07:00
Gregory Schier 778c74c635 Guard app releases against missing artifacts 2026-08-16 17:30:10 -07:00
Gregory Schier 0f434361a7 Fix Linux WASM release builds 2026-08-16 15:41:00 -07:00
Gregory Schier d27d11af7c Move plugin actions and authentication onto PluginHost (#563) 2026-08-16 11:41:03 -07:00
Gregory Schier 1a19a06a23 Add native OpenAPI importer (#486) 2026-08-16 11:40:34 -07:00
Gregory Schier 07a9a6c6c0 Update NTLM auth tests for the new send() response shape (#567) 2026-08-16 11:35:17 -07:00
Gregory Schier e54240d579 Let plugins declare assets to place beside the bundle (#565) 2026-08-16 11:22:43 -07:00
Gregory Schier 8bca013ab4 Fix plugin runtime build and JSON linter crash (#566) 2026-08-16 11:22:02 -07:00
Gregory Schier 10e962a0e6 Add a plugin API for reading HTTP response bodies (#560) 2026-08-16 11:10:14 -07:00
Gregory Schier 78954e10c8 Fix 23 Dependabot alerts (#562) 2026-08-16 10:27:28 -07:00
Gregory Schier 9eb7a001da Move template rendering and themes onto PluginHost (#559) 2026-08-16 09:22:46 -07:00
Gregory Schier 6a02cbe525 Add a Host trait and move DB/model commands off Tauri (#558) 2026-08-16 08:41:35 -07:00
Gregory Schier 6f91f76064 Run the desktop's model layer in the browser (#557) 2026-08-16 07:34:48 -07:00
Gregory Schier 32e92d484b Let yaak-models compile for wasm32-unknown-unknown (#556) 2026-08-15 14:55:54 -07:00
Gregory Schier cdbbef34f8 Fix native TLS client certificates on Linux (#554) 2026-08-15 11:36:36 -07:00
Gregory SchierandClaude Fable 5 4838353585 Move the RPC wire schema into a Tauri-free crate (#553)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 11:25:27 -07:00
Gregory Schier 93001e3da7 Extract generic model writes into yaak::models_ops (#552) 2026-08-15 11:01:41 -07:00
b31c066717 Load GraphQL schema from file for autocomplete (#462)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
2026-08-15 10:14:13 -07:00
Gregory Schier 5d1d24870a Import from a URL in the Import Data dialog (#551) 2026-08-15 10:03:43 -07:00
Gregory Schier 6f0d0ef275 Don't save response when the file dialog is cancelled 2026-08-15 09:06:07 -07:00
Gregory Schier 85a9b2a908 Address response bodies by response id instead of a filesystem path (#550) 2026-08-15 08:21:23 -07:00
Gregory Schier f3f05502d1 Make the HTTP send path runnable without a database (#545) 2026-08-15 07:17:17 -07:00
pixel-hawk 2e0f7d1818 Add response filter history with pinning (#338) 2026-08-14 22:44:11 -07:00
Gregory Schier dc793181bb Add submenuTrigger option for dropdown items (#548) 2026-08-14 22:40:07 -07:00
Gregory Schier 7dfa7e07e3 Own response filter state where the filter runs (#549) 2026-08-14 22:38:38 -07:00
Gregory SchierandClaude Fable 5 23e7229e63 Route all app commands through a single RPC envelope (#542)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 14:16:14 -07:00
Gregory Schier 1f91cddab9 Restore HTTP/2 and client certificate parity on the native TLS path (#543) 2026-08-14 14:08:14 -07:00
Michaël ArnautsandGregory Schier be004425fa fix: Add username to the tokenStoreKey so the saved token is invalidated when the user changes in the environment (#426)
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
2026-08-14 13:50:48 -07:00
Gregory Schier 4f03c5c390 Fix sync watcher panic when the sync directory path traverses a symlink (#541) 2026-08-14 13:34:39 -07:00
Gregory Schier cb43f0b9b9 Follow the OS appearance when set to System without a new window (#540) 2026-08-14 13:14:01 -07:00
Mark FultonandGregory Schier 68671377fd fix: AWS4 authentication method didn't sign request body (#430)
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
2026-08-14 13:07:05 -07:00
Gregory SchierandClaude Opus 5 2383f06e71 Add a typed platform package to decouple the frontend from Tauri (#539)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:44:01 -07:00
Gregory Schier 0068be9ffc Run the CLI update check in non-interactive shells so agents see it 2026-08-14 12:27:38 -07:00
Gregory Schier def7d752db Add the MIT license field to the CLI npm packages 2026-08-14 12:27:38 -07:00
Gregory Schier 85aed740a8 Import Curl from a paste in the browser build (#538) 2026-08-14 11:54:56 -07:00
Gregory Schier d3055c64b3 Honor workspace connection settings in CLI sends (#537) 2026-08-14 10:38:47 -07:00
Gregory Schier 912aaeb7a7 Raise release build timeout to 60 minutes 2026-08-14 10:33:39 -07:00
370 changed files with 31710 additions and 7673 deletions
+11
View File
@@ -0,0 +1,11 @@
node_modules
**/node_modules
dist
**/dist
target
**/target
.claude
vendored
**/vendored
*.log
.git
+4
View File
@@ -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
+13 -1
View File
@@ -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
+123
View File
@@ -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 }}
Generated
+449 -78
View File
@@ -2,6 +2,18 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "accessory"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc86a613208ca7d144ca24f6ec49c5cdfa5c05a46f2f7ab5880ff99371f165de"
dependencies = [
"macroific 3.0.1",
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]] [[package]]
name = "adler2" name = "adler2"
version = "2.0.0" version = "2.0.0"
@@ -40,18 +52,6 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"once_cell",
"version_check",
"zerocopy",
]
[[package]] [[package]]
name = "aho-corasick" name = "aho-corasick"
version = "0.6.10" version = "0.6.10"
@@ -249,7 +249,7 @@ dependencies = [
"enumflags2", "enumflags2",
"futures-channel", "futures-channel",
"futures-util", "futures-util",
"rand 0.8.5", "rand 0.8.7",
"serde", "serde",
"serde_repr", "serde_repr",
"url", "url",
@@ -265,7 +265,7 @@ dependencies = [
"enumflags2", "enumflags2",
"futures-channel", "futures-channel",
"futures-util", "futures-util",
"rand 0.9.1", "rand 0.9.5",
"raw-window-handle", "raw-window-handle",
"serde", "serde",
"serde_repr", "serde_repr",
@@ -585,9 +585,9 @@ checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
[[package]] [[package]]
name = "aws-lc-rs" name = "aws-lc-rs"
version = "1.16.1" version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf" checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e"
dependencies = [ dependencies = [
"aws-lc-sys", "aws-lc-sys",
"zeroize", "zeroize",
@@ -595,14 +595,15 @@ dependencies = [
[[package]] [[package]]
name = "aws-lc-sys" name = "aws-lc-sys"
version = "0.38.0" version = "0.44.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e" checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483"
dependencies = [ dependencies = [
"cc", "cc",
"cmake", "cmake",
"dunce", "dunce",
"fs_extra", "fs_extra",
"pkg-config",
] ]
[[package]] [[package]]
@@ -618,6 +619,8 @@ dependencies = [
"http", "http",
"http-body", "http-body",
"http-body-util", "http-body-util",
"hyper",
"hyper-util",
"itoa", "itoa",
"matchit", "matchit",
"memchr", "memchr",
@@ -626,10 +629,15 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
"rustversion", "rustversion",
"serde", "serde",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sync_wrapper", "sync_wrapper",
"tokio",
"tower 0.5.2", "tower 0.5.2",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing",
] ]
[[package]] [[package]]
@@ -650,6 +658,7 @@ dependencies = [
"sync_wrapper", "sync_wrapper",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing",
] ]
[[package]] [[package]]
@@ -1066,10 +1075,11 @@ dependencies = [
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.26" version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "956a5e21988b87f372569b66183b78babf23ebc2e744b733e4350a752c4dafac" checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d"
dependencies = [ dependencies = [
"find-msvc-tools",
"jobserver", "jobserver",
"libc", "libc",
"shlex", "shlex",
@@ -1386,6 +1396,16 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "console_error_panic_hook"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
dependencies = [
"cfg-if",
"wasm-bindgen",
]
[[package]] [[package]]
name = "const-random" name = "const-random"
version = "0.1.18" version = "0.1.18"
@@ -1870,6 +1890,20 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da692b8d1080ea3045efaab14434d40468c3d8657e42abddfffca87b428f4c1b" checksum = "da692b8d1080ea3045efaab14434d40468c3d8657e42abddfffca87b428f4c1b"
[[package]]
name = "delegate-display"
version = "3.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54e3a499943fd5180aeffcb708164ffb22125ceeccb05491b5297981d37d6249"
dependencies = [
"impartial-ord",
"itoa",
"macroific 3.0.1",
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]] [[package]]
name = "deranged" name = "deranged"
version = "0.5.5" version = "0.5.5"
@@ -2361,6 +2395,18 @@ dependencies = [
"regex-syntax 0.8.5", "regex-syntax 0.8.5",
] ]
[[package]]
name = "fancy_constructor"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a8211ab12c36b63c269e17873331dc1da196a3edb7a5c79015677ef972da34a"
dependencies = [
"macroific 3.0.1",
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]] [[package]]
name = "fast-glob" name = "fast-glob"
version = "1.0.0" version = "1.0.0"
@@ -2417,6 +2463,12 @@ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
[[package]]
name = "find-msvc-tools"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
[[package]] [[package]]
name = "fixedbitset" name = "fixedbitset"
version = "0.4.2" version = "0.4.2"
@@ -2849,8 +2901,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"wasi 0.11.0+wasi-snapshot-preview1", "wasi 0.11.0+wasi-snapshot-preview1",
"wasm-bindgen",
] ]
[[package]] [[package]]
@@ -3091,7 +3145,7 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
dependencies = [ dependencies = [
"ahash 0.7.8", "ahash",
] ]
[[package]] [[package]]
@@ -3099,9 +3153,6 @@ name = "hashbrown"
version = "0.14.5" version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash 0.8.12",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
@@ -3131,11 +3182,11 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]] [[package]]
name = "hashlink" name = "hashlink"
version = "0.9.1" version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f"
dependencies = [ dependencies = [
"hashbrown 0.14.5", "hashbrown 0.16.1",
] ]
[[package]] [[package]]
@@ -3233,6 +3284,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
[[package]]
name = "http-range-header"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
[[package]] [[package]]
name = "httparse" name = "httparse"
version = "1.10.1" version = "1.10.1"
@@ -3497,6 +3554,17 @@ dependencies = [
"tiff", "tiff",
] ]
[[package]]
name = "impartial-ord"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc680e03d1a08bdc1c01b60e0c39723430ced63d8cabd93cee0449b6ba916a25"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]] [[package]]
name = "include_dir" name = "include_dir"
version = "0.7.4" version = "0.7.4"
@@ -3516,6 +3584,40 @@ dependencies = [
"quote", "quote",
] ]
[[package]]
name = "indexed_db_futures"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69ff41758cbd104e91033bb53bc449bec7eea65652960c81eddf3fc146ecea19"
dependencies = [
"accessory",
"cfg-if",
"delegate-display",
"derive_more 2.1.1",
"fancy_constructor",
"indexed_db_futures_macros_internal",
"js-sys",
"sealed 0.6.0",
"smallvec",
"thiserror 2.0.17",
"tokio",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "indexed_db_futures_macros_internal"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caeba94923b68f254abef921cea7e7698bf4675fdd89d7c58bf1ed885b49a27d"
dependencies = [
"macroific 2.0.0",
"proc-macro2",
"quote",
"syn 2.0.101",
]
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "1.9.3" version = "1.9.3"
@@ -3561,17 +3663,6 @@ dependencies = [
"cfb", "cfb",
] ]
[[package]]
name = "inherent"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c38228f24186d9cc68c729accb4d413be9eaed6ad07ff79e0270d9e56f3de13"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.101",
]
[[package]] [[package]]
name = "inotify" name = "inotify"
version = "0.11.0" version = "0.11.0"
@@ -4007,9 +4098,9 @@ dependencies = [
[[package]] [[package]]
name = "libsqlite3-sys" name = "libsqlite3-sys"
version = "0.30.1" version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a"
dependencies = [ dependencies = [
"cc", "cc",
"pkg-config", "pkg-config",
@@ -4109,6 +4200,102 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "macroific"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89f276537b4b8f981bf1c13d79470980f71134b7bdcc5e6e911e910e556b0285"
dependencies = [
"macroific_attr_parse 2.0.0",
"macroific_core 2.0.0",
"macroific_macro 2.0.0",
]
[[package]]
name = "macroific"
version = "3.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c437abb3bf40939b00d1dff858ed2050d948bc230dee39546ed16795fcf10ec2"
dependencies = [
"macroific_attr_parse 3.0.1",
"macroific_core 3.0.1",
"macroific_macro 3.0.1",
]
[[package]]
name = "macroific_attr_parse"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad4023761b45fcd36abed8fb7ae6a80456b0a38102d55e89a57d9a594a236be9"
dependencies = [
"proc-macro2",
"quote",
"sealed 0.6.0",
"syn 2.0.101",
]
[[package]]
name = "macroific_attr_parse"
version = "3.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aeb7a82ec1aa16094da719be18615d79cb2574b037358e4e6c6d96eedcff3a93"
dependencies = [
"proc-macro2",
"quote",
"sealed 0.7.0",
"syn 3.0.3",
]
[[package]]
name = "macroific_core"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a7594d3c14916fa55bef7e9d18c5daa9ed410dd37504251e4b75bbdeec33e3"
dependencies = [
"proc-macro2",
"quote",
"sealed 0.6.0",
"syn 2.0.101",
]
[[package]]
name = "macroific_core"
version = "3.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26c8e789484d2fa216eee9d5e0565ba80ccda389bcf0e111143a7b8f4792336e"
dependencies = [
"proc-macro2",
"quote",
"sealed 0.7.0",
"syn 3.0.3",
]
[[package]]
name = "macroific_macro"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4da6f2ed796261b0a74e2b52b42c693bb6dee1effba3a482c49592659f824b3b"
dependencies = [
"macroific_attr_parse 2.0.0",
"macroific_core 2.0.0",
"proc-macro2",
"quote",
"syn 2.0.101",
]
[[package]]
name = "macroific_macro"
version = "3.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e30d63cb102648965753f0d47a6ab171b1cbb1fb5c363f4784ea986782fc7b77"
dependencies = [
"macroific_attr_parse 3.0.1",
"macroific_core 3.0.1",
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]] [[package]]
name = "malloc_buf" name = "malloc_buf"
version = "0.0.6" version = "0.0.6"
@@ -4291,7 +4478,7 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ffa00dec017b5b1a8b7cf5e2c008bfda1aa7e0697ac1508b491fdf2622fb4d8" checksum = "3ffa00dec017b5b1a8b7cf5e2c008bfda1aa7e0697ac1508b491fdf2622fb4d8"
dependencies = [ dependencies = [
"rand 0.8.5", "rand 0.8.7",
] ]
[[package]] [[package]]
@@ -4851,15 +5038,14 @@ dependencies = [
[[package]] [[package]]
name = "openssl" name = "openssl"
version = "0.10.73" version = "0.10.81"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.0",
"cfg-if", "cfg-if",
"foreign-types 0.3.2", "foreign-types 0.3.2",
"libc", "libc",
"once_cell",
"openssl-macros", "openssl-macros",
"openssl-sys", "openssl-sys",
] ]
@@ -4883,18 +5069,18 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]] [[package]]
name = "openssl-src" name = "openssl-src"
version = "300.5.0+3.5.0" version = "300.6.1+3.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8ce546f549326b0e6052b649198487d91320875da901e7bd11a06d1ee3f9c2f" checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846"
dependencies = [ dependencies = [
"cc", "cc",
] ]
[[package]] [[package]]
name = "openssl-sys" name = "openssl-sys"
version = "0.9.109" version = "0.9.117"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
dependencies = [ dependencies = [
"cc", "cc",
"libc", "libc",
@@ -5708,7 +5894,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6"
dependencies = [ dependencies = [
"phf_shared 0.10.0", "phf_shared 0.10.0",
"rand 0.8.5", "rand 0.8.7",
] ]
[[package]] [[package]]
@@ -5718,7 +5904,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [ dependencies = [
"phf_shared 0.11.3", "phf_shared 0.11.3",
"rand 0.8.5", "rand 0.8.7",
] ]
[[package]] [[package]]
@@ -6242,9 +6428,9 @@ dependencies = [
[[package]] [[package]]
name = "r2d2_sqlite" name = "r2d2_sqlite"
version = "0.25.0" version = "0.32.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb14dba8247a6a15b7fdbc7d389e2e6f03ee9f184f87117706d509c092dfe846" checksum = "a2ebd03c29250cdf191da93a35118b4567c2ef0eacab54f65e058d6f4c9965f6"
dependencies = [ dependencies = [
"r2d2", "r2d2",
"rusqlite", "rusqlite",
@@ -6283,9 +6469,9 @@ dependencies = [
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.8.5" version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
dependencies = [ dependencies = [
"libc", "libc",
"rand_chacha 0.3.1", "rand_chacha 0.3.1",
@@ -6294,9 +6480,9 @@ dependencies = [
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.9.1" version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [ dependencies = [
"rand_chacha 0.9.0", "rand_chacha 0.9.0",
"rand_core 0.9.3", "rand_core 0.9.3",
@@ -7130,10 +7316,20 @@ dependencies = [
] ]
[[package]] [[package]]
name = "rusqlite" name = "rsqlite-vfs"
version = "0.32.1" version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
dependencies = [
"hashbrown 0.16.1",
"thiserror 2.0.17",
]
[[package]]
name = "rusqlite"
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3"
dependencies = [ dependencies = [
"bitflags 2.11.0", "bitflags 2.11.0",
"chrono", "chrono",
@@ -7142,6 +7338,7 @@ dependencies = [
"hashlink", "hashlink",
"libsqlite3-sys", "libsqlite3-sys",
"smallvec", "smallvec",
"sqlite-wasm-rs",
] ]
[[package]] [[package]]
@@ -7165,7 +7362,7 @@ dependencies = [
"borsh", "borsh",
"bytes", "bytes",
"num-traits", "num-traits",
"rand 0.8.5", "rand 0.8.7",
"rkyv", "rkyv",
"serde", "serde",
"serde_json", "serde_json",
@@ -7423,20 +7620,20 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]] [[package]]
name = "sea-query" name = "sea-query"
version = "0.32.6" version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64c91783d1514b99754fc6a4079081dcc2c587dadbff65c48c7f62297443536a" checksum = "546040c653a705e60ec65ecd3191a809603734bebbc225775916dea9ae409b31"
dependencies = [ dependencies = [
"chrono", "chrono",
"inherent", "itoa",
"sea-query-derive", "sea-query-derive",
] ]
[[package]] [[package]]
name = "sea-query-derive" name = "sea-query-derive"
version = "0.4.3" version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bae0cbad6ab996955664982739354128c58d16e126114fe88c2a493642502aab" checksum = "a0b0f466921cdd3cf4b89d5c3ac2173dba89a873ab395b123a645de181ec7537"
dependencies = [ dependencies = [
"darling 0.20.11", "darling 0.20.11",
"heck 0.4.1", "heck 0.4.1",
@@ -7448,9 +7645,9 @@ dependencies = [
[[package]] [[package]]
name = "sea-query-rusqlite" name = "sea-query-rusqlite"
version = "0.7.0" version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3743bbdfb24b1a84cc1a6fbf4b1188e6851f6e00ea20944b44c56bf03a585bb4" checksum = "1ec6038023c8517c623e5bf9606b3c54d40bc8296bb6b2986040428dd84deddd"
dependencies = [ dependencies = [
"rusqlite", "rusqlite",
"sea-query", "sea-query",
@@ -7462,6 +7659,28 @@ version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
[[package]]
name = "sealed"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.101",
]
[[package]]
name = "sealed"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b68e2ea526d9fb32f23ca8894fb5da9e743f34c2f41701f0501dc8a25c4b343"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]] [[package]]
name = "security-framework" name = "security-framework"
version = "2.11.1" version = "2.11.1"
@@ -7861,9 +8080,9 @@ dependencies = [
[[package]] [[package]]
name = "shlex" name = "shlex"
version = "1.3.0" version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]] [[package]]
name = "signal-hook" name = "signal-hook"
@@ -8031,6 +8250,34 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "sqlite-wasm-rs"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
dependencies = [
"cc",
"js-sys",
"rsqlite-vfs",
"wasm-bindgen",
]
[[package]]
name = "sqlite-wasm-vfs"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f7a5c9ac229421d577bb5a9bb59048838509958b218dd4e0b3c1214a87c361e"
dependencies = [
"indexed_db_futures",
"js-sys",
"rsqlite-vfs",
"thiserror 2.0.17",
"tokio",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]] [[package]]
name = "stable_deref_trait" name = "stable_deref_trait"
version = "1.2.0" version = "1.2.0"
@@ -8165,6 +8412,17 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "syn"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]] [[package]]
name = "sync_wrapper" name = "sync_wrapper"
version = "1.0.2" version = "1.0.2"
@@ -9265,7 +9523,7 @@ dependencies = [
"indexmap 1.9.3", "indexmap 1.9.3",
"pin-project", "pin-project",
"pin-project-lite", "pin-project-lite",
"rand 0.8.5", "rand 0.8.7",
"slab", "slab",
"tokio", "tokio",
"tokio-util", "tokio-util",
@@ -9287,6 +9545,7 @@ dependencies = [
"tokio", "tokio",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing",
] ]
[[package]] [[package]]
@@ -9295,12 +9554,22 @@ version = "0.6.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"
dependencies = [ dependencies = [
"async-compression",
"bitflags 2.11.0", "bitflags 2.11.0",
"bytes", "bytes",
"futures-core",
"futures-util", "futures-util",
"http", "http",
"http-body", "http-body",
"http-body-util",
"http-range-header",
"httpdate",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite", "pin-project-lite",
"tokio",
"tokio-util",
"tower 0.5.2", "tower 0.5.2",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
@@ -9325,6 +9594,7 @@ version = "0.1.41"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
dependencies = [ dependencies = [
"log 0.4.29",
"pin-project-lite", "pin-project-lite",
"tracing-attributes", "tracing-attributes",
"tracing-core", "tracing-core",
@@ -9482,7 +9752,7 @@ dependencies = [
"http", "http",
"httparse", "httparse",
"log 0.4.29", "log 0.4.29",
"rand 0.9.1", "rand 0.9.5",
"rustls", "rustls",
"rustls-pki-types", "rustls-pki-types",
"sha1", "sha1",
@@ -9752,7 +10022,7 @@ checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d"
dependencies = [ dependencies = [
"getrandom 0.3.3", "getrandom 0.3.3",
"js-sys", "js-sys",
"rand 0.9.1", "rand 0.9.5",
"serde", "serde",
"wasm-bindgen", "wasm-bindgen",
] ]
@@ -10952,6 +11222,7 @@ name = "yaak"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"base64 0.22.1",
"log 0.4.29", "log 0.4.29",
"md5 0.8.0", "md5 0.8.0",
"serde_json", "serde_json",
@@ -10996,7 +11267,7 @@ dependencies = [
"pretty_graphql", "pretty_graphql",
"r2d2", "r2d2",
"r2d2_sqlite", "r2d2_sqlite",
"rand 0.9.1", "rand 0.9.5",
"reqwest 0.12.20", "reqwest 0.12.20",
"rlimit", "rlimit",
"serde", "serde",
@@ -11023,6 +11294,7 @@ dependencies = [
"uuid", "uuid",
"yaak", "yaak",
"yaak-api", "yaak-api",
"yaak-commands",
"yaak-common", "yaak-common",
"yaak-core", "yaak-core",
"yaak-crypto", "yaak-crypto",
@@ -11031,9 +11303,12 @@ dependencies = [
"yaak-grpc", "yaak-grpc",
"yaak-http", "yaak-http",
"yaak-license", "yaak-license",
"yaak-lifecycle",
"yaak-mac-window", "yaak-mac-window",
"yaak-models", "yaak-models",
"yaak-plugins", "yaak-plugins",
"yaak-rpc",
"yaak-rpc-schema",
"yaak-sse", "yaak-sse",
"yaak-sync", "yaak-sync",
"yaak-system-appearance", "yaak-system-appearance",
@@ -11078,7 +11353,7 @@ dependencies = [
"log 0.4.29", "log 0.4.29",
"oxc_resolver", "oxc_resolver",
"predicates", "predicates",
"rand 0.8.5", "rand 0.8.7",
"reqwest 0.12.20", "reqwest 0.12.20",
"rolldown", "rolldown",
"schemars 0.8.22", "schemars 0.8.22",
@@ -11094,12 +11369,30 @@ dependencies = [
"yaak-core", "yaak-core",
"yaak-crypto", "yaak-crypto",
"yaak-http", "yaak-http",
"yaak-lifecycle",
"yaak-models", "yaak-models",
"yaak-plugins", "yaak-plugins",
"yaak-templates", "yaak-templates",
"zip", "zip",
] ]
[[package]]
name = "yaak-commands"
version = "0.0.0"
dependencies = [
"serde_json",
"tempfile",
"thiserror 2.0.17",
"tokio",
"yaak",
"yaak-core",
"yaak-crypto",
"yaak-models",
"yaak-plugins",
"yaak-rpc-schema",
"yaak-templates",
]
[[package]] [[package]]
name = "yaak-common" name = "yaak-common"
version = "0.1.0" version = "0.1.0"
@@ -11134,6 +11427,7 @@ name = "yaak-database"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"getrandom 0.2.16",
"include_dir", "include_dir",
"log 0.4.29", "log 0.4.29",
"nanoid", "nanoid",
@@ -11146,6 +11440,7 @@ dependencies = [
"serde_json", "serde_json",
"thiserror 2.0.17", "thiserror 2.0.17",
"ts-rs", "ts-rs",
"uuid",
] ]
[[package]] [[package]]
@@ -11201,6 +11496,7 @@ dependencies = [
"tokio-stream", "tokio-stream",
"tonic", "tonic",
"tonic-reflection", "tonic-reflection",
"ts-rs",
"uuid", "uuid",
"yaak-common", "yaak-common",
"yaak-tls", "yaak-tls",
@@ -11222,10 +11518,10 @@ dependencies = [
"log 0.4.29", "log 0.4.29",
"mime_guess", "mime_guess",
"native-tls", "native-tls",
"regex 1.11.1",
"reqwest 0.12.20", "reqwest 0.12.20",
"serde", "serde",
"serde_json", "serde_json",
"tempfile",
"thiserror 2.0.17", "thiserror 2.0.17",
"tokio", "tokio",
"tokio-util", "tokio-util",
@@ -11257,6 +11553,14 @@ dependencies = [
"yaak-models", "yaak-models",
] ]
[[package]]
name = "yaak-lifecycle"
version = "0.0.0"
dependencies = [
"log 0.4.29",
"yaak-models",
]
[[package]] [[package]]
name = "yaak-mac-window" name = "yaak-mac-window"
version = "0.1.0" version = "0.1.0"
@@ -11265,7 +11569,7 @@ dependencies = [
"csscolorparser", "csscolorparser",
"log 0.4.29", "log 0.4.29",
"objc", "objc",
"rand 0.9.1", "rand 0.9.5",
"tauri", "tauri",
"tauri-plugin", "tauri-plugin",
] ]
@@ -11290,8 +11594,10 @@ dependencies = [
"sha2", "sha2",
"thiserror 2.0.17", "thiserror 2.0.17",
"ts-rs", "ts-rs",
"urlencoding",
"yaak-core", "yaak-core",
"yaak-database", "yaak-database",
"yaak-templates",
] ]
[[package]] [[package]]
@@ -11307,7 +11613,7 @@ dependencies = [
"log 0.4.29", "log 0.4.29",
"md5 0.7.0", "md5 0.7.0",
"path-slash", "path-slash",
"rand 0.9.1", "rand 0.9.5",
"reqwest 0.12.20", "reqwest 0.12.20",
"serde", "serde",
"serde_json", "serde_json",
@@ -11369,6 +11675,22 @@ dependencies = [
"ts-rs", "ts-rs",
] ]
[[package]]
name = "yaak-rpc-schema"
version = "0.0.0"
dependencies = [
"serde",
"ts-rs",
"yaak-git",
"yaak-grpc",
"yaak-models",
"yaak-plugins",
"yaak-sse",
"yaak-sync",
"yaak-templates",
"yaak-ws",
]
[[package]] [[package]]
name = "yaak-sse" name = "yaak-sse"
version = "0.1.0" version = "0.1.0"
@@ -11401,7 +11723,10 @@ name = "yaak-system-appearance"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"dark-light", "dark-light",
"dispatch2",
"log 0.4.29", "log 0.4.29",
"objc2-app-kit",
"objc2-foundation 0.3.1",
"tauri", "tauri",
] ]
@@ -11411,6 +11736,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"regex 1.11.1", "regex 1.11.1",
"tauri", "tauri",
"yaak-core",
] ]
[[package]] [[package]]
@@ -11434,6 +11760,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"log 0.4.29", "log 0.4.29",
"p12", "p12",
"pem",
"rustls", "rustls",
"rustls-pemfile", "rustls-pemfile",
"rustls-platform-verifier", "rustls-platform-verifier",
@@ -11441,6 +11768,50 @@ dependencies = [
"thiserror 2.0.17", "thiserror 2.0.17",
"url", "url",
"yaak-models", "yaak-models",
"yasna",
]
[[package]]
name = "yaak-wasm"
version = "0.1.0"
dependencies = [
"console_error_panic_hook",
"js-sys",
"log 0.4.29",
"serde",
"serde-wasm-bindgen",
"serde_json",
"sqlite-wasm-rs",
"sqlite-wasm-vfs",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"yaak-lifecycle",
"yaak-models",
"yaak-templates",
]
[[package]]
name = "yaak-web"
version = "0.1.0"
dependencies = [
"async-trait",
"axum",
"base64 0.22.1",
"bytes",
"clap",
"env_logger",
"futures-util",
"log 0.4.29",
"serde",
"serde_json",
"tokio",
"tower-http",
"ts-rs",
"url",
"uuid",
"yaak-http",
"yaak-models",
] ]
[[package]] [[package]]
@@ -11449,7 +11820,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"log 0.4.29", "log 0.4.29",
"md5 0.8.0", "md5 0.8.0",
"rand 0.9.1", "rand 0.9.5",
"serde", "serde",
"serde_json", "serde_json",
"tauri", "tauri",
+9
View File
@@ -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" }
+19
View File
@@ -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:
+46
View File
@@ -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"]
+52
View File
@@ -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`,
});
},
});
+25 -7
View File
@@ -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 {
@@ -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,
}); });
@@ -5,11 +5,11 @@ 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 { importCurl, looksLikeCurl } from "../commands/importCurl";
import { allRequestUrlsAtom } 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";
@@ -278,7 +278,6 @@ 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"]) => patchModelDebounced(activeRequest, { body }), (body: HttpRequest["body"]) => patchModelDebounced(activeRequest, { body }),
@@ -299,8 +298,8 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
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) {
@@ -322,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),
@@ -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 }) {
+112
View File
@@ -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>
);
}
+118 -36
View File
@@ -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) { /**
const [isLoading, setIsLoading] = useState<boolean>(false); * An absolute or relative path is unambiguously a file. Everything else is treated as a URL, so a
const [filePath, setFilePath] = useLocalStorage<string | null>("importFilePath", null); * bare host like `example.com/openapi.json` still works (the backend defaults it to https).
*/
function isFilePath(value: string): boolean {
return ( return (
<VStack space={5} className="pb-4"> value.startsWith("/") ||
<CommercialUseBanner source="data-import" title="Importing work data?" /> value.startsWith("./") ||
value.startsWith("../") ||
value.startsWith("~/") ||
value.startsWith("\\\\") ||
/^[a-zA-Z]:[\\/]/.test(value)
);
}
<VStack space={1}> function fileName(path: string): string {
<ul className="list-disc pl-5"> return path.split(/[/\\]/).at(-1) || path;
<li>OpenAPI 3.0, 3.1</li> }
<li>Postman Collection v2, v2.1</li>
<li>Insomnia v4+</li> export function ImportDataDialog({ importFile, importUrl }: Props) {
<li>Swagger 2.0</li> const [isLoading, setIsLoading] = useState<boolean>(false);
<li> // A file path or a URL. Both inputs write here, so there is only ever one thing to import
Curl commands <em className="text-text-subtle">(or paste into URL)</em> const [source, setSource] = useLocalStorage<string | null>("importPathOrUrl", null);
</li> const [forceUpdateKey, setForceUpdateKey] = useState<number>(0);
</ul> const [isHovering, setIsHovering] = useState<boolean>(false);
</VStack> const ref = useRef<HTMLDivElement>(null);
<VStack space={2}> const trimmedSource = source?.trim() ?? "";
<SelectFile const filePath = isFilePath(trimmedSource) ? trimmedSource : null;
filePath={filePath ?? null}
onChange={({ filePath }) => setFilePath(filePath)} const selectSource = (value: string) => {
/> setSource(value);
{filePath && ( // Remount the input so it shows the path of the newly-picked file
<Button setForceUpdateKey((k) => k + 1);
color="primary" };
disabled={!filePath || isLoading}
isLoading={isLoading} // Accept a file dropped anywhere on the dialog, the way SelectFile does for its button
size="sm" useEffect(() => {
onClick={async () => { 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); setIsLoading(true);
try { try {
await importData(filePath); if (filePath != null) {
await importFile(filePath);
} else {
await importUrl(trimmedSource);
}
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}} };
return (
<VStack ref={ref} space={4} className="pb-4">
<CommercialUseBanner source="data-import" title="Importing work data?" />
<button
type="button"
onClick={handleSelectFile}
className={classNames(
"w-full rounded-lg border border-dashed px-4 py-6",
"flex flex-col items-center gap-1 text-center",
isHovering ? "border-notice bg-surface-highlight" : "border-border hover:border-text",
)}
>
<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"} {isLoading ? "Importing" : "Import"}
</Button> </Button>
)}
</VStack> </VStack>
</VStack> </VStack>
); );
+3 -3
View File
@@ -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",
+3 -4
View File
@@ -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>
+2 -2
View File
@@ -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>
+7 -19
View File
@@ -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) => {
if (event.payload.type === "over") {
const p = event.payload.position;
const r = ref.current?.getBoundingClientRect(); const r = ref.current?.getBoundingClientRect();
if (r == null) return; if (r == null) return;
const isOver = p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom; const isOver = p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom;
console.log("IS OVER", isOver);
setIsHovering(isOver); setIsHovering(isOver);
} else if (event.payload.type === "drop" && isHovering) { } else if (event.type === "drop" && isHovering) {
console.log("User dropped", event.payload.paths); const p = event.paths[0];
const p = event.payload.paths[0];
if (p) onChange({ filePath: p, contentType: null }); if (p) onChange({ filePath: p, contentType: null });
setIsHovering(false); setIsHovering(false);
} else { } else {
console.log("File drop cancelled");
setIsHovering(false); 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}`),
}, },
]} ]}
> >
+4 -4
View File
@@ -1,6 +1,5 @@
import type { Extension } from "@codemirror/state"; import type { Extension } from "@codemirror/state";
import { Compartment } from "@codemirror/state"; import { Compartment } from "@codemirror/state";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { debounce } from "@yaakapp-internal/lib"; import { debounce } from "@yaakapp-internal/lib";
import { gitMutations } from "@yaakapp-internal/git"; import { gitMutations } from "@yaakapp-internal/git";
import type { GitStatus } from "@yaakapp-internal/git"; import type { GitStatus } from "@yaakapp-internal/git";
@@ -44,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";
@@ -135,10 +135,10 @@ function Sidebar({ className }: { className?: string }) {
// Focus new sidebar models created by the user in this window. Writes from other // Focus new sidebar models created by the user in this window. Writes from other
// sources (import, sync, CLI) can carry thousands of models and shouldn't move // sources (import, sync, CLI) can carry thousands of models and shouldn't move
// the selection. // the selection.
useListenToTauriEvent<ModelPayload[]>("model_writes", ({ payload: payloads }) => { usePlatformEvent<ModelPayload[]>("model_writes", (payloads) => {
for (const payload of payloads) { for (const payload of payloads) {
if (payload.updateSource.type !== "window") continue; if (payload.updateSource.type !== "window") continue;
if (payload.updateSource.label !== getCurrentWebviewWindow().label) continue; if (payload.updateSource.label !== platform.window.label) continue;
if (!isSidebarLeafModel(payload.model)) continue; if (!isSidebarLeafModel(payload.model)) continue;
if (!(payload.change.type === "upsert" && payload.change.created)) continue; if (!(payload.change.type === "upsert" && payload.change.created)) continue;
treeRef.current?.selectItem(payload.model.id, true); treeRef.current?.selectItem(payload.model.id, true);
@@ -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;
+2 -2
View File
@@ -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";
@@ -27,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">;
@@ -76,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,
@@ -120,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
+81 -9
View File
@@ -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"];
}; };
@@ -502,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, () => {
@@ -629,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 });
@@ -648,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) {
@@ -776,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>
@@ -797,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}
@@ -868,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;
} }
@@ -879,6 +900,7 @@ function MenuItem({
onHover, onHover,
item, item,
onSelect, onSelect,
onOpenSubmenu,
isParentOfActiveSubmenu, isParentOfActiveSubmenu,
...props ...props
}: MenuItemProps) { }: MenuItemProps) {
@@ -914,19 +936,22 @@ function MenuItem({
e.currentTarget.focus(); e.currentTarget.focus();
}; };
const rightSlot = item.submenu ? ( const hasButtonSubmenu = item.submenu != null && item.submenuTrigger === "button";
const rightSlot =
item.submenu && !hasButtonSubmenu ? (
<Icon icon="chevron_right" color="secondary" /> <Icon icon="chevron_right" color="secondary" />
) : ( ) : (
(item.rightSlot ?? <Hotkey variant="text" action={item.hotKeyAction ?? null} />) (item.rightSlot ?? <Hotkey variant="text" action={item.hotKeyAction ?? null} />)
); );
return ( 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}
@@ -947,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!",
@@ -959,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 {
@@ -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,
disallowComments: !(options?.allowComments ?? true),
});
// Later errors are mostly consequences of the first one, so only that one is shown
const error = errors[0];
if (error == null) return [];
return [ return [
{ {
from: err.location.start.offset, from: error.offset,
to: err.location.start.offset, to: error.offset + error.length,
severity: "error", severity: "error",
message: err.message, message: MESSAGES[printParseErrorCode(error.error)] ?? "Invalid JSON",
}, },
]; ];
}
}
return [];
}; };
} }
@@ -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,6 +33,7 @@ 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 {
/** /**
@@ -851,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",
+2 -2
View File
@@ -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: () => {
setGraphqlDocStateAtomValue((v) => ({
...v,
[request.id]: isDocOpen ? undefined : null,
}));
},
},
{
label: "Introspect Schema",
leftSlot: <Icon icon="refresh" spin={isLoading} />, leftSlot: <Icon icon="refresh" spin={isLoading} />,
keepOpenOnSelect: true, keepOpenOnSelect: true,
onSelect: refetch, // Failures surface through the hook's error state either way.
onSelect: async () => {
if (filePath != null) await reloadFromFile();
else await refetch();
},
}, },
{ type: "separator", label: "Setting" },
{ {
label: "Automatic Introspection", label: filePath == null ? "Load Schema from File…" : "Load a Different File…",
leftSlot: <Icon icon="import" />,
onSelect: handleLoadFromFile,
},
{
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,29 +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; mimeType?: string;
} }
export function AudioViewer({ bodyPath, data, mimeType }: 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) {
// The type matters here in a way it doesn't for an image: a media element goes by what // The type matters here in a way it doesn't for an image: a media element goes by what
// the blob declares rather than sniffing it, so an Ogg labelled as MP3 won't play // the blob declares rather than sniffing it, so an Ogg labelled as MP3 won't play
const blob = new Blob([new Uint8Array(data)], { type: mimeType ?? "audio/mpeg" }); const blob = new Blob([new Uint8Array(data)], { type: mimeType ?? "audio/mpeg" });
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, mimeType]); }, [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>
))} ))}
@@ -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,10 +1,10 @@
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; mimeType?: string } & ( type Props = { className?: string; mimeType?: string } & (
| { | {
bodyPath: string; /** A URL for the body the host already stored. */
bodyUrl: string;
} }
| { | {
data: ArrayBuffer; data: ArrayBuffer;
@@ -13,21 +13,21 @@ type Props = { className?: string; mimeType?: string } & (
export function ImageViewer({ className, mimeType, ...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: mimeType ?? "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, mimeType]); }, [bodyUrl, data, mimeType]);
return ( return (
<img <img
@@ -1,6 +1,5 @@
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 { useMemo, useRef, useState } from "react"; import { useMemo, useRef, useState } from "react";
@@ -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,7 +27,7 @@ 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>();
@@ -36,8 +36,8 @@ export function PdfViewer({ bodyPath, data }: Props) {
// During render, not in an effect: an effect leaves the first paint with no file, and // During render, not in an effect: an effect leaves the first paint with no file, and
// `Document` renders its "Failed to load PDF file" state for that frame before recovering // `Document` renders its "Failed to load PDF file" state for that frame before recovering
const src = useMemo(() => { const src = useMemo(() => {
if (bodyPath) { if (bodyUrl) {
return convertFileSrc(bodyPath); return bodyUrl;
} }
if (data) { if (data) {
// Create a copy to avoid "Buffer is already detached" errors // Create a copy to avoid "Buffer is already detached" errors
@@ -45,7 +45,7 @@ export function PdfViewer({ bodyPath, data }: Props) {
return { data: new Uint8Array(data) }; return { data: new Uint8Array(data) };
} }
return undefined; return undefined;
}, [bodyPath, data]); }, [bodyUrl, data]);
const onDocumentLoadSuccess = ({ numPages: nextNumPages }: PDFDocumentProxy): void => { const onDocumentLoadSuccess = ({ numPages: nextNumPages }: PDFDocumentProxy): void => {
setNumPages(nextNumPages); setNumPages(nextNumPages);
@@ -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,56 +17,44 @@ 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;
setFilterTextMap((m) => ({ ...m, [filterKey]: v }));
},
[filterKey, setFilterTextMap],
);
const isSearching = filterText != null; const handleFilterKeyDown = useCallback(
const filteredResponse = (e: KeyboardEvent) => {
onFilter && debouncedFilterText if (filter == null) return;
? onFilter(debouncedFilterText) if (e.key === "Escape") {
: { data: null, isPending: false, error: false }; filter.toggleSearch();
} else if (e.key === "Enter" && filter.filterText != null) {
const toggleSearch = useCallback(() => { filter.applyFilter(filter.filterText);
if (isSearching) {
setFilterText(null);
} else {
setFilterText("");
} }
}, [isSearching, setFilterText]); },
[filter],
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( nodes.push(
<IconButton <IconButton
key="icon" key="icon"
size="sm" size="sm"
isLoading={filteredResponse.isPending} isLoading={filterResult?.isPending ?? false}
icon={isSearching ? "x" : "filter"} icon="filter"
title={isSearching ? "Close filter" : "Filter response"} title="Filter response"
onClick={toggleSearch} onClick={filter.toggleSearch}
className={classNames("border border-border-subtle!", isSearching && "opacity-100!")} className="border border-border-subtle!"
/>, />,
); );
}
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,6 +155,16 @@ export function TextViewer({
} }
return ( return (
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full w-full">
{appliedFilter && filter != null ? (
<AppliedFilterBar
filter={appliedFilter}
error={resultError}
onClear={() => filter.replaceFilter("")}
/>
) : (
<span />
)}
<Editor <Editor
readOnly readOnly
className={className} className={className}
@@ -152,6 +174,42 @@ export function TextViewer({
extraExtensions={extraExtensions} extraExtensions={extraExtensions}
stateKey={stateKey} 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,28 +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; mimeType?: string;
} }
export function VideoViewer({ bodyPath, data, mimeType }: 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) {
// As in AudioViewer: a media element trusts the declared type instead of sniffing // As in AudioViewer: a media element trusts the declared type instead of sniffing
const blob = new Blob([new Uint8Array(data)], { type: mimeType ?? "video/mp4" }); const blob = new Blob([new Uint8Array(data)], { type: mimeType ?? "video/mp4" });
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, mimeType]); }, [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} />;
+4 -4
View File
@@ -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,12 +8,12 @@ function setFontSizeOnDocument(fontSize: number) {
document.documentElement.style.fontSize = `${fontSize}px`; document.documentElement.style.fontSize = `${fontSize}px`;
} }
listen<ModelPayload[]>("model_writes", async (event) => { platform.listen<ModelPayload[]>("model_writes", (payloads) => {
for (const payload of event.payload) { for (const payload of payloads) {
if (payload.change.type !== "upsert") continue; if (payload.change.type !== "upsert") continue;
if (payload.model.model !== "settings") continue; if (payload.model.model !== "settings") continue;
setFontSizeOnDocument(payload.model.interfaceFontSize); setFontSizeOnDocument(payload.model.interfaceFontSize);
} }
}).catch(console.error); });
fireAndForget(getSettings().then((settings) => setFontSizeOnDocument(settings.interfaceFontSize))); fireAndForget(getSettings().then((settings) => setFontSizeOnDocument(settings.interfaceFontSize)));
+4 -4
View File
@@ -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,12 +12,12 @@ function setFonts(settings: Settings) {
); );
} }
listen<ModelPayload[]>("model_writes", async (event) => { platform.listen<ModelPayload[]>("model_writes", (payloads) => {
for (const payload of event.payload) { for (const payload of payloads) {
if (payload.change.type !== "upsert") continue; if (payload.change.type !== "upsert") continue;
if (payload.model.model !== "settings") continue; if (payload.model.model !== "settings") continue;
setFonts(payload.model); setFonts(payload.model);
} }
}).catch(console.error); });
fireAndForget(getSettings().then((settings) => setFonts(settings))); fireAndForget(getSettings().then((settings) => setFonts(settings)));
@@ -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;
}, },
}); });
+3 -3
View File
@@ -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 });
}
+7 -7
View File
@@ -1,10 +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 { 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";
@@ -25,7 +25,7 @@ export function useGrpc(
mutationKey: ["grpc_go", conn?.id], mutationKey: ["grpc_go", conn?.id],
mutationFn: async () => { mutationFn: async () => {
await flushAllModelWrites(); // The backend reads the request from the DB await flushAllModelWrites(); // The backend reads the request from the DB
return invokeCmd<void>("cmd_grpc_go", { return rpc<void>("cmd_grpc_go", {
requestId, requestId,
environmentId: environment?.id, environmentId: environment?.id,
protoFiles, protoFiles,
@@ -36,17 +36,17 @@ export function useGrpc(
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);
@@ -61,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 });
}, },
})), })),
); );
+23 -9
View File
@@ -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) => { )
.filter((a) => platform.capabilities.interfaceZoom || !ZOOM_ACTIONS.includes(a))
.sort((a, b) => {
const scopeA = a.split(".")[0] || ""; const scopeA = a.split(".")[0] || "";
const scopeB = b.split(".")[0] || ""; const scopeB = b.split(".")[0] || "";
if (scopeA !== scopeB) { if (scopeA !== scopeB) {
return scopeA.localeCompare(scopeB); return scopeA.localeCompare(scopeB);
} }
return hotkeyLabels[a].localeCompare(hotkeyLabels[b]); 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 });
}, },
})), })),
); );
+2 -2
View File
@@ -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),
), ),
-52
View File
@@ -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`,
});
},
});
}
+124 -11
View File
@@ -1,14 +1,16 @@
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { invoke } from "@tauri-apps/api/core";
import type { GraphQlIntrospection, HttpRequest } from "@yaakapp-internal/models"; import type { GraphQlIntrospection, HttpRequest } from "@yaakapp-internal/models";
import { platform } from "@yaakapp-internal/platform";
import type { GraphQLSchema, IntrospectionQuery } from "graphql"; import type { GraphQLSchema, IntrospectionQuery } from "graphql";
import { buildClientSchema, getIntrospectionQuery } from "graphql"; import { buildClientSchema, getIntrospectionQuery } from "graphql";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { tryBuildIntrospectionFromFile } from "../lib/graphqlSchema";
import { minPromiseMillis } from "../lib/minPromiseMillis"; import { minPromiseMillis } from "../lib/minPromiseMillis";
import { getResponseBodyText } from "../lib/responseBody";
import { sendEphemeralRequest } from "../lib/sendEphemeralRequest"; import { sendEphemeralRequest } from "../lib/sendEphemeralRequest";
import { useActiveEnvironment } from "./useActiveEnvironment"; import { useActiveEnvironment } from "./useActiveEnvironment";
import { useGraphQLSchemaFile } from "./useGraphQLSchemaFile";
import { useDebouncedValue } from "@yaakapp-internal/ui"; import { useDebouncedValue } from "@yaakapp-internal/ui";
import { rpc } from "../lib/rpc";
const introspectionRequestBody = JSON.stringify({ const introspectionRequestBody = JSON.stringify({
query: getIntrospectionQuery(), query: getIntrospectionQuery(),
@@ -30,9 +32,14 @@ export function useIntrospectGraphQL(
const introspection = useIntrospectionResult(baseRequest); const introspection = useIntrospectionResult(baseRequest);
// The schema's source. Outlives the introspection row it produces, so a
// request configured with a file keeps working after the row is swept.
const schemaFile = useGraphQLSchemaFile(baseRequest.id);
const filePath = schemaFile.value ?? null;
const upsertIntrospection = useCallback( const upsertIntrospection = useCallback(
async (content: string | null) => { async (content: string | null) => {
const v = await invoke<GraphQlIntrospection>("models_upsert_graphql_introspection", { const v = await rpc<GraphQlIntrospection>("models_upsert_graphql_introspection", {
requestId: baseRequest.id, requestId: baseRequest.id,
workspaceId: baseRequest.workspaceId, workspaceId: baseRequest.workspaceId,
content: content ?? "", content: content ?? "",
@@ -54,7 +61,7 @@ export function useIntrospectGraphQL(
bodyType: "application/json", bodyType: "application/json",
body: { text: introspectionRequestBody }, body: { text: introspectionRequestBody },
}; };
const response = await minPromiseMillis( const { response, body } = await minPromiseMillis(
sendEphemeralRequest(args, activeEnvironment?.id ?? null), sendEphemeralRequest(args, activeEnvironment?.id ?? null),
700, 700,
); );
@@ -63,14 +70,16 @@ export function useIntrospectGraphQL(
return setError(response.error); return setError(response.error);
} }
const bodyText = await getResponseBodyText({ response, filter: null }); // The send hands back the only copy of the body — an unsaved response has
// nothing on disk and no row to read it back from
const bodyText = new TextDecoder("utf-8").decode(new Uint8Array(body));
if (response.status < 200 || response.status >= 300) { if (response.status < 200 || response.status >= 300) {
return setError( return setError(
`Request failed with status ${response.status}.\nThe response text is:\n\n${bodyText}`, `Request failed with status ${response.status}.\nThe response text is:\n\n${bodyText}`,
); );
} }
if (bodyText === null) { if (bodyText === "") {
return setError("Empty body returned in response"); return setError("Empty body returned in response");
} }
@@ -90,15 +99,109 @@ export function useIntrospectGraphQL(
return; return;
} }
refetch().catch(console.error); // A request pointed at a file gets its schema from that file. Introspecting
}, [baseRequest.id, debouncedRequest.url, debouncedRequest.method, activeEnvironment?.id]); // here would overwrite it on the next URL edit.
if (filePath != null) {
return;
}
refetch().catch(console.error);
}, [
baseRequest.id,
debouncedRequest.url,
debouncedRequest.method,
activeEnvironment?.id,
filePath,
]);
// Clears the schema, not the source. Removing a file source is a separate
// action, because the source is what would rebuild this a moment later.
const clear = useCallback(async () => { const clear = useCallback(async () => {
setError(""); setError("");
setSchema(null); setSchema(null);
await upsertIntrospection(null); await upsertIntrospection(null);
}, [upsertIntrospection]); }, [upsertIntrospection]);
// Reads a schema file and produces an introspection row from it, the same way
// `refetch` produces one from a server. Does not touch the stored source.
const introspectFromFile = useCallback(
async (path: string): Promise<{ ok: true } | { ok: false; error: string }> => {
try {
setIsLoading(true);
setError(undefined);
const fileContent = await platform.files.readText(path);
const result = tryBuildIntrospectionFromFile(fileContent);
if ("error" in result) {
setError(result.error);
return { ok: false, error: result.error };
}
await upsertIntrospection(result.content);
return { ok: true };
} catch (err) {
// The host rejects with a bare string for a missing or unreadable path,
// so this can't assume an Error.
const message = err instanceof Error ? err.message : String(err);
setError(message);
return { ok: false, error: message };
} finally {
setIsLoading(false);
}
},
[upsertIntrospection],
);
// Points the request at a file and immediately builds its schema from it.
const loadFromFile = useCallback(
async (path: string) => {
const result = await introspectFromFile(path);
if (result.ok) await schemaFile.set(path);
return result;
},
[introspectFromFile, schemaFile],
);
const reloadFromFile = useCallback(async () => {
if (filePath == null) return { ok: false as const, error: "No schema file to reload" };
return introspectFromFile(filePath);
}, [filePath, introspectFromFile]);
// The file-source counterpart of automatic introspection: re-read the file
// when the request is opened, so an edited schema is picked up without asking.
//
// A missing row is repaired even with the setting off — that is recovering
// from the 7-day sweep, not keeping the schema fresh, and skipping it would
// make the schema disappear with no visible cause.
const reloadedFor = useRef<string | null>(null);
useEffect(() => {
if (filePath == null || introspection.isLoading) return;
// Only attempt once per path, so an unreadable file doesn't spin.
if (reloadedFor.current === filePath) return;
const hasContent = (introspection.data?.content ?? "") !== "";
if (hasContent && options.disabled) return;
reloadedFor.current = filePath;
introspectFromFile(filePath).catch(console.error);
}, [
filePath,
introspection.data?.content,
introspection.isLoading,
introspectFromFile,
options.disabled,
]);
// Stops using the file. The schema goes with it, since the file is what
// produced it; introspection repopulates if it's set to run automatically.
const removeSchemaFile = useCallback(async () => {
setError("");
setSchema(null);
await schemaFile.set(null);
await upsertIntrospection(null);
}, [schemaFile, upsertIntrospection]);
useEffect(() => { useEffect(() => {
if (introspection.data?.content == null || introspection.data.content === "") { if (introspection.data?.content == null || introspection.data.content === "") {
return; return;
@@ -112,14 +215,24 @@ export function useIntrospectGraphQL(
} }
}, [introspection.data?.content]); }, [introspection.data?.content]);
return { schema, isLoading, error, refetch, clear }; return {
schema,
isLoading,
error,
refetch,
clear,
loadFromFile,
reloadFromFile,
removeSchemaFile,
filePath,
};
} }
function useIntrospectionResult(request: HttpRequest) { function useIntrospectionResult(request: HttpRequest) {
return useQuery({ return useQuery({
queryKey: ["introspection", request.id], queryKey: ["introspection", request.id],
queryFn: async () => queryFn: async () =>
invoke<GraphQlIntrospection | null>("models_get_graphql_introspection", { rpc<GraphQlIntrospection | null>("models_get_graphql_introspection", {
requestId: request.id, requestId: request.id,
}), }),
}); });
@@ -1,28 +0,0 @@
import type { EventCallback, EventName } from "@tauri-apps/api/event";
import { listen } from "@tauri-apps/api/event";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { useEffect, useRef } from "react";
export function useListenToTauriEvent<T>(event: EventName, fn: EventCallback<T>) {
const handlerRef = useRef(fn);
useEffect(() => {
handlerRef.current = fn;
}, [fn]);
useEffect(() => {
return listenToTauriEvent<T>(event, (p) => handlerRef.current(p));
}, [event]);
}
export function listenToTauriEvent<T>(event: EventName, fn: EventCallback<T>) {
const unsubPromise = listen<T>(
event,
fn,
// Listen to `emit_all()` events or events specific to the current window
{ target: { label: getCurrentWebviewWindow().label, kind: "Window" } },
);
return () => {
unsubPromise.then((unsub) => unsub()).catch(console.error);
};
}
@@ -1,4 +1,3 @@
import { invoke } from "@tauri-apps/api/core";
import type { GrpcConnection, GrpcEvent } from "@yaakapp-internal/models"; import type { GrpcConnection, GrpcEvent } from "@yaakapp-internal/models";
import { import {
grpcConnectionsAtom, grpcConnectionsAtom,
@@ -11,6 +10,7 @@ import { useEffect, useMemo } from "react";
import { fireAndForget } from "../lib/fireAndForget"; import { fireAndForget } from "../lib/fireAndForget";
import { atomWithKVStorage } from "../lib/atoms/atomWithKVStorage"; import { atomWithKVStorage } from "../lib/atoms/atomWithKVStorage";
import { activeRequestIdAtom } from "./useActiveRequestId"; import { activeRequestIdAtom } from "./useActiveRequestId";
import { rpc } from "../lib/rpc";
const pinnedGrpcConnectionIdsAtom = atomWithKVStorage<Record<string, string | null>>( const pinnedGrpcConnectionIdsAtom = atomWithKVStorage<Record<string, string | null>>(
"pinned-grpc-connection-ids", "pinned-grpc-connection-ids",
@@ -71,7 +71,7 @@ export function useGrpcEvents(connectionId: string | null) {
// Fetch events from database, filtering out events from other connections and merging atomically // Fetch events from database, filtering out events from other connections and merging atomically
fireAndForget( fireAndForget(
invoke<GrpcEvent[]>("models_grpc_events", { connectionId }).then((events) => rpc<GrpcEvent[]>("models_grpc_events", { connectionId }).then((events) =>
mergeModelsInStore("grpc_event", events, (e) => e.connectionId === connectionId), mergeModelsInStore("grpc_event", events, (e) => e.connectionId === connectionId),
), ),
); );
@@ -1,4 +1,3 @@
import { invoke } from "@tauri-apps/api/core";
import type { WebsocketConnection, WebsocketEvent } from "@yaakapp-internal/models"; import type { WebsocketConnection, WebsocketEvent } from "@yaakapp-internal/models";
import { import {
mergeModelsInStore, mergeModelsInStore,
@@ -12,6 +11,7 @@ import { fireAndForget } from "../lib/fireAndForget";
import { atomWithKVStorage } from "../lib/atoms/atomWithKVStorage"; import { atomWithKVStorage } from "../lib/atoms/atomWithKVStorage";
import { jotaiStore } from "../lib/jotai"; import { jotaiStore } from "../lib/jotai";
import { activeRequestIdAtom } from "./useActiveRequestId"; import { activeRequestIdAtom } from "./useActiveRequestId";
import { rpc } from "../lib/rpc";
const pinnedWebsocketConnectionIdAtom = atomWithKVStorage<Record<string, string | null>>( const pinnedWebsocketConnectionIdAtom = atomWithKVStorage<Record<string, string | null>>(
"pinned-websocket-connection-ids", "pinned-websocket-connection-ids",
@@ -58,7 +58,7 @@ export function useWebsocketEvents(connectionId: string | null) {
// Fetch events from database, filtering out events from other connections and merging atomically // Fetch events from database, filtering out events from other connections and merging atomically
fireAndForget( fireAndForget(
invoke<WebsocketEvent[]>("models_websocket_events", { connectionId }).then((events) => rpc<WebsocketEvent[]>("models_websocket_events", { connectionId }).then((events) =>
mergeModelsInStore("websocket_event", events, (e) => e.connectionId === connectionId), mergeModelsInStore("websocket_event", events, (e) => e.connectionId === connectionId),
), ),
); );
@@ -0,0 +1,12 @@
import { platform } from "@yaakapp-internal/platform";
import { useEffect, useRef } from "react";
/** Subscribe to a backend event for the lifetime of the component. */
export function usePlatformEvent<T>(event: string, fn: (payload: T) => void) {
const handlerRef = useRef(fn);
useEffect(() => {
handlerRef.current = fn;
}, [fn]);
useEffect(() => platform.listen<T>(event, (payload) => handlerRef.current(payload)), [event]);
}
+2 -2
View File
@@ -4,7 +4,7 @@ import { pluginsAtom } from "@yaakapp-internal/models";
import type { PluginMetadata } from "@yaakapp-internal/plugins"; import type { PluginMetadata } from "@yaakapp-internal/plugins";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { queryClient } from "../lib/queryClient"; import { queryClient } from "../lib/queryClient";
import { invokeCmd } from "../lib/tauri"; import { rpc } from "../lib/rpc";
function pluginInfoKey(id: string | null, plugin: Plugin | null) { function pluginInfoKey(id: string | null, plugin: Plugin | null) {
return ["plugin_info", id ?? "n/a", plugin?.updatedAt ?? "n/a"]; return ["plugin_info", id ?? "n/a", plugin?.updatedAt ?? "n/a"];
@@ -19,7 +19,7 @@ export function usePluginInfo(id: string | null) {
placeholderData: (prev) => prev, // Keep previous data on refetch placeholderData: (prev) => prev, // Keep previous data on refetch
queryFn: () => { queryFn: () => {
if (id == null) return null; if (id == null) return null;
return invokeCmd<PluginMetadata>("cmd_plugin_info", { id }); return rpc<PluginMetadata>("cmd_plugin_info", { id });
}, },
}); });
} }
+2 -2
View File
@@ -3,7 +3,7 @@ import { changeModelStoreWorkspace, pluginsAtom } from "@yaakapp-internal/models
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
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 { activeWorkspaceIdAtom } from "./useActiveWorkspace"; import { activeWorkspaceIdAtom } from "./useActiveWorkspace";
import { useDebouncedValue } from "@yaakapp-internal/ui"; import { useDebouncedValue } from "@yaakapp-internal/ui";
import { invalidateAllPluginInfo } from "./usePluginInfo"; import { invalidateAllPluginInfo } from "./usePluginInfo";
@@ -26,7 +26,7 @@ export function useRefreshPlugins() {
mutationFn: async () => { mutationFn: async () => {
await minPromiseMillis( await minPromiseMillis(
(async () => { (async () => {
await invokeCmd("cmd_reload_plugins"); await rpc("cmd_reload_plugins");
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom); const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
await changeModelStoreWorkspace(workspaceId); // Force refresh models await changeModelStoreWorkspace(workspaceId); // Force refresh models
invalidateAllPluginInfo(); invalidateAllPluginInfo();
@@ -1,9 +1,15 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { Appearance } from "@yaakapp-internal/theme"; import type { Appearance } from "@yaakapp-internal/theme";
import { getCSSAppearance, subscribeToPreferredAppearance } from "@yaakapp-internal/theme"; import {
getCSSAppearance,
getSystemAppearance,
subscribeToPreferredAppearance,
} from "@yaakapp-internal/theme";
export function usePreferredAppearance() { export function usePreferredAppearance() {
const [preferredAppearance, setPreferredAppearance] = useState<Appearance>(getCSSAppearance()); const [preferredAppearance, setPreferredAppearance] = useState<Appearance>(
getSystemAppearance() ?? getCSSAppearance(),
);
useEffect(() => subscribeToPreferredAppearance(setPreferredAppearance), []); useEffect(() => subscribeToPreferredAppearance(setPreferredAppearance), []);
return preferredAppearance; return preferredAppearance;
} }
@@ -0,0 +1,67 @@
import { useCallback } from "react";
import { useKeyValue } from "./useKeyValue";
export interface RecentFilter {
value: string;
pinned?: boolean;
}
const MAX_RECENT_FILTERS = 20;
const kvKey = (filterStateKey: string) => `recent_filters::${filterStateKey}`;
const namespace = "global";
const fallback: RecentFilter[] = [];
export function useRecentFilters(filterStateKey: string | null) {
const { value, set } = useKeyValue<RecentFilter[]>({
key: kvKey(filterStateKey ?? "n/a"),
namespace,
fallback,
});
const addFilter = useCallback(
async (rawValue: string) => {
const value = rawValue.trim();
if (filterStateKey == null || value === "") return;
await set((prev) => {
// Returning the same reference skips the write, so re-committing the
// expression already at the top (on every blur) costs nothing
if (prev[0]?.value === value) return prev;
const existing = prev.find((f) => f.value === value);
const rest = prev.filter((f) => f.value !== value);
return trim([{ value, pinned: existing?.pinned }, ...rest]);
});
},
[filterStateKey, set],
);
const removeFilter = useCallback(
async (value: string) => set((prev) => prev.filter((f) => f.value !== value)),
[set],
);
const togglePin = useCallback(
async (value: string) =>
set((prev) => prev.map((f) => (f.value === value ? { ...f, pinned: !f.pinned } : f))),
[set],
);
const clearFilters = useCallback(async () => set([]), [set]);
return { recentFilters: value ?? fallback, addFilter, removeFilter, togglePin, clearFilters };
}
/** Bound the list, evicting the oldest unpinned entries before any pinned ones */
function trim(filters: RecentFilter[]): RecentFilter[] {
const excess = filters.length - MAX_RECENT_FILTERS;
if (excess <= 0) return filters;
const evicted = new Set<number>();
for (let i = filters.length - 1; i >= 0 && evicted.size < excess; i--) {
if (!filters[i]?.pinned) evicted.add(i);
}
for (let i = filters.length - 1; i >= 0 && evicted.size < excess; i--) {
evicted.add(i);
}
return filters.filter((_, i) => !evicted.has(i));
}
+3 -3
View File
@@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query";
import type { RenderPurpose } from "@yaakapp-internal/plugins"; import type { RenderPurpose } from "@yaakapp-internal/plugins";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { minPromiseMillis } from "../lib/minPromiseMillis"; import { minPromiseMillis } from "../lib/minPromiseMillis";
import { invokeCmd } from "../lib/tauri"; import { rpc } from "../lib/rpc";
import { useActiveEnvironment } from "./useActiveEnvironment"; import { useActiveEnvironment } from "./useActiveEnvironment";
import { activeWorkspaceIdAtom } from "./useActiveWorkspace"; import { activeWorkspaceIdAtom } from "./useActiveWorkspace";
@@ -49,7 +49,7 @@ export async function renderTemplate({
purpose: RenderPurpose; purpose: RenderPurpose;
ignoreError?: boolean; ignoreError?: boolean;
}): Promise<string> { }): Promise<string> {
return invokeCmd("cmd_render_template", { return rpc("cmd_render_template", {
template, template,
workspaceId, workspaceId,
environmentId, environmentId,
@@ -67,5 +67,5 @@ export async function decryptTemplate({
workspaceId: string; workspaceId: string;
environmentId: string | null; environmentId: string | null;
}): Promise<string> { }): Promise<string> {
return invokeCmd("cmd_decrypt_template", { template, workspaceId, environmentId }); return rpc("cmd_decrypt_template", { template, workspaceId, environmentId });
} }
@@ -1,4 +1,4 @@
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { platform } from "@yaakapp-internal/platform";
import type { ModelPayload } from "@yaakapp-internal/models"; import type { ModelPayload } from "@yaakapp-internal/models";
import { atom, useAtomValue } from "jotai"; import { atom, useAtomValue } from "jotai";
import { generateId } from "../lib/generateId"; import { generateId } from "../lib/generateId";
@@ -6,8 +6,7 @@ import { jotaiStore } from "../lib/jotai";
const requestUpdateKeyAtom = atom<Record<string, string>>({}); const requestUpdateKeyAtom = atom<Record<string, string>>({});
getCurrentWebviewWindow() platform.listen<ModelPayload[]>("model_writes", (payloads) => {
.listen<ModelPayload[]>("model_writes", ({ payload: payloads }) => {
const changedIds: string[] = []; const changedIds: string[] = [];
for (const payload of payloads) { for (const payload of payloads) {
if (payload.change.type !== "upsert") continue; if (payload.change.type !== "upsert") continue;
@@ -17,15 +16,14 @@ getCurrentWebviewWindow()
payload.model.model === "grpc_request" || payload.model.model === "grpc_request" ||
payload.model.model === "websocket_request") && payload.model.model === "websocket_request") &&
((payload.updateSource.type === "window" && ((payload.updateSource.type === "window" &&
payload.updateSource.label !== getCurrentWebviewWindow().label) || payload.updateSource.label !== platform.window.label) ||
payload.updateSource.type !== "window") payload.updateSource.type !== "window")
) { ) {
changedIds.push(payload.model.id); changedIds.push(payload.model.id);
} }
} }
if (changedIds.length > 0) wasUpdatedExternally(changedIds); if (changedIds.length > 0) wasUpdatedExternally(changedIds);
}) });
.catch(console.error);
export function wasUpdatedExternally(changedRequestIds: string | string[]) { export function wasUpdatedExternally(changedRequestIds: string | string[]) {
const ids = Array.isArray(changedRequestIds) ? changedRequestIds : [changedRequestIds]; const ids = Array.isArray(changedRequestIds) ? changedRequestIds : [changedRequestIds];
+20 -8
View File
@@ -2,6 +2,25 @@ import { useQuery } from "@tanstack/react-query";
import type { HttpResponse } from "@yaakapp-internal/models"; import type { HttpResponse } from "@yaakapp-internal/models";
import { getResponseBodyBytes, getResponseBodyText } from "../lib/responseBody"; import { getResponseBodyBytes, getResponseBodyText } from "../lib/responseBody";
export function responseBodyTextQuery({
response,
filter,
}: {
response: HttpResponse;
filter: string | null;
}) {
return {
queryKey: [
"response_body_text",
response.id,
response.updatedAt,
response.contentLength,
filter ?? "",
],
queryFn: () => getResponseBodyText({ response, filter }),
};
}
export function useResponseBodyText({ export function useResponseBodyText({
response, response,
filter, filter,
@@ -11,14 +30,7 @@ export function useResponseBodyText({
}) { }) {
return useQuery({ return useQuery({
placeholderData: (prev) => prev, // Keep previous data on refetch placeholderData: (prev) => prev, // Keep previous data on refetch
queryKey: [ ...responseBodyTextQuery({ response, filter }),
"response_body_text",
response.id,
response.updatedAt,
response.contentLength,
filter ?? "",
],
queryFn: () => getResponseBodyText({ response, filter }),
}); });
} }
@@ -0,0 +1,24 @@
import { useQuery } from "@tanstack/react-query";
import type { HttpResponse } from "@yaakapp-internal/models";
import { platform } from "@yaakapp-internal/platform";
/**
* A URL for a stored response body, for the viewers that hand one to an element
* instead of reading the bytes themselves.
*
* Resolved once here rather than inside each viewer: the host may have to ask
* the backend where the body is, and a viewer that computes its source during
* render (the PDF one, deliberately) needs it settled before it mounts.
*
* Null data means the response has no stored body.
*/
export function useResponseBodyUrl(response: HttpResponse | null) {
const responseId = response?.id ?? null;
return useQuery({
queryKey: ["response_body_url", responseId, response?.updatedAt ?? ""],
enabled: responseId != null,
// A response body is stored under the response's own id
queryFn: () => (responseId == null ? null : platform.blobs.url(responseId)),
});
}
+129
View File
@@ -0,0 +1,129 @@
import { useCallback, useState } from "react";
import { createGlobalState } from "react-use";
import type { RecentFilter } from "./useRecentFilters";
import { useRecentFilters } from "./useRecentFilters";
/** What's typed in the filter box. `null` means the filter box is closed */
const useFilterTextMap = createGlobalState<Record<string, string | null>>({});
/** What's actually applied to the response. Only changes on an explicit apply */
const useAppliedFilterMap = createGlobalState<Record<string, string | null>>({});
export interface ResponseFilterApi {
stateKey: string | null;
/** Draft text in the filter box, or `null` when the box is closed */
filterText: string | null;
/** The expression currently filtering the response */
appliedFilter: string | null;
isSearching: boolean;
/** The box holds an expression that isn't the one currently applied */
isDirty: boolean;
/** Bumped when the (uncontrolled) filter input must re-read its defaultValue */
filterUpdateKey: number;
setFilterText: (value: string | null) => void;
/** Apply the expression to the response, recording it if the filter accepts it */
applyFilter: (value: string) => void;
/** Like applyFilter, but also replaces what's shown in the filter box */
replaceFilter: (value: string) => void;
toggleSearch: () => void;
recentFilters: RecentFilter[];
removeRecentFilter: (value: string) => void;
togglePinRecentFilter: (value: string) => void;
clearRecentFilters: () => void;
}
/**
* Draft/applied state and history for a response filter (JSONPath/XPath).
*
* History records at most one entry per apply gesture, and only after `runFilter`
* confirms the plugin accepts the expression — the plugin is the sole judge of
* validity. Because nothing but the gesture ever writes, refetches can't resurrect
* deleted entries and a gesture can't record into another request's history.
*/
export function useResponseFilter({
stateKey,
runFilter,
}: {
stateKey: string | null;
/** Evaluate an expression, rejecting if the filter plugin reports an error */
runFilter: (filter: string) => Promise<unknown>;
}): ResponseFilterApi {
const [filterTextMap, setFilterTextMap] = useFilterTextMap();
const [appliedFilterMap, setAppliedFilterMap] = useAppliedFilterMap();
const filterText = stateKey ? (filterTextMap[stateKey] ?? null) : null;
const appliedFilter = stateKey ? (appliedFilterMap[stateKey] ?? null) : null;
const setFilterText = useCallback(
(v: string | null) => {
if (!stateKey) return;
setFilterTextMap((m) => ({ ...m, [stateKey]: v }));
},
[stateKey, setFilterTextMap],
);
const setAppliedFilter = useCallback(
(v: string | null) => {
if (!stateKey) return;
setAppliedFilterMap((m) => ({ ...m, [stateKey]: v }));
},
[stateKey, setAppliedFilterMap],
);
const {
recentFilters,
addFilter,
removeFilter: removeRecentFilter,
togglePin: togglePinRecentFilter,
clearFilters: clearRecentFilters,
} = useRecentFilters(stateKey);
const applyFilter = useCallback(
(value: string) => {
setFilterText(value);
const applied = value.trim() === "" ? null : value.trim();
setAppliedFilter(applied);
if (applied == null) return;
runFilter(applied).then(
() => addFilter(applied),
() => {}, // Rejected by the filter plugin — don't record
);
},
[setFilterText, setAppliedFilter, runFilter, addFilter],
);
const [filterUpdateKey, setFilterUpdateKey] = useState(0);
const replaceFilter = useCallback(
(value: string) => {
applyFilter(value);
setFilterUpdateKey((k) => k + 1);
},
[applyFilter],
);
const isSearching = filterText != null;
const toggleSearch = useCallback(() => {
if (isSearching) {
setFilterText(null);
setAppliedFilter(null);
} else {
setFilterText("");
}
}, [isSearching, setFilterText, setAppliedFilter]);
return {
stateKey,
filterText,
appliedFilter,
isSearching,
isDirty: filterText != null && filterText.trim() !== (appliedFilter ?? ""),
filterUpdateKey,
setFilterText,
applyFilter,
replaceFilter,
toggleSearch,
recentFilters,
removeRecentFilter,
togglePinRecentFilter,
clearRecentFilters,
};
}
+8 -4
View File
@@ -1,13 +1,13 @@
import { save } from "@tauri-apps/plugin-dialog";
import type { HttpResponse } from "@yaakapp-internal/models"; import type { HttpResponse } from "@yaakapp-internal/models";
import { getModel } from "@yaakapp-internal/models"; import { getModel } from "@yaakapp-internal/models";
import mime from "mime"; import mime from "mime";
import slugify from "slugify"; import slugify from "slugify";
import { InlineCode } from "@yaakapp-internal/ui"; import { InlineCode } from "@yaakapp-internal/ui";
import { getContentTypeFromHeaders } from "../lib/model_util"; import { getContentTypeFromHeaders } from "../lib/model_util";
import { invokeCmd } from "../lib/tauri"; import { rpc } from "../lib/rpc";
import { showToast } from "../lib/toast"; import { showToast } from "../lib/toast";
import { useFastMutation } from "./useFastMutation"; import { useFastMutation } from "./useFastMutation";
import { platform } from "@yaakapp-internal/platform";
export function useSaveResponse(response: HttpResponse | null) { export function useSaveResponse(response: HttpResponse | null) {
return useFastMutation({ return useFastMutation({
@@ -21,11 +21,15 @@ export function useSaveResponse(response: HttpResponse | null) {
const contentType = getContentTypeFromHeaders(response.headers) ?? "unknown"; const contentType = getContentTypeFromHeaders(response.headers) ?? "unknown";
const ext = mime.getExtension(contentType); const ext = mime.getExtension(contentType);
const slug = slugify(request.name || "response", { lower: true }); const slug = slugify(request.name || "response", { lower: true });
const filepath = await save({ const filepath = await platform.dialog.save({
defaultPath: ext ? `${slug}.${ext}` : slug, defaultPath: ext ? `${slug}.${ext}` : slug,
title: "Save Response", title: "Save Response",
}); });
await invokeCmd("cmd_save_response", { responseId: response.id, filepath }); if (filepath == null) {
return; // Cancelled
}
await rpc("cmd_save_response", { responseId: response.id, filepath });
showToast({ showToast({
message: ( message: (
<> <>
@@ -1,6 +1,6 @@
import type { HttpResponse } from "@yaakapp-internal/models"; import type { HttpResponse } from "@yaakapp-internal/models";
import { flushAllModelWrites } from "@yaakapp-internal/models"; import { flushAllModelWrites } from "@yaakapp-internal/models";
import { invokeCmd } from "../lib/tauri"; import { rpc } from "../lib/rpc";
import { getActiveCookieJar } from "./useActiveCookieJar"; import { getActiveCookieJar } from "./useActiveCookieJar";
import { getActiveEnvironment } from "./useActiveEnvironment"; import { getActiveEnvironment } from "./useActiveEnvironment";
import { createFastMutation, useFastMutation } from "./useFastMutation"; import { createFastMutation, useFastMutation } from "./useFastMutation";
@@ -12,7 +12,7 @@ async function sendAnyHttpRequestById(id: string | null): Promise<HttpResponse |
await flushAllModelWrites(); await flushAllModelWrites();
return invokeCmd("cmd_send_http_request", { return rpc("cmd_send_http_request", {
requestId: id, requestId: id,
environmentId: getActiveEnvironment()?.id, environmentId: getActiveEnvironment()?.id,
cookieJarId: getActiveCookieJar()?.id, cookieJarId: getActiveCookieJar()?.id,
@@ -1,8 +1,8 @@
import { type } from "@tauri-apps/plugin-os"; import { platform } from "@yaakapp-internal/platform";
import { useIsFullscreen } from "@yaakapp-internal/ui"; import { useIsFullscreen } from "@yaakapp-internal/ui";
export function useStoplightsVisible() { export function useStoplightsVisible() {
const fullscreen = useIsFullscreen(); const fullscreen = useIsFullscreen();
const stoplightsVisible = type() === "macos" && !fullscreen; const stoplightsVisible = platform.osType() === "macos" && !fullscreen;
return stoplightsVisible; return stoplightsVisible;
} }
@@ -1,4 +1,4 @@
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { platform } from "@yaakapp-internal/platform";
import { settingsAtom } from "@yaakapp-internal/models"; import { settingsAtom } from "@yaakapp-internal/models";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useEffect } from "react"; import { useEffect } from "react";
@@ -11,7 +11,7 @@ export function useSyncFontSizeSetting() {
} }
const { interfaceScale, editorFontSize } = settings; const { interfaceScale, editorFontSize } = settings;
getCurrentWebviewWindow().setZoom(interfaceScale).catch(console.error); platform.window.setZoom(interfaceScale).catch(console.error);
document.documentElement.style.setProperty("--editor-font-size", `${editorFontSize}px`); document.documentElement.style.setProperty("--editor-font-size", `${editorFontSize}px`);
}, [settings]); }, [settings]);
} }
+4 -4
View File
@@ -1,5 +1,5 @@
import { useHotKey } from "./useHotKey"; import { useHotKey } from "./useHotKey";
import { useListenToTauriEvent } from "./useListenToTauriEvent"; import { usePlatformEvent } from "./usePlatformEvent";
import { useZoom } from "./useZoom"; import { useZoom } from "./useZoom";
export function useSyncZoomSetting() { export function useSyncZoomSetting() {
@@ -8,9 +8,9 @@ export function useSyncZoomSetting() {
// shortcuts for Windows/Linux // shortcuts for Windows/Linux
const zoom = useZoom(); const zoom = useZoom();
useHotKey("app.zoom_in", zoom.zoomIn); useHotKey("app.zoom_in", zoom.zoomIn);
useListenToTauriEvent("zoom_in", zoom.zoomIn); usePlatformEvent("zoom_in", zoom.zoomIn);
useHotKey("app.zoom_out", zoom.zoomOut); useHotKey("app.zoom_out", zoom.zoomOut);
useListenToTauriEvent("zoom_out", zoom.zoomOut); usePlatformEvent("zoom_out", zoom.zoomOut);
useHotKey("app.zoom_reset", zoom.zoomReset); useHotKey("app.zoom_reset", zoom.zoomReset);
useListenToTauriEvent("zoom_reset", zoom.zoomReset); usePlatformEvent("zoom_reset", zoom.zoomReset);
} }
@@ -12,7 +12,7 @@ import {
import type { GetTemplateFunctionConfigResponse, JsonPrimitive } from "@yaakapp-internal/plugins"; import type { GetTemplateFunctionConfigResponse, JsonPrimitive } from "@yaakapp-internal/plugins";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { md5 } from "js-md5"; import { md5 } from "js-md5";
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";
@@ -63,7 +63,7 @@ export async function getTemplateFunctionConfig(
model: HttpRequest | GrpcRequest | WebsocketRequest | Folder | Workspace, model: HttpRequest | GrpcRequest | WebsocketRequest | Folder | Workspace,
environmentId: string | undefined, environmentId: string | undefined,
) { ) {
const config = await invokeCmd<GetTemplateFunctionConfigResponse>( const config = await rpc<GetTemplateFunctionConfigResponse>(
"cmd_template_function_config", "cmd_template_function_config",
{ {
functionName, functionName,
@@ -6,7 +6,7 @@ import type {
import { atom, useAtomValue, useSetAtom } from "jotai"; import { atom, useAtomValue, useSetAtom } from "jotai";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import type { TwigCompletionOption } from "../components/core/Editor/twig/completion"; import type { TwigCompletionOption } from "../components/core/Editor/twig/completion";
import { invokeCmd } from "../lib/tauri"; import { rpc } from "../lib/rpc";
import { usePluginsKey } from "./usePlugins"; import { usePluginsKey } from "./usePlugins";
const templateFunctionsAtom = atom<TemplateFunction[]>([]); const templateFunctionsAtom = atom<TemplateFunction[]>([]);
@@ -49,7 +49,7 @@ export function useSubscribeTemplateFunctions() {
refetchInterval: numFns > 0 ? Number.POSITIVE_INFINITY : 1000, refetchInterval: numFns > 0 ? Number.POSITIVE_INFINITY : 1000,
refetchOnMount: true, refetchOnMount: true,
queryFn: async () => { queryFn: async () => {
const result = await invokeCmd<GetTemplateFunctionSummaryResponse[]>( const result = await rpc<GetTemplateFunctionSummaryResponse[]>(
"cmd_template_function_summaries", "cmd_template_function_summaries",
); );
setNumFns(result.length); setNumFns(result.length);
@@ -1,6 +1,6 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import type { Tokens } from "@yaakapp-internal/templates"; import type { Tokens } from "@yaakapp-internal/templates";
import { invokeCmd } from "../lib/tauri"; import { rpc } from "../lib/rpc";
export function useTemplateTokensToString(tokens: Tokens) { export function useTemplateTokensToString(tokens: Tokens) {
return useQuery<string>({ return useQuery<string>({
@@ -11,5 +11,5 @@ export function useTemplateTokensToString(tokens: Tokens) {
} }
export async function templateTokensToString(tokens: Tokens): Promise<string> { export async function templateTokensToString(tokens: Tokens): Promise<string> {
return invokeCmd("cmd_template_tokens_to_string", { tokens }); return rpc("cmd_template_tokens_to_string", { tokens });
} }
@@ -6,7 +6,7 @@ import type {
WebsocketRequestAction, WebsocketRequestAction,
} 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 CallableWebSocketRequestAction = Pick<WebsocketRequestAction, "label" | "icon"> & { export type CallableWebSocketRequestAction = Pick<WebsocketRequestAction, "label" | "icon"> & {
@@ -30,7 +30,7 @@ export function useWebsocketRequestActions() {
} }
export async function getWebsocketRequestActions() { export async function getWebsocketRequestActions() {
const responses = await invokeCmd<GetWebsocketRequestActionsResponse[]>( const responses = await rpc<GetWebsocketRequestActionsResponse[]>(
"cmd_websocket_request_actions", "cmd_websocket_request_actions",
); );
const actions = responses.flatMap((r) => const actions = responses.flatMap((r) =>
@@ -43,7 +43,7 @@ export async function getWebsocketRequestActions() {
pluginRefId: r.pluginRefId, pluginRefId: r.pluginRefId,
args: { websocketRequest }, args: { websocketRequest },
}; };
await invokeCmd("cmd_call_websocket_request_action", { req: payload }); await rpc("cmd_call_websocket_request_action", { req: payload });
}, },
})), })),
); );
+2 -9
View File
@@ -1,18 +1,11 @@
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { platform } from "@yaakapp-internal/platform";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { fireAndForget } from "../lib/fireAndForget";
export function useWindowFocus() { export function useWindowFocus() {
const [visible, setVisible] = useState(true); const [visible, setVisible] = useState(true);
useEffect(() => { useEffect(() => {
const unlisten = getCurrentWebviewWindow().onFocusChanged((e) => { return platform.window.onFocusChanged(setVisible);
setVisible(e.payload);
});
return () => {
fireAndForget(unlisten.then((fn) => fn()));
};
}, []); }, []);
return visible; return visible;
@@ -6,7 +6,7 @@ import type {
WorkspaceAction, WorkspaceAction,
} 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 CallableWorkspaceAction = Pick<WorkspaceAction, "label" | "icon"> & { export type CallableWorkspaceAction = Pick<WorkspaceAction, "label" | "icon"> & {
@@ -30,7 +30,7 @@ export function useWorkspaceActions() {
} }
export async function getWorkspaceActions() { export async function getWorkspaceActions() {
const responses = await invokeCmd<GetWorkspaceActionsResponse[]>("cmd_workspace_actions"); const responses = await rpc<GetWorkspaceActionsResponse[]>("cmd_workspace_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 getWorkspaceActions() {
pluginRefId: r.pluginRefId, pluginRefId: r.pluginRefId,
args: { workspace }, args: { workspace },
}; };
await invokeCmd("cmd_call_workspace_action", { req: payload }); await rpc("cmd_call_workspace_action", { req: payload });
}, },
})), })),
); );
+3 -3
View File
@@ -3,7 +3,7 @@ import type { AnyModel, ModelPayload } from "@yaakapp-internal/models";
import { watchWorkspaceFiles } from "@yaakapp-internal/sync"; import { watchWorkspaceFiles } from "@yaakapp-internal/sync";
import { syncWorkspace } from "../commands/commands"; import { syncWorkspace } from "../commands/commands";
import { activeWorkspaceIdAtom, activeWorkspaceMetaAtom } from "../hooks/useActiveWorkspace"; import { activeWorkspaceIdAtom, activeWorkspaceMetaAtom } from "../hooks/useActiveWorkspace";
import { listenToTauriEvent } from "../hooks/useListenToTauriEvent"; import { platform } from "@yaakapp-internal/platform";
import { jotaiStore } from "../lib/jotai"; import { jotaiStore } from "../lib/jotai";
export function initSync() { export function initSync() {
@@ -33,8 +33,8 @@ const syncAfterModelWrite = eagerDebounceAsync(sync, 1000);
* simply add long-lived subscribers for the lifetime of the app. * simply add long-lived subscribers for the lifetime of the app.
*/ */
function initModelListeners() { function initModelListeners() {
listenToTauriEvent<ModelPayload[]>("model_writes", (p) => { platform.listen<ModelPayload[]>("model_writes", (payloads) => {
if (p.payload.some((payload) => isModelRelevant(payload.model))) syncAfterModelWrite(); if (payloads.some((payload) => isModelRelevant(payload.model))) syncAfterModelWrite();
}); });
} }

Some files were not shown because too many files have changed in this diff Show More