Compare commits

...
Author SHA1 Message Date
Gregory SchierandClaude Opus 5 64b9479deb fix(import): treat a pair's row ID as identity, not content
Headers, URL parameters, and environment variables each carry an `id`
that identifies the row to the pair editor. Importers leave it empty and
the editor stamps one in the first time it touches a resource, so merely
opening an imported request made the next import report it as locally
edited. `comparable` now drops `id` at every level rather than only the
top, since a row ID says no more about content than the model's own ID.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 11:31:12 -07:00
Gregory SchierandClaude Opus 5 6796569466 feat(import): say which fields differ, and stop offering to apply nothing
A row marked "edited" or "conflict" gave no way to tell what actually
differs, so a resource that drifted from the file — however it drifted —
reads as an unexplained accusation. The plan now carries the field names
the source and the local copy disagree on, and the row's tooltip lists
them.

The preview's primary button also read "Apply" when the current selection
would change nothing. It now reads "Done", since committing an empty
selection only records the decisions made in the preview.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 11:24:25 -07:00
Gregory SchierandClaude Opus 5 72d3bda769 fix(import): shrink the conflict control and fold its help inside it
The conflict row's segmented control ran at the default size with the help
icon floating outside the group. Drop it to the smallest button size and
hand the help to the control, which shows it in its last option whenever
the label is hidden — the only case where the label has nowhere to put it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 11:13:19 -07:00
Gregory SchierandClaude Opus 5 bd932ce85f feat(import): remember the user's import selection
Yaak now remembers, per item of a linked import source, whether the user
wants it. A mapping row that still points at a model means wanted, so
re-imports merge into it; a row with no model behind it means not wanted,
so re-imports leave it alone. Wanted-ness is structural rather than a flag:
deleting the model locally, or turning the item down in the preview, is
what makes it not wanted.

- import_source_resources drops `snapshot` for a nullable `content_hash`
  and lets `model_id` be NULL. The migration keeps every beta row's
  key to model mapping and starts hashes empty; until a hash is recorded,
  a difference can't be attributed to either side, so the item is offered
  once as a conflict that keeps local changes.
- comparable() ignores sortPriority. Importers number it from source
  order, so inserting one operation used to plan a fake update for
  everything after it. The trade is that pure reorders don't propagate.
- Keys the user turned down come back as unchecked "not imported" rows
  instead of being re-offered as new resources or resurrected. Checking
  one imports it under the same source key and pulls in the folders it
  needs.
- An imported resource the source moves into a folder that isn't imported
  is offered as a deletion, with a tooltip pointing at the folder.
- Plan-time source resolution binds by source-key overlap instead of by
  path, so a renamed or moved file merges into what it created and heals
  the stored origin. Several sources sharing keys never guess-merge: the
  plan says so and imports everything as new.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 10:56:34 -07:00
Gregory Schier d2d4b80a09 feat(import): merge re-imports from a linked source instead of duplicating (#618) 2026-09-01 11:12:07 -07:00
Gregory Schier d461c982ec Don't block window creation on plugin runtime boot (#616) 2026-09-01 09:14:50 -07:00
Gregory SchierandClaude Fable 5 8eebee460e fix(pair-editor): remount the editor when its stateKey changes (#617)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 08:58:55 -07:00
Gregory SchierandClaude Opus 5 81a2a5d955 feat(import): add stable per-resource source keys to the importer contract (#614)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 08:11:54 -07:00
Gregory SchierandClaude Opus 5 e63a87718c fix(tooling): keep the formatter off generated files in every entry point (#615)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 07:40:42 -07:00
Gregory Schier 222ea9cb83 fix(editor): opt editors out of macOS Writing Tools suggestions
A "Write with Siri" pill appears over empty editors on the macOS 27 beta,
because Codemirror's contentDOM is a contenteditable like any other. The
`writingsuggestions` attribute opts an editable element out, and setting
it here covers every editor at once.

If the pill turns out to be separate from writing suggestions, the
remaining lever is WKWebView's writingToolsBehavior, which would disable
Writing Tools for the whole window.
2026-08-31 10:55:05 -07:00
Gregory Schier 360c098b09 fix(grpc): remove the dead Refresh item from the method picker
It has never had an onSelect. Introduced inert in #193 and carried
through the codebase split, so clicking it only ever closed the dropdown.
Reload Schema in the message editor's schema dropdown does the job it
looked like it was for.
2026-08-31 10:54:40 -07:00
Gregory Schier fce1039bac feat(grpc): generate an example message from the method schema (#613) 2026-08-31 10:44:31 -07:00
Gregory Schier f18f93d613 Stage imports before committing (#571) 2026-08-31 10:20:44 -07:00
Gregory SchierandClaude Opus 5 661a384bed Faster codemirror search match counting (#612)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 10:09:50 -07:00
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
Gregory Schier d0af512a0c Fix UI freezes with large workspaces (#536) 2026-08-14 10:15:32 -07:00
Gregory Schier e032c4c8d6 Add a Yaak CLI skill for coding agents (#535) 2026-08-14 06:16:30 -07:00
Carlos SimonandClaude Sonnet 4.6 c3fc3fb68a Fix Wayland crash on Nvidia by disabling explicit sync (#474)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-13 18:41:50 -07:00
429 changed files with 39298 additions and 8140 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
- name: Run Rust Tests
run: cargo test --all --features yaak-app-client/wry
- name: OpenAPI import round-trip
run: |
cargo build -p yaak-cli
node plugins/importer-openapi/tests/roundtrip.mjs
+13 -1
View File
@@ -70,7 +70,7 @@ jobs:
runtime: "wry"
targets: "aarch64-pc-windows-msvc"
runs-on: ${{ matrix.platform }}
timeout-minutes: 40
timeout-minutes: 60
steps:
- name: Checkout yaakapp/app
uses: actions/checkout@v4
@@ -103,6 +103,18 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install -y cmake ninja-build libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libnss3 patchelf xdg-utils
# crates/yaak-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
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 }}
-3
View File
@@ -1,3 +0,0 @@
**/bindings/**
**/routeTree.gen.ts
crates/yaak-templates/pkg/**
Generated
+452 -78
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -2,9 +2,11 @@
resolver = "2"
members = [
"crates/yaak",
"crates/yaak-commands",
# Common/foundation crates
"crates/common/yaak-database",
"crates/common/yaak-rpc",
"crates/common/yaak-rpc-schema",
# Shared crates (no Tauri dependency)
"crates/yaak-core",
"crates/yaak-common",
@@ -12,6 +14,7 @@ members = [
"crates/yaak-git",
"crates/yaak-grpc",
"crates/yaak-http",
"crates/yaak-lifecycle",
"crates/yaak-models",
"crates/yaak-plugins",
"crates/yaak-sse",
@@ -19,10 +22,13 @@ members = [
"crates/yaak-templates",
"crates/yaak-tls",
"crates/yaak-ws",
"crates/yaak-wasm",
"crates/yaak-api",
"crates/yaak-proxy",
# Proxy-specific crates
"crates-proxy/yaak-proxy-lib",
# Server crates (the browser tier's hosted send executor)
"crates-server/yaak-web",
# CLI crates
"crates-cli/yaak-cli",
# Tauri-specific crates
@@ -63,15 +69,18 @@ ts-rs = "11.1.0"
# Internal crates - common/foundation
yaak-database = { path = "crates/common/yaak-database" }
yaak-rpc = { path = "crates/common/yaak-rpc" }
yaak-rpc-schema = { path = "crates/common/yaak-rpc-schema" }
# Internal crates - shared
yaak-core = { path = "crates/yaak-core" }
yaak = { path = "crates/yaak" }
yaak-commands = { path = "crates/yaak-commands" }
yaak-common = { path = "crates/yaak-common" }
yaak-crypto = { path = "crates/yaak-crypto" }
yaak-git = { path = "crates/yaak-git" }
yaak-grpc = { path = "crates/yaak-grpc" }
yaak-http = { path = "crates/yaak-http" }
yaak-lifecycle = { path = "crates/yaak-lifecycle" }
yaak-models = { path = "crates/yaak-models" }
yaak-plugins = { path = "crates/yaak-plugins" }
yaak-sse = { path = "crates/yaak-sse" }
+19
View File
@@ -44,6 +44,25 @@ After bootstrapping, start the app in development mode:
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
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 { createFastMutation } from "../hooks/useFastMutation";
import { showDialog } from "../lib/dialog";
import { jotaiStore } from "../lib/jotai";
import { router } from "../lib/router";
import { invokeCmd } from "../lib/tauri";
import { rpc } from "../lib/rpc";
// Allow tab with optional subtab (e.g., "plugins:installed")
type SettingsTabWithSubtab = SettingsTab | `${SettingsTab}:${string}` | null;
export const openSettings = createFastMutation<void, string, SettingsTabWithSubtab>({
export const openSettings = createFastMutation<void, string, SettingsTabWithSubtab | null>({
mutationKey: ["open_settings"],
mutationFn: async (tab) => {
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
if (workspaceId == null) return;
// 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({
to: "/workspaces/$workspaceId/settings",
params: { workspaceId },
search: { tab: (tab ?? undefined) as SettingsTab | undefined },
});
await invokeCmd("cmd_new_child_window", {
await rpc("cmd_new_child_window", {
url: location.href,
label: "settings",
title: "Yaak Settings",
@@ -1,9 +1,10 @@
import { platform } from "@yaakapp-internal/platform";
import { createFastMutation } from "../hooks/useFastMutation";
import { getRecentCookieJars } from "../hooks/useRecentCookieJars";
import { getRecentEnvironments } from "../hooks/useRecentEnvironments";
import { getRecentRequests } from "../hooks/useRecentRequests";
import { router } from "../lib/router";
import { invokeCmd } from "../lib/tauri";
import { rpc } from "../lib/rpc";
export const switchWorkspace = createFastMutation<
void,
@@ -24,13 +25,15 @@ export const switchWorkspace = createFastMutation<
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({
to: "/workspaces/$workspaceId",
params: { workspaceId },
search,
});
await invokeCmd<void>("cmd_new_main_window", { url: location.href });
await rpc<void>("cmd_new_main_window", { url: location.href });
return;
}
@@ -1,4 +1,3 @@
import { open } from "@tauri-apps/plugin-dialog";
import { gitClone } from "@yaakapp-internal/git";
import { Banner, VStack } from "@yaakapp-internal/ui";
import { useState } from "react";
@@ -11,6 +10,7 @@ import { Checkbox } from "./core/Checkbox";
import { IconButton } from "./core/IconButton";
import { PlainInput } from "./core/PlainInput";
import { promptCredentials } from "./git/credentials";
import { platform } from "@yaakapp-internal/platform";
interface Props {
hide: () => void;
@@ -38,7 +38,7 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
hasSubdirectory && subdirectory ? `${directory}${sep}${subdirectory}` : directory;
const handleSelectDirectory = async () => {
const dir = await open({
const dir = await platform.dialog.open({
title: "Select Directory",
directory: true,
multiple: false,
@@ -1,11 +1,10 @@
import { invoke } from "@tauri-apps/api/core";
import { openUrl } from "@tauri-apps/plugin-opener";
import type { LicenseCheckStatus } from "@yaakapp-internal/license";
import { checkLicense } from "@yaakapp-internal/license";
import { useCallback, useEffect, useRef, useState } from "react";
import { useKeyValue } from "../hooks/useKeyValue";
import { appInfo } from "../lib/appInfo";
import { pricingUrl } from "../lib/pricingUrl";
import { DismissibleBanner } from "./core/DismissibleBanner";
import { platform } from "@yaakapp-internal/platform";
const COMMERCIAL_USE_SNOOZE_MS = 7 * 24 * 60 * 60 * 1000;
const COMMERCIAL_USE_BANNER_MESSAGE =
@@ -95,7 +94,7 @@ async function shouldShowCommercialUsePrompt(): Promise<boolean> {
}
try {
const license = await invoke<LicenseCheckStatus>("plugin:yaak-license|check");
const license = await checkLicense();
return license.status === "personal_use";
} catch (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> {
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 {
@@ -5,7 +5,7 @@ import { VStack } from "@yaakapp-internal/ui";
import { useState } from "react";
import { router } from "../lib/router";
import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
import { invokeCmd } from "../lib/tauri";
import { rpc } from "../lib/rpc";
import { showErrorToast } from "../lib/toast";
import { Button } from "./core/Button";
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
// 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,
});
await updateModel({
@@ -67,7 +67,14 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo
)}
// If no environments, the button simply opens the dialog.
// NOTE: We don't create a new button because we want to reuse the hotkey from the menu items
onClick={subEnvironments.length === 0 ? () => editEnvironment(null) : undefined}
onClick={
subEnvironments.length === 0
? (event) => {
event.preventDefault();
editEnvironment(null);
}
: undefined
}
{...buttonProps}
>
<EnvironmentColorIndicator environment={activeEnvironment ?? null} />
@@ -1,4 +1,3 @@
import { save } from "@tauri-apps/plugin-dialog";
import type { Workspace } from "@yaakapp-internal/models";
import { workspacesAtom } from "@yaakapp-internal/models";
import { HStack, VStack } from "@yaakapp-internal/ui";
@@ -7,12 +6,13 @@ import { useCallback, useMemo, useState } from "react";
import slugify from "slugify";
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
import { pluralizeCount } from "../lib/pluralize";
import { invokeCmd } from "../lib/tauri";
import { rpc } from "../lib/rpc";
import { CommercialUseBanner } from "./CommercialUseBanner";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { DetailsBanner } from "./core/DetailsBanner";
import { Link } from "./core/Link";
import { platform } from "@yaakapp-internal/platform";
interface Props {
onHide: () => void;
@@ -65,7 +65,7 @@ function ExportDataDialogContent({
const ids = Object.keys(selectedWorkspaces).filter((k) => selectedWorkspaces[k]);
const workspace = ids.length === 1 ? workspaces.find((w) => w.id === ids[0]) : undefined;
const slug = workspace ? slugify(workspace.name, { lower: true }) : "workspaces";
const exportPath = await save({
const exportPath = await platform.dialog.save({
title: "Export Data",
defaultPath: `yaak.${slug}.json`,
});
@@ -73,7 +73,7 @@ function ExportDataDialogContent({
return;
}
await invokeCmd("cmd_export_data", {
await rpc("cmd_export_data", {
workspaceIds: ids,
exportPath,
includePrivateEnvironments: includePrivateEnvironments,
@@ -2,7 +2,7 @@ import { HStack, VStack } from "@yaakapp-internal/ui";
import { useRef, useState } from "react";
import type { FeedbackFeature } 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 { Button } from "./core/Button";
import { Input } from "./core/Input";
@@ -31,7 +31,7 @@ export function FeedbackToast({ feature, onDone }: Props) {
onDone();
// Fire-and-forget; failures are intentionally ignored
invokeCmd("cmd_send_feedback", { feature, text: trimmedText }).catch(() => {});
rpc("cmd_send_feedback", { feature, text: trimmedText }).catch(() => {});
showToast({
id: `feature-feedback-${feature}`,
timeout: 3000,
+180 -63
View File
@@ -1,9 +1,8 @@
import { linter } from "@codemirror/lint";
import type { EditorView } from "@codemirror/view";
import { jsoncLanguage } from "@shopify/lang-jsonc";
import type { GrpcRequest } from "@yaakapp-internal/models";
import { FormattedError, InlineCode, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { type GrpcRequest, patchModel } from "@yaakapp-internal/models";
import { Banner, FormattedError, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import {
handleRefresh,
jsonCompletion,
@@ -11,12 +10,20 @@ import {
stateExtensions,
updateSchema,
} from "codemirror-json-schema";
import type { JSONSchema7 } from "json-schema";
import type { ReactNode } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ReflectResponseService } from "../hooks/useGrpc";
import { wasUpdatedExternally } from "../hooks/useRequestUpdateKey";
import { showAlert } from "../lib/alert";
import { showConfirm } from "../lib/confirm";
import { showDialog } from "../lib/dialog";
import type { JsonSchema } from "../lib/jsonSchemaExample";
import { buildExampleFromSchema } from "../lib/jsonSchemaExample";
import { pluralizeCount } from "../lib/pluralize";
import { queryClient } from "../lib/queryClient";
import { Button } from "./core/Button";
import { Dropdown } from "./core/Dropdown";
import type { EditorProps } from "./core/Editor/Editor";
import { Editor } from "./core/Editor/LazyEditor";
import { GrpcProtoSelectionDialog } from "./GrpcProtoSelectionDialog";
@@ -29,6 +36,11 @@ type Props = Pick<EditorProps, "heightMode" | "onChange" | "className" | "forceU
protoFiles: string[];
};
type MethodSchema =
| { type: "none" }
| { type: "schema"; schema: JsonSchema }
| { type: "error"; id: string; title: string; body: ReactNode; log: unknown[] };
export function GrpcEditor({
services,
reflectionError,
@@ -42,21 +54,16 @@ export function GrpcEditor({
setEditorView(h);
}, []);
// Find the schema for the selected service and method and update the editor
useEffect(() => {
if (
editorView == null ||
services === null ||
request.service === null ||
request.method === null
) {
return;
// Find the schema for the selected service and method
const methodSchema = useMemo<MethodSchema>(() => {
if (services === null || request.service === null || request.method === null) {
return { type: "none" };
}
const s = services.find((s) => s.name === request.service);
if (s == null) {
console.log("Failed to find service", { service: request.service, services });
showAlert({
return {
type: "error",
id: "grpc-find-service-error",
title: "Couldn't Find Service",
body: (
@@ -64,14 +71,14 @@ export function GrpcEditor({
Failed to find service <InlineCode>{request.service}</InlineCode> in schema
</>
),
});
return;
log: ["Failed to find service", { service: request.service, services }],
};
}
const schema = s.methods.find((m) => m.name === request.method)?.schema;
if (request.method != null && schema == null) {
console.log("Failed to find method", { method: request.method, methods: s?.methods });
showAlert({
if (schema == null) {
return {
type: "error",
id: "grpc-find-schema-error",
title: "Couldn't Find Method",
body: (
@@ -80,18 +87,15 @@ export function GrpcEditor({
<InlineCode>{request.service}</InlineCode> in schema
</>
),
});
return;
}
if (schema == null) {
return;
log: ["Failed to find method", { method: request.method, methods: s.methods }],
};
}
try {
updateSchema(editorView, JSON.parse(schema));
return { type: "schema", schema: JSON.parse(schema) as JsonSchema };
} catch (err) {
showAlert({
return {
type: "error",
id: "grpc-parse-schema-error",
title: "Failed to Parse Schema",
body: (
@@ -103,9 +107,22 @@ export function GrpcEditor({
<FormattedError>{String(err)}</FormattedError>
</VStack>
),
});
log: ["Failed to parse schema", err],
};
}
}, [editorView, services, request.method, request.service]);
}, [services, request.method, request.service]);
useEffect(() => {
if (methodSchema.type !== "error") return;
console.log(...methodSchema.log);
showAlert({ id: methodSchema.id, title: methodSchema.title, body: methodSchema.body });
}, [methodSchema]);
// Update the editor whenever the schema changes
useEffect(() => {
if (editorView == null || methodSchema.type !== "schema") return;
updateSchema(editorView, methodSchema.schema as JSONSchema7);
}, [editorView, methodSchema]);
const extraExtensions = useMemo(
() => [
@@ -124,45 +141,145 @@ export function GrpcEditor({
const reflectionUnavailable = reflectionError?.match(/unimplemented/i);
reflectionError = reflectionUnavailable ? undefined : reflectionError;
const handleGenerateExample = useCallback(async () => {
if (methodSchema.type !== "schema") return;
if (request.message.trim() !== "") {
const confirmed = await showConfirm({
id: "grpc-generate-example",
title: "Generate Example",
description: "The current message will be replaced with an example.",
confirmText: "Generate",
});
if (!confirmed) return;
}
const message = JSON.stringify(buildExampleFromSchema(methodSchema.schema), null, 2);
await patchModel(request, { message });
// Force the editor to pick up the new message
wasUpdatedExternally(request.id);
}, [methodSchema, request]);
// The reflect query is keyed by request, url and proto files, so a prefix invalidate
// reaches it without threading a refetch down from the connection layout.
const handleReloadSchema = useCallback(
() => queryClient.invalidateQueries({ queryKey: ["grpc_reflect", request.id] }),
[request.id],
);
const handleShowReflectionError = useCallback(() => {
showDialog({
id: "grpc-reflection-error",
title: "Reflection Failed",
size: "sm",
render: ({ hide }) => (
<>
<FormattedError>{reflectionError ?? "unknown"}</FormattedError>
<div className="w-full my-4">
<Button
className="ml-auto"
color="primary"
size="sm"
onClick={async () => {
hide();
await handleReloadSchema();
}}
>
Retry
</Button>
</div>
</>
),
});
}, [handleReloadSchema, reflectionError]);
const actions = useMemo(
() => [
<div key="reflection" className={classNames(services == null && "opacity-100!")}>
<Button
size="xs"
color={
reflectionLoading
? "secondary"
: reflectionUnavailable
? "info"
: reflectionError
? "danger"
: "secondary"
}
isLoading={reflectionLoading}
onClick={() => {
showDialog({
title: "Configure Schema",
size: "md",
id: "reflection-failed",
render: ({ hide }) => <GrpcProtoSelectionDialog onDone={hide} />,
});
}}
// Matches the GraphQL editor: one always-visible control labelled by schema state,
// with everything schema-related behind it.
<div key="schema" className="opacity-100!">
<Dropdown
items={[
{
// Hidden for servers without reflection, which isn't an error
hidden: !reflectionError,
type: "content",
label: (
<Banner color="danger">
<p className="mb-1">Reflection failed</p>
<Button
size="xs"
color="danger"
variant="border"
onClick={handleShowReflectionError}
>
View Error
</Button>
</Banner>
),
},
{
label: "Generate Example Message",
leftSlot: <Icon icon="magic_wand" />,
disabled: methodSchema.type !== "schema",
onSelect: handleGenerateExample,
},
{ type: "separator" },
{
label: "Reload Schema",
leftSlot: <Icon icon="refresh" spin={reflectionLoading} />,
keepOpenOnSelect: true,
onSelect: handleReloadSchema,
},
{
label: protoFiles.length > 0 ? "Select Proto Files\u2026" : "Configure Schema\u2026",
leftSlot: <Icon icon="settings" />,
onSelect: () => {
showDialog({
title: "Configure Schema",
size: "md",
id: "grpc-configure-schema",
render: ({ hide }) => <GrpcProtoSelectionDialog onDone={hide} />,
});
},
},
]}
>
{reflectionLoading
? "Inspecting Schema"
: reflectionUnavailable
? "Select Proto Files"
: reflectionError
? "Server Error"
: protoFiles.length > 0
? pluralizeCount("File", protoFiles.length)
: services != null && protoFiles.length === 0
? "Schema Detected"
: "Select Schema"}
</Button>
<Button
size="sm"
variant="border"
title="Schema"
forDropdown
isLoading={reflectionLoading}
color={reflectionUnavailable ? "info" : reflectionError ? "danger" : "default"}
>
{reflectionLoading
? "Inspecting Schema"
: reflectionUnavailable
? "Select Proto Files"
: reflectionError
? "Server Error"
: protoFiles.length > 0
? pluralizeCount("File", protoFiles.length)
: services != null
? "Schema Detected"
: "Select Schema"}
</Button>
</Dropdown>
</div>,
],
[protoFiles.length, reflectionError, reflectionLoading, reflectionUnavailable, services],
[
handleGenerateExample,
handleReloadSchema,
handleShowReflectionError,
methodSchema.type,
protoFiles.length,
reflectionError,
reflectionLoading,
reflectionUnavailable,
services,
],
);
return (
@@ -1,4 +1,3 @@
import { open } from "@tauri-apps/plugin-dialog";
import type { GrpcRequest } from "@yaakapp-internal/models";
import { Banner, HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import { useActiveRequest } from "../hooks/useActiveRequest";
@@ -8,6 +7,7 @@ import { pluralizeCount } from "../lib/pluralize";
import { Button } from "./core/Button";
import { IconButton } from "./core/IconButton";
import { Link } from "./core/Link";
import { platform } from "@yaakapp-internal/platform";
interface Props {
onDone: () => void;
@@ -45,7 +45,7 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
color="primary"
variant="border"
onClick={async () => {
const selected = await open({
const selected = await platform.dialog.open({
title: "Select Proto Files",
multiple: true,
filters: [{ name: "Proto Files", extensions: ["proto"] }],
@@ -63,7 +63,7 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
variant="border"
color="primary"
onClick={async () => {
const selected = await open({
const selected = await platform.dialog.open({
title: "Select Proto Directory",
directory: true,
});
@@ -194,13 +194,6 @@ export function GrpcRequestPane({
type: "default",
shortLabel: o.label,
}))}
itemsAfter={[
{
label: "Refresh",
type: "default",
leftSlot: <Icon size="sm" icon="refresh" />,
},
]}
>
<Button
size="sm"
@@ -5,11 +5,11 @@ import classNames from "classnames";
import { atom, useAtomValue } from "jotai";
import type { CSSProperties } from "react";
import { lazy, Suspense, useCallback, useMemo, useRef, useState } from "react";
import { importCurl, looksLikeCurl } from "../commands/importCurl";
import { allRequestUrlsAtom } from "../hooks/useAllRequests";
import { useAuthTab } from "../hooks/useAuthTab";
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
import { useHeadersTab } from "../hooks/useHeadersTab";
import { useImportCurl } from "../hooks/useImportCurl";
import { useInheritedHeaders } from "../hooks/useInheritedHeaders";
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
@@ -278,7 +278,6 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
const { activeResponse } = usePinnedHttpResponse(activeRequestId);
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
const updateKey = useRequestUpdateKey(activeRequestId);
const { mutate: importCurl } = useImportCurl();
const handleBodyChange = useCallback(
(body: HttpRequest["body"]) => patchModelDebounced(activeRequest, { body }),
@@ -299,8 +298,8 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
const handlePaste = useCallback(
async (e: ClipboardEvent, text: string) => {
if (text.startsWith("curl ")) {
importCurl({ overwriteRequestId: activeRequestId, command: text });
if (looksLikeCurl(text)) {
importCurl.mutate({ overwriteRequestId: activeRequestId, command: text });
} else {
const patch = prepareImportQuerystring(text);
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(
() => sendRequest(activeRequest.id ?? null),
@@ -8,6 +8,7 @@ import { useCopyHttpResponse } from "../hooks/useCopyHttpResponse";
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
import { useResponseBodyBytes, useResponseBodyText } from "../hooks/useResponseBodyText";
import { useResponseBodyUrl } from "../hooks/useResponseBodyUrl";
import { useResponseViewMode } from "../hooks/useResponseViewMode";
import { useSaveResponse } from "../hooks/useSaveResponse";
import { useTimelineViewMode } from "../hooks/useTimelineViewMode";
@@ -409,14 +410,13 @@ function EnsureCompleteResponse({
Component,
}: {
response: HttpResponse;
Component: ComponentType<{ bodyPath: string }>;
Component: ComponentType<{ bodyUrl: string }>;
}) {
if (response.bodyPath === null) {
return <div>Empty response body</div>;
}
// Wait until the response has been fully-downloaded before asking for it
const complete = response.state === "closed";
const bodyUrl = useResponseBodyUrl(complete ? response : null);
// Wait until the response has been fully-downloaded
if (response.state !== "closed") {
if (!complete || bodyUrl.isPending) {
return (
<EmptyStateText>
<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 }) {
+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>
);
}
+723 -40
View File
@@ -1,57 +1,740 @@
import { VStack } from "@yaakapp-internal/ui";
import { useState } from "react";
import { useLocalStorage } from "react-use";
import {
type Folder,
type ImportDestination,
type ImportPlan,
type ImportPlanItem,
type ImportSource,
type Workspace,
} from "@yaakapp-internal/models";
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import { platform } from "@yaakapp-internal/platform";
import classNames from "classnames";
import { formatDistanceToNowStrict } from "date-fns";
import { useEffect, useMemo, useRef, useState } from "react";
import { pluralize } from "../lib/pluralize";
import { CommercialUseBanner } from "./CommercialUseBanner";
import { Button } from "./core/Button";
import { SelectFile } from "./SelectFile";
import { Checkbox } from "./core/Checkbox";
import type { CheckboxTreeNode } from "./core/CheckboxTree";
import { CheckboxTree } from "./core/CheckboxTree";
import { IconTooltip } from "./core/IconTooltip";
import { PlainInput } from "./core/PlainInput";
import { Select } from "./core/Select";
import { SegmentedControl } from "./core/SegmentedControl";
interface Props {
importData: (filePath: string) => Promise<void>;
currentWorkspace: Workspace | null;
workspaces: Workspace[];
selectedFolder: Folder | null;
planFile: (filePath: string, destination: ImportDestination) => Promise<ImportPlan>;
planUrl: (url: string, destination: ImportDestination) => Promise<ImportPlan>;
listSources: (workspaceId: string) => Promise<ImportSource[]>;
findSourcesForOrigin: (args: { filePath?: string; url?: string }) => Promise<ImportSource[]>;
commit: (plan: ImportPlan) => Promise<void>;
cancel: () => void;
onError: (err: unknown) => void;
}
export function ImportDataDialog({ importData }: Props) {
const [isLoading, setIsLoading] = useState<boolean>(false);
const [filePath, setFilePath] = useLocalStorage<string | null>("importFilePath", null);
/**
* An absolute or relative path is unambiguously a file. Everything else is treated as a URL, so a
* bare host like `example.com/openapi.json` still works (the backend defaults it to https).
*/
function isFilePath(value: string): boolean {
return (
<VStack space={5} className="pb-4">
<CommercialUseBanner source="data-import" title="Importing work data?" />
value.startsWith("/") ||
value.startsWith("./") ||
value.startsWith("../") ||
value.startsWith("~/") ||
value.startsWith("\\\\") ||
/^[a-zA-Z]:[\\/]/.test(value)
);
}
<VStack space={1}>
<ul className="list-disc pl-5">
<li>OpenAPI 3.0, 3.1</li>
<li>Postman Collection v2, v2.1</li>
<li>Insomnia v4+</li>
<li>Swagger 2.0</li>
<li>
Curl commands <em className="text-text-subtle">(or paste into URL)</em>
</li>
</ul>
</VStack>
<VStack space={2}>
<SelectFile
filePath={filePath ?? null}
onChange={({ filePath }) => setFilePath(filePath)}
/>
{filePath && (
function fileName(path: string): string {
return path.split(/[/\\]/).at(-1) || path;
}
/**
* Loads the current workspace's linked sources before rendering the dialog, so the inner
* component can construct its initial state (prefilled path, destination) in one pass instead of
* patching it in with effects after the first paint.
*/
export function ImportDataDialog(props: Props) {
const [initialSources, setInitialSources] = useState<ImportSource[] | null>(null);
const { currentWorkspace, listSources } = props;
useEffect(() => {
let cancelled = false;
const load = currentWorkspace == null ? Promise.resolve([]) : listSources(currentWorkspace.id);
load
.then((sources) => {
if (!cancelled) setInitialSources(sources);
})
.catch(() => {
if (!cancelled) setInitialSources([]);
});
return () => {
cancelled = true;
};
}, [currentWorkspace, listSources]);
if (initialSources == null) return null;
return <LoadedImportDataDialog {...props} initialSources={initialSources} />;
}
function latestSource(sources: ImportSource[]): ImportSource | null {
return sources.reduce<ImportSource | null>(
(latest, s) => (latest == null || s.lastImportedAt > latest.lastImportedAt ? s : latest),
null,
);
}
function LoadedImportDataDialog({
currentWorkspace,
workspaces,
selectedFolder,
planFile,
planUrl,
listSources,
findSourcesForOrigin,
commit,
cancel,
onError,
initialSources,
}: Props & { initialSources: ImportSource[] }) {
// A workspace with a linked source is probably being re-imported, so start from that source
const prefill = latestSource(initialSources);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [plan, setPlan] = useState<ImportPlan | null>(null);
const [items, setItems] = useState<ImportPlanItem[]>([]);
// null means no explicit choice yet, so the default below applies
const [destinationChoice, setDestinationChoice] = useState<"new" | "current" | "other" | null>(
null,
);
const [otherWorkspaceId, setOtherWorkspaceId] = useState<string | null>(null);
const [targetSelectedFolder, setTargetSelectedFolder] = useState(selectedFolder != null);
const [linkedSources, setLinkedSources] = useState<ImportSource[]>(
prefill != null ? initialSources : [],
);
const [originSources, setOriginSources] = useState<ImportSource[]>(
prefill != null ? [prefill] : [],
);
// A file path or a URL. Both inputs write here, so there is only ever one thing to import
const [source, setSource] = useState<string | null>(prefill?.origin ?? null);
const [forceUpdateKey, setForceUpdateKey] = useState<number>(0);
const [isHovering, setIsHovering] = useState<boolean>(false);
const ref = useRef<HTMLDivElement>(null);
const trimmedSource = source?.trim() ?? "";
const filePath = isFilePath(trimmedSource) ? trimmedSource : null;
const selectSource = (value: string) => {
setSource(value);
// Remount the input so it shows the path of the newly-picked file
setForceUpdateKey((k) => k + 1);
};
// Accept a file dropped anywhere on the dialog, the way SelectFile does for its button
useEffect(() => {
return platform.window.onDragDrop((event) => {
if (event.type === "over") {
const p = event.position;
const r = ref.current?.getBoundingClientRect();
if (r == null) return;
setIsHovering(p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom);
} else if (event.type === "drop" && isHovering) {
const p = event.paths[0];
if (p) selectSource(p);
setIsHovering(false);
} else {
setIsHovering(false);
}
});
}, [isHovering, setSource]);
useEffect(() => {
if (trimmedSource === "") {
setOriginSources([]);
return;
}
let cancelled = false;
const timeout = setTimeout(() => {
findSourcesForOrigin(filePath != null ? { filePath } : { url: trimmedSource })
.then((sources) => {
if (!cancelled) setOriginSources(sources);
})
.catch(() => setOriginSources([]));
}, 300);
return () => {
cancelled = true;
clearTimeout(timeout);
};
}, [trimmedSource, filePath, findSourcesForOrigin]);
// The one workspace this file is linked to, if there is exactly one.
const linkedWorkspace = useMemo(() => {
const ids = [...new Set(originSources.map((s) => s.workspaceId))];
if (ids.length !== 1) return null;
return workspaces.find((w) => w.id === ids[0]) ?? null;
}, [originSources, workspaces]);
// A file linked to the current workspace defaults back into it, so re-importing doesn't
// accidentally create a duplicate workspace. A file linked elsewhere only gets a suggestion —
// silently targeting a workspace that is neither new nor current is too surprising. An
// explicit choice always wins.
const destinationKind =
destinationChoice ??
(linkedWorkspace != null && linkedWorkspace.id === currentWorkspace?.id ? "current" : "new");
const destinationWorkspaceId =
destinationKind === "current"
? (currentWorkspace?.id ?? null)
: destinationKind === "other"
? otherWorkspaceId
: null;
useEffect(() => {
if (destinationWorkspaceId == null) {
setLinkedSources([]);
return;
}
let cancelled = false;
listSources(destinationWorkspaceId)
.then((sources) => {
if (!cancelled) setLinkedSources(sources);
})
.catch(() => setLinkedSources([]));
return () => {
cancelled = true;
};
}, [destinationWorkspaceId, listSources]);
const handleSelectFile = async () => {
const selected = await platform.dialog.open({ title: "Select File", multiple: false });
if (selected == null) return;
selectSource(selected);
};
// The selected folder belongs to the workspace being viewed, so it is only offerable when that
// is also the destination.
const canTargetSelectedFolder = selectedFolder != null && destinationKind === "current";
const destination = (): ImportDestination => {
if (destinationWorkspaceId == null) {
return { type: "new_workspace" };
}
return {
type: "existing_workspace",
workspaceId: destinationWorkspaceId,
folderId: canTargetSelectedFolder && targetSelectedFolder ? selectedFolder.id : undefined,
};
};
const handlePreview = async () => {
setIsLoading(true);
try {
const nextPlan =
filePath != null
? await planFile(filePath, destination())
: await planUrl(trimmedSource, destination());
setPlan(nextPlan);
setItems(nextPlan.items);
} catch (err) {
onError(err);
} finally {
setIsLoading(false);
}
};
const handleCommit = async () => {
if (plan == null) return;
setIsLoading(true);
try {
await commit({ ...plan, items });
} catch (err) {
onError(err);
} finally {
setIsLoading(false);
}
};
const itemTree = useMemo(() => buildItemTree(items), [items]);
// A folder row's checkbox aggregates its subtree the way the git commit tree does: creates and
// updates toggle together, while removals only ever cascade beneath a removed folder. Checking
// anything also brings back the folders it needs to live in.
const toggleNode = (node: CheckboxTreeNode<ImportPlanItem>, checked: boolean) => {
const targets = new Set(
collectItems(node)
.filter((i) => togglesWith(node.data, i))
.map((i) => i.modelId),
);
if (checked) {
const byId = new Map(items.map((i) => [i.modelId, i]));
for (const ancestor of ancestorsOf(node.data, byId)) {
if (ancestor.action === "not_imported") targets.add(ancestor.modelId);
}
}
setItems((prev) => prev.map((i) => (targets.has(i.modelId) ? { ...i, selected: checked } : i)));
};
const resolveConflict = (modelId: string, resolution: "keep_mine" | "take_source") => {
setItems((prev) =>
prev.map((item) => (item.modelId === modelId ? { ...item, resolution } : item)),
);
};
// A row the user can't meaningfully toggle on its own: a planned resource inside a deselected
// new folder can't exist, and a removed folder takes its contents with it.
const disabledIds = useMemo(() => {
const disabled = new Set<string>();
const byId = new Map(items.map((i) => [i.modelId, i]));
for (const item of items) {
for (const parent of ancestorsOf(item, byId)) {
if (parent.model !== "folder") break;
const missing =
(parent.action === "create" || parent.action === "not_imported") && !parent.selected;
// A not-imported row stays checkable: checking it brings its folders back with it
if (missing && item.action !== "delete" && item.action !== "not_imported") {
disabled.add(item.modelId);
}
if (parent.action === "delete" && parent.selected && item.action === "delete") {
disabled.add(item.modelId);
}
}
}
return disabled;
}, [items]);
if (plan != null) {
const unchanged = items.filter((i) => i.action === "unchanged");
const footerNote =
unchanged.length > 0
? `${unchanged.length} ${pluralize("resource", unchanged.length)} unchanged`
: "";
const changeCount = items.filter((item) => {
if (disabledIds.has(item.modelId)) return false;
if (item.action === "conflict") return item.resolution === "take_source";
if (item.action === "unchanged") return false;
return item.selected;
}).length;
const destinationLabel = (() => {
if (plan.destination.type === "new_workspace") return "New workspace";
const { workspaceId, folderId } = plan.destination;
const name = workspaces.find((w) => w.id === workspaceId)?.name ?? "Unknown workspace";
return folderId != null && folderId === selectedFolder?.id
? `${name} / ${selectedFolder.name}`
: name;
})();
// The destination workspace roots the tree. It is not a plan item — commit always applies
// it — so its checkbox only aggregates the subtree.
const workspaceRoot: CheckboxTreeNode<ImportPlanItem> = (() => {
const planned = plan.resources.workspaces[0];
const planDestination = plan.destination;
const existing =
planDestination.type === "existing_workspace"
? workspaces.find((w) => w.id === planDestination.workspaceId)
: null;
return {
key: existing?.id ?? planned?.id ?? "workspace",
data: {
action: plan.destination.type === "new_workspace" ? "create" : "unchanged",
model: "workspace",
modelId: existing?.id ?? planned?.id ?? "workspace",
name: existing?.name ?? planned?.name ?? "New workspace",
selected: true,
changedFields: [],
},
children: itemTree,
};
})();
return (
<VStack space={4} className="pb-4">
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
<PreviewRow label="Detected format" value={plan.importer} />
<PreviewRow label="Destination" value={destinationLabel} />
</div>
<div className="rounded-lg border border-border-subtle px-3 py-2 overflow-y-auto max-h-[40vh]">
<CheckboxTree
node={workspaceRoot}
checked={nodeCheckedStatus}
onCheck={toggleNode}
isCheckboxDisabled={(n) => disabledIds.has(n.key)}
isCollapsedByDefault={(n) => n.data.action === "not_imported"}
isRelevant={(n) => n.data.model === "workspace" || n.data.action !== "unchanged"}
renderRow={(n) => <ImportTreeRow item={n.data} onResolveConflict={resolveConflict} />}
/>
</div>
{plan.warnings.length > 0 && (
<div>
<div className="text-sm font-semibold mb-1">Import details</div>
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
{plan.warnings.map((warning) => (
<div
key={`${warning.title}:${warning.detail}`}
className="flex items-start gap-2.5 px-3 py-2.5"
>
<Icon icon="info" color="info" size="sm" className="mt-0.5" />
<div className="min-w-0">
<div className="text-sm font-medium">{warning.title}</div>
<div className="text-xs text-text-subtle mt-0.5">{warning.detail}</div>
</div>
</div>
))}
</div>
</div>
)}
<HStack space={2} alignItems="center" className="mt-3">
{footerNote !== "" && <div className="text-xs text-text-subtle">{footerNote}</div>}
<Button
color="primary"
disabled={!filePath || isLoading}
isLoading={isLoading}
size="sm"
onClick={async () => {
setIsLoading(true);
try {
await importData(filePath);
} finally {
setIsLoading(false);
}
className="ml-auto"
color="secondary"
variant="border"
disabled={isLoading}
onClick={() => {
setPlan(null);
setItems([]);
}}
>
{isLoading ? "Importing" : "Import"}
Back
</Button>
<Button color="primary" isLoading={isLoading} onClick={handleCommit}>
{isLoading
? "Importing"
: changeCount > 0
? `Apply ${changeCount} ${changeCount === 1 ? "Change" : "Changes"}`
: "Done"}
</Button>
</HStack>
</VStack>
);
}
const lastImported =
originSources.find((s) => s.workspaceId === destinationWorkspaceId) ??
linkedSources.reduce<ImportSource | null>(
(latest, s) => (latest == null || s.lastImportedAt > latest.lastImportedAt ? s : latest),
null,
);
return (
<VStack ref={ref} space={4} className="pb-4">
<CommercialUseBanner source="data-import" title="Importing work data?" />
<button
type="button"
onClick={handleSelectFile}
className={classNames(
"w-full rounded-lg border border-dashed px-4 py-6",
"flex flex-col items-center gap-1 text-center",
isHovering
? "border-notice bg-surface-highlight"
: "border-border hover:border-text-subtle",
)}
>
<Icon icon="folder_input" className="text-text-subtlest w-8! h-8! mb-2" />
{/* Fixed height so the region doesn't resize between the empty and selected states */}
<div className="h-6 w-full flex items-center justify-center">
{filePath == null ? (
<div className="text-text">
<strong className="font-semibold">Choose a file</strong> or drag it here
</div>
) : (
<div className="text-text font-mono text-xs max-w-full truncate" title={filePath}>
{fileName(filePath)}
</div>
)}
</div>
<div className="text-xs text-text-subtlest">
Supports OpenAPI, Swagger, Postman, Insomnia, and curl
</div>
</button>
<PlainInput
label="Or enter a file path or URL"
size="sm"
placeholder="https://example.com/openapi.json"
defaultValue={source ?? ""}
forceUpdateKey={String(forceUpdateKey)}
onChange={setSource}
/>
<VStack space={2}>
<Select
name="import-destination-kind"
label="Import location"
size="sm"
value={destinationKind}
onChange={setDestinationChoice}
options={[
{ value: "new", label: "New Workspace" },
...(currentWorkspace != null
? [{ value: "current" as const, label: "Current Workspace" }]
: []),
{ value: "other", label: "Other Workspace" },
]}
/>
{destinationKind === "other" && (
<Select
name="import-destination-workspace"
label="Workspace"
hideLabel
size="sm"
value={otherWorkspaceId ?? ""}
onChange={(id) => setOtherWorkspaceId(id === "" ? null : id)}
filterable
options={[
{ value: "", label: "Select a workspace" },
...workspaces
.filter((w) => w.id !== currentWorkspace?.id)
.map((w) => ({ value: w.id, label: w.name })),
]}
/>
)}
{lastImported != null && destinationWorkspaceId != null ? (
<div className="text-xs text-text-subtle">
Last imported from {lastImported.originLabel} ·{" "}
{formatDistanceToNowStrict(`${lastImported.lastImportedAt}Z`, { addSuffix: true })}
</div>
) : linkedWorkspace != null && linkedWorkspace.id !== destinationWorkspaceId ? (
<div className="text-xs text-text-subtle">
This file was last imported into{" "}
<button
type="button"
className="underline hocus:text-text"
onClick={() => {
if (linkedWorkspace.id === currentWorkspace?.id) {
setDestinationChoice("current");
} else {
setDestinationChoice("other");
setOtherWorkspaceId(linkedWorkspace.id);
}
}}
>
{linkedWorkspace.name}
</button>
</div>
) : null}
{canTargetSelectedFolder && (
<Checkbox
checked={targetSelectedFolder}
title={`Place root resources in selected folder “${selectedFolder.name}`}
onChange={setTargetSelectedFolder}
/>
)}
</VStack>
<HStack space={2} justifyContent="end">
<Button color="secondary" variant="border" disabled={isLoading} onClick={cancel}>
Cancel
</Button>
<Button
color="primary"
disabled={
trimmedSource === "" ||
isLoading ||
(destinationKind === "other" && otherWorkspaceId == null)
}
isLoading={isLoading}
onClick={handlePreview}
>
{isLoading ? "Analyzing" : "Preview Import"}
</Button>
</HStack>
</VStack>
);
}
function ImportTreeRow({
item,
onResolveConflict,
}: {
item: ImportPlanItem;
onResolveConflict: (modelId: string, resolution: "keep_mine" | "take_source") => void;
}) {
return (
<>
{item.model === "workspace" || item.model === "folder" || item.model === "environment" ? (
<Icon
color="secondary"
icon={
item.model === "workspace" ? "house" : item.model === "folder" ? "folder" : "variable"
}
/>
) : (
<span aria-hidden className="w-4" />
)}
<div className="truncate flex-1">{item.name}</div>
{item.action === "conflict" ? (
<div className="shrink-0">
<SegmentedControl
name={`conflict-${item.modelId}`}
label={`Resolve conflict for ${item.name}`}
hideLabel
size="2xs"
help={actionHelp(item)}
value={item.resolution ?? "keep_mine"}
onChange={(v) => onResolveConflict(item.modelId, v)}
options={[
{ value: "keep_mine", label: "Keep mine" },
{ value: "take_source", label: "Take source" },
]}
/>
</div>
) : (
actionLabel(item) && (
<InlineCode
className={classNames(
"py-0 bg-transparent w-32 shrink-0 whitespace-nowrap text-xs",
"inline-flex items-center justify-center gap-1.5",
item.action === "create" && "text-success",
item.action === "update" && "text-info",
item.action === "delete" && "text-danger",
item.action === "keep_local" && item.selected && "text-warning",
item.action === "not_imported" && "text-text-subtlest",
)}
>
{actionLabel(item)}
<IconTooltip content={actionHelp(item)} iconSize="xs" />
</InlineCode>
)
)}
</>
);
}
function actionLabel(item: ImportPlanItem): string | null {
switch (item.action) {
case "create":
return "new";
case "update":
return "updated";
case "delete":
return "removed";
case "keep_local":
return "edited";
case "not_imported":
return "not imported";
default:
return null;
}
}
function actionHelp(item: ImportPlanItem): string | null {
const help = (text: string) =>
item.changedFields.length > 0
? `${text} · ${item.changedFields.map(fieldLabel).join(", ")}`
: text;
switch (item.action) {
case "create":
return "Added since the last import";
case "update":
return help("Changed since the last import");
case "delete":
return item.reason === "moved_into_not_imported_folder"
? "Moved into a folder that isn't imported. Import that folder instead to follow the move"
: "Deleted since the last import";
case "keep_local":
return help("Local edits made since the last import. Importing will revert them if checked");
case "conflict":
return help("Changed both here and in the file since the last import");
case "not_imported":
return "In the file, but not imported. Check it to import it";
default:
return null;
}
}
function fieldLabel(field: string): string {
return field.replace(/([A-Z])/g, " $1").toLowerCase();
}
/** Every plan item above `item`, nearest first. */
function ancestorsOf(item: ImportPlanItem, byId: Map<string, ImportPlanItem>): ImportPlanItem[] {
const ancestors: ImportPlanItem[] = [];
const seen = new Set<string>();
let parentId = item.parentId;
while (parentId != null && !seen.has(parentId)) {
seen.add(parentId);
const parent = byId.get(parentId);
if (parent == null) break;
ancestors.push(parent);
parentId = parent.parentId;
}
return ancestors;
}
function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<ImportPlanItem>[] {
const byId = new Map(items.map((i) => [i.modelId, i]));
const childrenOf = new Map<string, ImportPlanItem[]>();
const roots: ImportPlanItem[] = [];
for (const item of items) {
if (item.parentId != null && byId.has(item.parentId)) {
const siblings = childrenOf.get(item.parentId) ?? [];
siblings.push(item);
childrenOf.set(item.parentId, siblings);
} else {
roots.push(item);
}
}
const foldersFirst = (list: ImportPlanItem[]) => [
...list.filter((i) => i.model === "folder"),
...list.filter((i) => i.model !== "folder"),
];
const toNode = (item: ImportPlanItem, seen: Set<string>): CheckboxTreeNode<ImportPlanItem> => ({
key: item.modelId,
data: item,
children: seen.has(item.modelId)
? []
: foldersFirst(childrenOf.get(item.modelId) ?? []).map((c) =>
toNode(c, new Set([...seen, item.modelId])),
),
});
return foldersFirst(roots).map((r) => toNode(r, new Set()));
}
function collectItems(node: CheckboxTreeNode<ImportPlanItem>): ImportPlanItem[] {
return [node.data, ...node.children.flatMap(collectItems)];
}
/**
* Whether toggling `root`'s checkbox also toggles `item` in its subtree. Destructive decisions
* (deletions, reverting local edits) never ride along with a parent toggle.
*/
function togglesWith(root: ImportPlanItem, item: ImportPlanItem): boolean {
if (item.model === "workspace") return false;
if (root.action === "delete") return item.action === "delete";
if (item.action === "keep_local") {
return root.modelId === item.modelId && item.model !== "folder";
}
return item.action === "create" || item.action === "update" || item.action === "not_imported";
}
function nodeCheckedStatus(
node: CheckboxTreeNode<ImportPlanItem>,
): boolean | "indeterminate" | "hidden" {
const covered = collectItems(node).filter((i) => togglesWith(node.data, i));
if (covered.length === 0) return "hidden";
const selected = covered.filter((i) => i.selected).length;
if (selected === covered.length) return true;
if (selected === 0) return false;
return "indeterminate";
}
function PreviewRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-start justify-between gap-4 px-3 py-2 text-sm">
<span className="text-text-subtle">{label}</span>
<span className="text-right font-medium">{value}</span>
</div>
);
}
+3 -3
View File
@@ -1,4 +1,3 @@
import { openUrl } from "@tauri-apps/plugin-opener";
import type { LicenseCheckStatus } from "@yaakapp-internal/license";
import { useLicense } from "@yaakapp-internal/license";
import { settingsAtom } from "@yaakapp-internal/models";
@@ -14,6 +13,7 @@ import type { ButtonProps } from "./core/Button";
import { Dropdown, type DropdownItem } from "./core/Dropdown";
import { Icon } from "@yaakapp-internal/ui";
import { PillButton } from "./core/PillButton";
import { platform } from "@yaakapp-internal/platform";
const dismissedAtom = atomWithKVStorage<string | null>("dismissed_license_expired", null);
@@ -52,7 +52,7 @@ function getDetail(
leftSlot: <Icon icon="gift" />,
rightSlot: <Icon icon="external_link" size="sm" className="opacity-disabled" />,
hidden: data.data.changes === 0 || data.data.changesUrl == null,
onSelect: () => openUrl(data.data.changesUrl ?? ""),
onSelect: () => platform.openUrl(data.data.changesUrl ?? ""),
},
{
type: "separator",
@@ -63,7 +63,7 @@ function getDetail(
leftSlot: <Icon icon="refresh" />,
rightSlot: <Icon icon="external_link" size="sm" className="opacity-disabled" />,
hidden: data.data.changesUrl == null,
onSelect: () => openUrl(data.data.billingUrl),
onSelect: () => platform.openUrl(data.data.billingUrl),
},
{
label: "Enter License Key",
+3 -4
View File
@@ -1,7 +1,6 @@
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 { platform } from "@yaakapp-internal/platform";
interface Props {
src: string;
@@ -12,8 +11,8 @@ export function LocalImage({ src: srcPath, className }: Props) {
const src = useQuery({
queryKey: ["local-image", srcPath],
queryFn: async () => {
const p = await resolveResource(srcPath);
return convertFileSrc(p);
const p = await platform.files.resolveResource(srcPath);
return platform.files.url(p);
},
});
@@ -2,7 +2,9 @@ import type {
Folder,
GrpcRequest,
HttpRequest,
HttpVersion,
InheritedBoolSetting,
InheritedHttpVersionSetting,
InheritedIntSetting,
WebsocketRequest,
Workspace,
@@ -13,6 +15,7 @@ import {
modelSupportsSetting,
type RequestSettingDefinition,
SETTING_FOLLOW_REDIRECTS,
SETTING_HTTP_VERSION,
SETTING_REQUEST_MESSAGE_SIZE,
SETTING_REQUEST_TIMEOUT,
SETTING_SEND_COOKIES,
@@ -21,6 +24,7 @@ import {
} from "../lib/requestSettings";
import { Checkbox } from "./core/Checkbox";
import { PlainInput } from "./core/PlainInput";
import { Select } from "./core/Select";
import {
SettingOverrideRow,
SettingRow,
@@ -38,37 +42,21 @@ interface Props {
model: ModelWithSettings;
}
type ModelWithSettings =
| Workspace
| Folder
| HttpRequest
| WebsocketRequest
| GrpcRequest;
type ModelWithSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest;
type ModelWithHttpSettings = Workspace | Folder | HttpRequest;
type ModelWithTlsSettings =
| Workspace
| Folder
| HttpRequest
| WebsocketRequest
| GrpcRequest;
type ModelWithCookieSettings =
| Workspace
| Folder
| HttpRequest
| WebsocketRequest;
type ModelWithMessageSizeSettings =
| Workspace
| Folder
| WebsocketRequest
| GrpcRequest;
type ModelWithTlsSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest;
type ModelWithCookieSettings = Workspace | Folder | HttpRequest | WebsocketRequest;
type ModelWithMessageSizeSettings = Workspace | Folder | WebsocketRequest | GrpcRequest;
type BooleanSetting = boolean | InheritedBoolSetting;
type IntegerSetting = number | InheritedIntSetting;
type HttpVersionSetting = HttpVersion | InheritedHttpVersionSetting;
type CookieSettingsPatch = {
settingSendCookies?: ModelWithCookieSettings["settingSendCookies"];
settingStoreCookies?: ModelWithCookieSettings["settingStoreCookies"];
};
type HttpSettingsPatch = {
settingFollowRedirects?: ModelWithHttpSettings["settingFollowRedirects"];
settingHttpVersion?: ModelWithHttpSettings["settingHttpVersion"];
settingRequestTimeout?: ModelWithHttpSettings["settingRequestTimeout"];
};
type TlsSettingsPatch = {
@@ -78,10 +66,7 @@ type MessageSizeSettingsPatch = {
settingRequestMessageSize?: ModelWithMessageSizeSettings["settingRequestMessageSize"];
};
export function ModelSettingsEditor({
model,
showSectionTitles = false,
}: Props) {
export function ModelSettingsEditor({ model, showSectionTitles = false }: Props) {
const ancestors = useModelAncestors(model);
const supportsHttpSettings = modelSupportsHttpSettings(model);
const supportsCookieSettings = modelSupportsCookieSettings(model);
@@ -154,12 +139,26 @@ export function ModelSettingsEditor({
}
/>
)}
{supportsHttpSettings && (
<HttpVersionSettingRow
settingDefinition={SETTING_HTTP_VERSION}
setting={model.settingHttpVersion}
inheritedValue={resolveInheritedValue(
ancestors,
SETTING_HTTP_VERSION.modelKey,
model.settingHttpVersion,
)}
onChange={(settingHttpVersion) =>
patchHttpSettings(model, {
settingHttpVersion,
})
}
/>
)}
</SettingsSection>
)}
{supportsCookieSettings && (
<SettingsSection
title={supportsTlsSettings || showSectionTitles ? "Cookies" : null}
>
<SettingsSection title={supportsTlsSettings || showSectionTitles ? "Cookies" : null}>
<BooleanSettingRow
settingDefinition={SETTING_SEND_COOKIES}
setting={model.settingSendCookies}
@@ -195,7 +194,7 @@ export function ModelSettingsEditor({
}
export function countOverriddenSettings(model: ModelWithSettings) {
const settings: (BooleanSetting | IntegerSetting)[] = [];
const settings: (BooleanSetting | IntegerSetting | HttpVersionSetting)[] = [];
if (modelSupportsCookieSettings(model)) {
settings.push(model.settingSendCookies, model.settingStoreCookies);
@@ -204,22 +203,22 @@ export function countOverriddenSettings(model: ModelWithSettings) {
settings.push(model.settingValidateCertificates);
if (modelSupportsHttpSettings(model)) {
settings.push(model.settingFollowRedirects, model.settingRequestTimeout);
settings.push(
model.settingFollowRedirects,
model.settingRequestTimeout,
model.settingHttpVersion,
);
}
if (modelSupportsMessageSizeSettings(model)) {
settings.push(model.settingRequestMessageSize);
}
return settings.filter(
(setting) => isInheritedSetting(setting) && setting.enabled === true,
).length;
return settings.filter((setting) => isInheritedSetting(setting) && setting.enabled === true)
.length;
}
function patchCookieSettings(
model: ModelWithCookieSettings,
patch: Partial<CookieSettingsPatch>,
) {
function patchCookieSettings(model: ModelWithCookieSettings, patch: Partial<CookieSettingsPatch>) {
switch (model.model) {
case "workspace":
return patchModel(model, patch as Partial<Workspace>);
@@ -232,10 +231,7 @@ function patchCookieSettings(
}
}
function patchHttpSettings(
model: ModelWithHttpSettings,
patch: Partial<HttpSettingsPatch>,
) {
function patchHttpSettings(model: ModelWithHttpSettings, patch: Partial<HttpSettingsPatch>) {
switch (model.model) {
case "workspace":
return patchModel(model, patch as Partial<Workspace>);
@@ -246,10 +242,7 @@ function patchHttpSettings(
}
}
function patchTlsSettings(
model: ModelWithTlsSettings,
patch: Partial<TlsSettingsPatch>,
) {
function patchTlsSettings(model: ModelWithTlsSettings, patch: Partial<TlsSettingsPatch>) {
switch (model.model) {
case "workspace":
return patchModel(model, patch as Partial<Workspace>);
@@ -280,21 +273,15 @@ function patchMessageSizeSettings(
}
}
function modelSupportsHttpSettings(
model: ModelWithSettings,
): model is ModelWithHttpSettings {
function modelSupportsHttpSettings(model: ModelWithSettings): model is ModelWithHttpSettings {
return modelSupportsSetting(model, SETTING_REQUEST_TIMEOUT);
}
function modelSupportsCookieSettings(
model: ModelWithSettings,
): model is ModelWithCookieSettings {
function modelSupportsCookieSettings(model: ModelWithSettings): model is ModelWithCookieSettings {
return modelSupportsSetting(model, SETTING_SEND_COOKIES);
}
function modelSupportsTlsSettings(
model: ModelWithSettings,
): model is ModelWithTlsSettings {
function modelSupportsTlsSettings(model: ModelWithSettings): model is ModelWithTlsSettings {
return modelSupportsSetting(model, SETTING_VALIDATE_CERTIFICATES);
}
@@ -317,11 +304,7 @@ function BooleanSettingRow({
}) {
const inherited = isInheritedSetting(setting);
const overridden = inherited ? setting.enabled === true : false;
const value = inherited
? overridden
? setting.value
: inheritedValue
: setting;
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
if (!inherited) {
return (
@@ -352,6 +335,63 @@ function BooleanSettingRow({
);
}
const HTTP_VERSION_OPTIONS: { label: string; value: HttpVersion }[] = [
{ label: "Automatic", value: "auto" },
{ label: "HTTP/1.1", value: "http1" },
{ label: "HTTP/2", value: "http2" },
];
function HttpVersionSettingRow({
inheritedValue,
setting,
settingDefinition,
onChange,
}: {
inheritedValue: HttpVersion;
setting: HttpVersionSetting;
settingDefinition: RequestSettingDefinition<"settingHttpVersion">;
onChange: (setting: HttpVersionSetting) => void;
}) {
const inherited = isInheritedSetting(setting);
const overridden = inherited ? setting.enabled === true : false;
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
if (!inherited) {
return (
<SettingRow title={settingDefinition.title} description={settingDefinition.description}>
<Select
hideLabel
name={settingDefinition.modelKey}
label={settingDefinition.title}
size="sm"
value={value}
options={HTTP_VERSION_OPTIONS}
onChange={(value) => onChange(value)}
/>
</SettingRow>
);
}
return (
<SettingOverrideRow
title={settingDefinition.title}
description={settingDefinition.description}
overridden={overridden}
onResetOverride={() => onChange({ ...setting, enabled: false })}
>
<Select
hideLabel
name={settingDefinition.modelKey}
label={settingDefinition.title}
size="sm"
value={value}
options={HTTP_VERSION_OPTIONS}
onChange={(value) => onChange({ ...setting, enabled: true, value })}
/>
</SettingOverrideRow>
);
}
function IntegerSettingRow({
inheritedValue,
setting,
@@ -365,18 +405,11 @@ function IntegerSettingRow({
}) {
const inherited = isInheritedSetting(setting);
const overridden = inherited ? setting.enabled === true : false;
const value = inherited
? overridden
? setting.value
: inheritedValue
: setting;
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
if (!inherited) {
return (
<SettingRow
title={settingDefinition.title}
description={settingDefinition.description}
>
<SettingRow title={settingDefinition.title} description={settingDefinition.description}>
<NumberUnitInput
name={settingDefinition.modelKey}
label={settingDefinition.title}
@@ -429,20 +462,13 @@ function MessageSizeSettingRow({
}) {
const inherited = isInheritedSetting(setting);
const overridden = inherited ? setting.enabled === true : false;
const value = inherited
? overridden
? setting.value
: inheritedValue
: setting;
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
const displayValue = formatMegabytes(value);
const placeholder = "0";
if (!inherited) {
return (
<SettingRow
title={settingDefinition.title}
description={settingDefinition.description}
>
<SettingRow title={settingDefinition.title} description={settingDefinition.description}>
<MessageSizeInput
name={settingDefinition.modelKey}
label={settingDefinition.title}
@@ -567,13 +593,18 @@ function resolveInheritedValue(
key: BooleanWorkspaceSettingKey,
fallback: BooleanSetting,
): boolean;
function resolveInheritedValue(
ancestors: (Folder | Workspace)[],
key: "settingHttpVersion",
fallback: HttpVersionSetting,
): HttpVersion;
function resolveInheritedValue(
ancestors: (Folder | Workspace)[],
key: keyof WorkspaceSettings,
fallback: BooleanSetting | IntegerSetting,
fallback: BooleanSetting | IntegerSetting | HttpVersionSetting,
) {
for (const ancestor of ancestors) {
const setting = ancestor[key] as BooleanSetting | IntegerSetting;
const setting = ancestor[key] as BooleanSetting | IntegerSetting | HttpVersionSetting;
if (isInheritedSetting(setting)) {
if (setting.enabled === true) {
return setting.value;
@@ -589,6 +620,7 @@ function resolveInheritedValue(
type WorkspaceSettings = Pick<
Workspace,
| "settingFollowRedirects"
| "settingHttpVersion"
| "settingRequestMessageSize"
| "settingRequestTimeout"
| "settingSendCookies"
@@ -598,14 +630,12 @@ type WorkspaceSettings = Pick<
type BooleanWorkspaceSettingKey = Exclude<
keyof WorkspaceSettings,
"settingRequestTimeout" | "settingRequestMessageSize"
"settingRequestTimeout" | "settingRequestMessageSize" | "settingHttpVersion"
>;
function formatMegabytes(bytes: number) {
const megabytes = bytes / BYTES_PER_MB;
return Number.isInteger(megabytes)
? `${megabytes}`
: megabytes.toFixed(3).replace(/\.?0+$/, "");
return Number.isInteger(megabytes) ? `${megabytes}` : megabytes.toFixed(3).replace(/\.?0+$/, "");
}
function parseMegabytes(value: string) {
@@ -626,9 +656,5 @@ function isValidInteger(value: string) {
function isValidMegabytes(value: string) {
if (value === "") return true;
const megabytes = Number(value);
return (
Number.isFinite(megabytes) &&
megabytes >= 0 &&
megabytes <= MAX_MESSAGE_SIZE_MB
);
return Number.isFinite(megabytes) && megabytes >= 0 && megabytes <= MAX_MESSAGE_SIZE_MB;
}
@@ -1,4 +1,3 @@
import { openUrl } from "@tauri-apps/plugin-opener";
import type { HttpResponse } from "@yaakapp-internal/models";
import { format, formatDistanceToNowStrict } from "date-fns";
import { useMemo } from "react";
@@ -6,6 +5,7 @@ import { CountBadge } from "./core/CountBadge";
import { DetailsBanner } from "./core/DetailsBanner";
import { IconButton } from "./core/IconButton";
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
import { platform } from "@yaakapp-internal/platform";
interface Props {
response: HttpResponse;
@@ -45,7 +45,7 @@ export function ResponseHeaders({ response }: Props) {
iconSize="sm"
className="inline-block w-auto h-auto! opacity-50 hover:opacity-100"
icon="external_link"
onClick={() => openUrl(response.url)}
onClick={() => platform.openUrl(response.url)}
title="Open in browser"
/>
</div>
+2 -2
View File
@@ -1,7 +1,7 @@
import { openUrl } from "@tauri-apps/plugin-opener";
import type { HttpResponse } from "@yaakapp-internal/models";
import { IconButton } from "./core/IconButton";
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
import { platform } from "@yaakapp-internal/platform";
interface Props {
response: HttpResponse;
@@ -26,7 +26,7 @@ export function ResponseInfo({ response }: Props) {
iconSize="sm"
className="inline-block w-auto ml-1 h-auto! opacity-50 hover:opacity-100"
icon="external_link"
onClick={() => openUrl(response.url)}
onClick={() => platform.openUrl(response.url)}
title="Open in browser"
/>
</div>
+17 -29
View File
@@ -1,5 +1,4 @@
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { open } from "@tauri-apps/plugin-dialog";
import { platform } from "@yaakapp-internal/platform";
import { HStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import mime from "mime";
@@ -41,7 +40,7 @@ export function SelectFile({
...props
}: Props) {
const handleClick = async () => {
const filePath = await open({
const filePath = await platform.dialog.open({
title: directory ? "Select Folder" : "Select File",
multiple: false,
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
// as browser drag-n-drop.
useEffect(() => {
let unlisten: (() => void) | undefined;
const setup = async () => {
const webview = getCurrentWebviewWindow();
unlisten = await webview.onDragDropEvent((event) => {
if (event.payload.type === "over") {
const p = event.payload.position;
const r = ref.current?.getBoundingClientRect();
if (r == null) return;
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);
} else if (event.payload.type === "drop" && isHovering) {
console.log("User dropped", event.payload.paths);
const p = event.payload.paths[0];
if (p) onChange({ filePath: p, contentType: null });
setIsHovering(false);
} else {
console.log("File drop cancelled");
setIsHovering(false);
}
});
};
setup().catch(console.error);
return () => {
if (unlisten) unlisten();
};
return platform.window.onDragDrop((event) => {
if (event.type === "over") {
const p = event.position;
const r = ref.current?.getBoundingClientRect();
if (r == null) return;
const isOver = p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom;
setIsHovering(isOver);
} else if (event.type === "drop" && isHovering) {
const p = event.paths[0];
if (p) onChange({ filePath: p, contentType: null });
setIsHovering(false);
} else {
setIsHovering(false);
}
});
}, [isHovering, onChange]);
const filePathWithNameOverride = nameOverride ? `${filePath} (${nameOverride})` : filePath;
@@ -1,6 +1,4 @@
import { useSearch } from "@tanstack/react-router";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { type } from "@tauri-apps/plugin-os";
import { platform } from "@yaakapp-internal/platform";
import { useLicense } from "@yaakapp-internal/license";
import { pluginsAtom, settingsAtom } from "@yaakapp-internal/models";
import { HeaderSize, HStack, Icon } from "@yaakapp-internal/ui";
@@ -21,6 +19,8 @@ import { SettingsProxy } from "./SettingsProxy";
import { SettingsTheme } from "./SettingsTheme";
interface Props {
tab?: SettingsTabWithSubtab | null;
/** Set when Settings is in a dialog rather than owning a window. */
hide?: () => void;
}
@@ -43,25 +43,19 @@ const tabs = [
TAB_LICENSE,
] as const;
export type SettingsTab = (typeof tabs)[number];
export type SettingsTabWithSubtab = SettingsTab | `${SettingsTab}:${string}`;
export default function Settings({ hide }: Props) {
const { tab: tabFromQuery } = useSearch({ from: "/workspaces/$workspaceId/settings" });
export default function Settings({ tab, hide }: Props) {
// Parse tab and subtab (e.g., "plugins:installed")
const [mainTab, subtab] = tabFromQuery?.split(":") ?? [];
const [mainTab, subtab] = tab?.split(":") ?? [];
const settings = useAtomValue(settingsAtom);
const plugins = useAtomValue(pluginsAtom);
const licenseCheck = useLicense();
// Close settings window on escape
// Close settings window on escape. In a dialog, the dialog handles Escape itself.
// TODO: Could this be put in a better place? Eg. in Rust key listener when creating the window
useKeyPressEvent("Escape", async () => {
if (hide != null) {
// It's being shown in a dialog, so close the dialog
hide();
} else {
// It's being shown in a window, so close the window
await getCurrentWebviewWindow().close();
}
if (hide == null) await platform.window.close();
});
return (
@@ -75,7 +69,7 @@ export default function Settings({ hide }: Props) {
onlyXWindowControl
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"
osType={type()}
osType={platform.osType()}
hideWindowControls={settings.hideWindowControls}
useNativeTitlebar={settings.useNativeTitlebar}
interfaceScale={settings.interfaceScale}
@@ -85,13 +79,13 @@ export default function Settings({ hide }: Props) {
justifyContent="center"
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>
</HeaderSize>
)}
<Tabs
layout="horizontal"
defaultValue={mainTab || tabFromQuery}
defaultValue={mainTab}
addBorders
tabListClassName="min-w-40 bg-surface x-theme-sidebar border-r border-border pl-3"
label="Settings"
@@ -1,4 +1,3 @@
import { revealItemInDir } from "@tauri-apps/plugin-opener";
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import { Heading, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
@@ -9,6 +8,7 @@ import { CargoFeature } from "../CargoFeature";
import { CommercialUseBanner } from "../CommercialUseBanner";
import { DismissibleBanner } from "../core/DismissibleBanner";
import { IconButton } from "../core/IconButton";
import { platform } from "@yaakapp-internal/platform";
import {
ModelSettingRowBoolean,
ModelSettingSelectControl,
@@ -152,7 +152,7 @@ export function SettingsGeneral() {
{
title: revealInFinderText,
icon: "folder_open",
onClick: () => revealItemInDir(appInfo.appDataDir),
onClick: () => platform.revealItemInDir(appInfo.appDataDir),
},
]}
/>
@@ -168,7 +168,7 @@ export function SettingsGeneral() {
{
title: revealInFinderText,
icon: "folder_open",
onClick: () => revealItemInDir(appInfo.appLogDir),
onClick: () => platform.revealItemInDir(appInfo.appLogDir),
},
]}
/>
@@ -124,7 +124,7 @@ export function SettingsHotkeys() {
<HotkeyRow
key={action}
action={action}
currentKeys={hotkeys[action]}
currentKeys={hotkeys[action] ?? []}
defaultKeys={defaultHotkeys[action]}
onSave={async (keys) => {
const newHotkeys = { ...settings.hotkeys };
@@ -1,4 +1,4 @@
import { type } from "@tauri-apps/plugin-os";
import { platform } from "@yaakapp-internal/platform";
import { useFonts } from "@yaakapp-internal/fonts";
import { useLicense } from "@yaakapp-internal/license";
import type { EditorKeymap, Settings } from "@yaakapp-internal/models";
@@ -9,7 +9,7 @@ import { useState } from "react";
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
import { showConfirm } from "../../lib/confirm";
import { pricingUrl } from "../../lib/pricingUrl";
import { invokeCmd } from "../../lib/tauri";
import { rpc } from "../../lib/rpc";
import { CargoFeature } from "../CargoFeature";
import { Button } from "../core/Button";
import { Checkbox } from "../core/Checkbox";
@@ -176,7 +176,7 @@ export function SettingsInterface() {
<SettingsSection title="Window">
<NativeTitlebarSetting settings={settings} />
{type() !== "macos" && (
{platform.osType() !== "macos" && (
<ModelSettingRowBoolean
model={settings}
modelKey="hideWindowControls"
@@ -216,7 +216,7 @@ function NativeTitlebarSetting({ settings }: { settings: Settings }) {
size="xs"
onClick={async () => {
await patchModel(settings, { useNativeTitlebar: nativeTitlebar });
await invokeCmd("cmd_restart");
await rpc("cmd_restart");
}}
>
Apply and Restart
@@ -1,4 +1,3 @@
import { openUrl } from "@tauri-apps/plugin-opener";
import { useLicense } from "@yaakapp-internal/license";
import { Banner, HStack, Icon, VStack } from "@yaakapp-internal/ui";
import { differenceInDays } from "date-fns";
@@ -12,6 +11,7 @@ import { Button } from "../core/Button";
import { Link } from "../core/Link";
import { PlainInput } from "../core/PlainInput";
import { Separator } from "../core/Separator";
import { platform } from "@yaakapp-internal/platform";
export function SettingsLicense() {
return (
@@ -135,7 +135,7 @@ function SettingsLicenseCmp() {
<Button
color="secondary"
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" />}
>
Direct Support
@@ -151,7 +151,7 @@ function SettingsLicenseCmp() {
color="primary"
rightSlot={<Icon icon="external_link" />}
onClick={() =>
openUrl(pricingUrl(`app.license.purchase.${check.data?.status ?? "unknown"}`))
platform.openUrl(pricingUrl(`app.license.purchase.${check.data?.status ?? "unknown"}`))
}
>
Purchase License
@@ -1,5 +1,4 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { openUrl } from "@tauri-apps/plugin-opener";
import type { Plugin } from "@yaakapp-internal/models";
import { patchModel, pluginsAtom } from "@yaakapp-internal/models";
import type { PluginVersion } from "@yaakapp-internal/plugins";
@@ -39,6 +38,7 @@ import { PlainInput } from "../core/PlainInput";
import { TabContent, Tabs } from "../core/Tabs/Tabs";
import { EmptyStateText } from "../EmptyStateText";
import { SelectFile } from "../SelectFile";
import { platform } from "@yaakapp-internal/platform";
interface SettingsPluginsProps {
defaultSubtab?: string;
@@ -113,7 +113,7 @@ export function SettingsPlugins({ defaultSubtab }: SettingsPluginsProps) {
icon="help"
title="View documentation"
onClick={() =>
openUrl("https://yaak.app/docs/plugin-development/plugins-quick-start")
platform.openUrl("https://yaak.app/docs/plugin-development/plugins-quick-start")
}
/>
</HStack>
@@ -1,4 +1,3 @@
import { openUrl } from "@tauri-apps/plugin-opener";
import { useLicense } from "@yaakapp-internal/license";
import { useRef } from "react";
import { openSettings } from "../commands/openSettings";
@@ -13,6 +12,7 @@ import { Dropdown } from "./core/Dropdown";
import { Icon } from "@yaakapp-internal/ui";
import { IconButton } from "./core/IconButton";
import { KeyboardShortcutsDialog } from "./KeyboardShortcutsDialog";
import { platform } from "@yaakapp-internal/platform";
export function SettingsDropdown() {
const exportData = useExportData();
@@ -62,7 +62,7 @@ export function SettingsDropdown() {
{
label: "Create Run Button",
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}` },
{
@@ -78,26 +78,26 @@ export function SettingsDropdown() {
leftSlot: <Icon icon="circle_dollar_sign" />,
rightSlot: <Icon icon="external_link" color="success" className="opacity-60" />,
onSelect: () =>
openUrl(pricingUrl(`app.menu.purchase.${check.data?.status ?? "unknown"}`)),
platform.openUrl(pricingUrl(`app.menu.purchase.${check.data?.status ?? "unknown"}`)),
},
{
label: "Install CLI",
hidden: appInfo.cliVersion != null,
leftSlot: <Icon icon="square_terminal" />,
rightSlot: <Icon icon="external_link" color="secondary" />,
onSelect: () => openUrl("https://yaak.app/docs/cli"),
onSelect: () => platform.openUrl("https://yaak.app/docs/cli"),
},
{
label: "Feedback",
leftSlot: <Icon icon="chat" />,
rightSlot: <Icon icon="external_link" color="secondary" />,
onSelect: () => openUrl("https://yaak.app/feedback"),
onSelect: () => platform.openUrl("https://yaak.app/feedback"),
},
{
label: "Changelog",
leftSlot: <Icon icon="cake" />,
rightSlot: <Icon icon="external_link" color="secondary" />,
onSelect: () => openUrl(`https://yaak.app/changelog/${appInfo.version}`),
onSelect: () => platform.openUrl(`https://yaak.app/changelog/${appInfo.version}`),
},
]}
>
+13 -6
View File
@@ -43,7 +43,8 @@ import { getFolderActions } from "../hooks/useFolderActions";
import { getGrpcRequestActions } from "../hooks/useGrpcRequestActions";
import { useHotKey } from "../hooks/useHotKey";
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 { sendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
import { useSidebarHidden } from "../hooks/useSidebarHidden";
@@ -131,11 +132,17 @@ function Sidebar({ className }: { className?: string }) {
if (!didFocus) filterRef.current?.focus();
}, []);
// Focus any new sidebar models when created
useListenToTauriEvent<ModelPayload>("model_write", ({ payload }) => {
if (!isSidebarLeafModel(payload.model)) return;
if (!(payload.change.type === "upsert" && payload.change.created)) return;
treeRef.current?.selectItem(payload.model.id, true);
// 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
// the selection.
usePlatformEvent<ModelPayload[]>("model_writes", (payloads) => {
for (const payload of payloads) {
if (payload.updateSource.type !== "window") continue;
if (payload.updateSource.label !== platform.window.label) continue;
if (!isSidebarLeafModel(payload.model)) continue;
if (!(payload.change.type === "upsert" && payload.change.created)) continue;
treeRef.current?.selectItem(payload.model.id, true);
}
});
useEffect(() => {
@@ -1,4 +1,3 @@
import { readDir } from "@tauri-apps/plugin-fs";
import { Banner, VStack } from "@yaakapp-internal/ui";
import { useState } from "react";
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
@@ -6,6 +5,7 @@ import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { SettingRowBoolean, SettingRowDirectory } from "./core/SettingRow";
import { SelectFile } from "./SelectFile";
import { platform } from "@yaakapp-internal/platform";
export interface SyncToFilesystemSettingProps {
layout?: "form" | "settings";
@@ -24,7 +24,7 @@ export function SyncToFilesystemSetting({
const handleFilePathChange = async (filePath: string | null) => {
if (filePath != null) {
const files = await readDir(filePath);
const files = await platform.files.readDir(filePath);
if (files.length > 0) {
setSyncDir(filePath);
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 { Banner, HeaderSize, HStack, SidebarLayout } from "@yaakapp-internal/ui";
import classNames from "classnames";
@@ -53,7 +53,7 @@ export function Workspace() {
const workspaces = useAtomValue(workspacesAtom);
const settings = useAtomValue(settingsAtom);
const osType = type();
const osType = platform.osType();
const [width, setWidth] = useSidebarWidth();
const [sidebarHidden, setSidebarHidden] = useSidebarHidden();
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 classNames from "classnames";
import { useAtomValue } from "jotai";
@@ -16,6 +14,7 @@ import { useCreateWorkspace } from "../hooks/useCreateWorkspace";
import { useDeleteSendHistory } from "../hooks/useDeleteSendHistory";
import { useWorkspaceActions } from "../hooks/useWorkspaceActions";
import { showDialog } from "../lib/dialog";
import { importData } from "../lib/importData";
import { jotaiStore } from "../lib/jotai";
import { revealInFinderText } from "../lib/reveal";
import { CloneGitRepositoryDialog } from "./CloneGitRepositoryDialog";
@@ -26,6 +25,7 @@ import { Icon } from "@yaakapp-internal/ui";
import type { RadioDropdownItem } from "./core/RadioDropdown";
import { RadioDropdown } from "./core/RadioDropdown";
import { SwitchWorkspaceDialog } from "./SwitchWorkspaceDialog";
import { platform } from "@yaakapp-internal/platform";
type Props = Pick<ButtonProps, "className" | "justify" | "forDropdown" | "leftSlot">;
@@ -75,7 +75,7 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
label: "Open Folder",
leftSlot: <Icon icon="folder_open" />,
onSelect: async () => {
const dir = await open({
const dir = await platform.dialog.open({
title: "Select Workspace Directory",
directory: true,
multiple: false,
@@ -90,6 +90,11 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
leftSlot: <Icon icon="hard_drive_download" />,
onSelect: openCloneGitRepositoryDialog,
},
{
label: "Import Data",
leftSlot: <Icon icon="folder_input" />,
onSelect: () => importData.mutate(),
},
],
},
];
@@ -114,7 +119,7 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
leftSlot: <Icon icon="folder_symlink" />,
onSelect: async () => {
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 { PillButton } from "./core/PillButton";
import { EnvironmentActionsDropdown } from "./EnvironmentActionsDropdown";
import { ImportCurlButton } from "./ImportCurlButton";
import { ImportCurl } from "./ImportCurl";
import { LicenseBadge } from "./LicenseBadge";
import { RecentRequestsDropdown } from "./RecentRequestsDropdown";
import { SettingsDropdown } from "./SettingsDropdown";
@@ -56,7 +56,7 @@ export const WorkspaceHeader = memo(function WorkspaceHeader({
<RecentRequestsDropdown />
</div>
<div className="flex-1 flex gap-1 items-center h-full justify-end pointer-events-none pr-1">
<ImportCurlButton />
<ImportCurl />
{showEncryptionSetup ? (
<PillButton color="danger" onClick={setupOrConfigureEncryption}>
Enter Encryption Key
@@ -0,0 +1,114 @@
import { Icon } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { ReactNode } from "react";
import { useState } from "react";
import type { CheckboxProps } from "./Checkbox";
import { Checkbox } from "./Checkbox";
export interface CheckboxTreeNode<T> {
key: string;
data: T;
children: CheckboxTreeNode<T>[];
}
interface Props<T> {
node: CheckboxTreeNode<T>;
depth?: number;
/** Return "hidden" to render row alignment space instead of a checkbox */
checked: (node: CheckboxTreeNode<T>) => CheckboxProps["checked"] | "hidden";
onCheck: (node: CheckboxTreeNode<T>, checked: boolean) => void;
checkboxTitle?: (node: CheckboxTreeNode<T>) => string;
isCheckboxDisabled?: (node: CheckboxTreeNode<T>) => boolean;
/** An irrelevant row is hidden unless one of its descendants is relevant */
isRelevant: (node: CheckboxTreeNode<T>) => boolean;
/** A node that starts collapsed, so a large subtree doesn't crowd out the rest */
isCollapsedByDefault?: (node: CheckboxTreeNode<T>) => boolean;
renderRow: (node: CheckboxTreeNode<T>) => ReactNode;
onSelectRow?: (node: CheckboxTreeNode<T>) => void;
canSelectRow?: (node: CheckboxTreeNode<T>) => boolean;
isRowSelected?: (node: CheckboxTreeNode<T>) => boolean;
}
export function CheckboxTree<T>(props: Props<T>) {
const { node, depth = 0 } = props;
const [collapsed, setCollapsed] = useState<boolean>(
() => props.isCollapsedByDefault?.(node) ?? false,
);
if (!hasRelevantNode(node, props.isRelevant)) return null;
const checked = props.checked(node);
const selected = props.isRowSelected?.(node) ?? false;
const selectable = props.onSelectRow != null && (props.canSelectRow?.(node) ?? true);
const hasVisibleChildren = node.children.some((c) => hasRelevantNode(c, props.isRelevant));
const rowContent = (
<div className="flex-1 min-w-0 flex items-center gap-1 px-1 py-0.5 text-left">
{props.renderRow(node)}
</div>
);
return (
<div
className={classNames(
depth > 0 && "pl-4 ml-2 border-l border-dashed border-border-subtle relative",
)}
>
<div
className={classNames(
"relative flex gap-1 w-full h-xs items-center",
selected ? "text-text" : "text-text-subtle",
)}
>
{selected && (
<div className="absolute left-[-100vw] right-0 top-0 bottom-0 bg-surface-active opacity-30 -z-10" />
)}
{hasVisibleChildren ? (
<button
type="button"
aria-label={collapsed ? "Expand" : "Collapse"}
aria-expanded={!collapsed}
className="shrink-0 text-text-subtlest hocus:text-text"
onClick={() => setCollapsed((v) => !v)}
>
<Icon size="sm" icon={collapsed ? "chevron_right" : "chevron_down"} />
</button>
) : (
<span aria-hidden className="w-4 shrink-0" />
)}
{checked === "hidden" ? (
<span aria-hidden className="w-4 mr-0.5 shrink-0" />
) : (
<Checkbox
checked={checked}
title={props.checkboxTitle?.(node) ?? "Toggle"}
hideLabel
disabled={props.isCheckboxDisabled?.(node)}
onChange={(checked) => props.onCheck(node, checked)}
/>
)}
{selectable ? (
<button
type="button"
className="flex-1 min-w-0 flex text-left"
onClick={() => props.onSelectRow?.(node)}
>
{rowContent}
</button>
) : (
rowContent
)}
</div>
{!collapsed &&
node.children.map((child) => (
<CheckboxTree key={child.key} {...props} node={child} depth={depth + 1} />
))}
</div>
);
}
function hasRelevantNode<T>(
node: CheckboxTreeNode<T>,
isRelevant: (node: CheckboxTreeNode<T>) => boolean,
): boolean {
return isRelevant(node) || node.children.some((c) => hasRelevantNode(c, isRelevant));
}
+7 -5
View File
@@ -10,11 +10,13 @@ export interface DialogProps {
children: ReactNode;
open: boolean;
onClose?: () => void;
disableBackdropClose?: boolean;
/** Block dismissal from the backdrop, Escape key, and built-in close button. */
disableClose?: boolean;
title?: ReactNode;
description?: ReactNode;
className?: string;
size?: DialogSize;
/** Hide the built-in close button without changing backdrop or Escape behavior. */
hideX?: boolean;
noPadding?: boolean;
noScroll?: boolean;
@@ -27,7 +29,7 @@ export function Dialog({
size = "full",
open,
onClose,
disableBackdropClose,
disableClose,
title,
description,
hideX,
@@ -42,7 +44,7 @@ export function Dialog({
);
return (
<Overlay open={open} onClose={disableBackdropClose ? undefined : onClose} portalName="dialog">
<Overlay open={open} onClose={disableClose ? undefined : onClose} portalName="dialog">
<div
role="dialog"
className={classNames(
@@ -58,7 +60,7 @@ export function Dialog({
// NOTE: We handle Escape on the element itself so that it doesn't close multiple
// dialogs and can be intercepted by children if needed.
if (e.key === "Escape") {
onClose?.();
if (!disableClose) onClose?.();
e.stopPropagation();
e.preventDefault();
}
@@ -110,7 +112,7 @@ export function Dialog({
</div>
{/*Put close at the end so that it's the last thing to be tabbed to*/}
{!hideX && (
{!disableClose && !hideX && (
<div className="ml-auto absolute right-1 top-1">
<IconButton
className="opacity-70 hover:opacity-100"
+85 -13
View File
@@ -36,12 +36,16 @@ import { fireAndForget } from "../../lib/fireAndForget";
import { ErrorBoundary } from "../ErrorBoundary";
import { Button } from "./Button";
import { Hotkey } from "./Hotkey";
import { IconButton } from "./IconButton";
import type { SeparatorAction } from "./Separator";
import { Separator } from "./Separator";
export type DropdownItemSeparator = {
type: "separator";
label?: ReactNode;
hidden?: boolean;
/** A control shown beside the label, eg. revealing the labelled file on disk. */
action?: SeparatorAction;
};
export type DropdownItemContent = {
@@ -66,6 +70,12 @@ export type DropdownItemDefault = {
submenu?: DropdownItem[];
/** If true, submenu opens on click instead of hover */
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"];
};
@@ -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, () => {
@@ -629,7 +645,7 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
const item = filteredItems[selectedIndex ?? -1];
if (!item || item.type === "separator" || item.type === "content") return;
e.preventDefault();
if (item.submenu) {
if (item.submenu && item.submenuTrigger !== "button") {
const parent = document.activeElement as HTMLButtonElement;
if (parent) {
setActiveSubmenu({ item, parent, viaKeyboard: true });
@@ -648,9 +664,11 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
clearTimeout(submenuTimeoutRef.current);
}
if (item.submenu && !item.submenuOpenOnClick) {
if (item.submenu && !item.submenuOpenOnClick && item.submenuTrigger !== "button") {
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(() => {
const submenuEl = submenuRef.current;
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
key={i}
className={classNames("my-1.5", item.label ? "ml-2" : null)}
action={item.action}
>
{item.label}
</Separator>
@@ -797,6 +816,7 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
onFocus={handleFocus}
onSelect={handleSelect}
onHover={handleItemHover}
onOpenSubmenu={(item, el) => setActiveSubmenu({ item, parent: el })}
// oxlint-disable-next-line no-array-index-key -- It's fine
key={i}
item={item}
@@ -868,6 +888,7 @@ interface MenuItemProps {
onSelect: (item: DropdownItemDefault, el?: HTMLButtonElement) => Promise<void>;
onFocus: (item: DropdownItemDefault) => void;
onHover: (item: DropdownItemDefault, el: HTMLButtonElement) => void;
onOpenSubmenu: (item: DropdownItemDefault, el: HTMLButtonElement) => void;
focused: boolean;
isParentOfActiveSubmenu?: boolean;
}
@@ -879,6 +900,7 @@ function MenuItem({
onHover,
item,
onSelect,
onOpenSubmenu,
isParentOfActiveSubmenu,
...props
}: MenuItemProps) {
@@ -914,19 +936,22 @@ function MenuItem({
e.currentTarget.focus();
};
const rightSlot = item.submenu ? (
<Icon icon="chevron_right" color="secondary" />
) : (
(item.rightSlot ?? <Hotkey variant="text" action={item.hotKeyAction ?? null} />)
);
const hasButtonSubmenu = item.submenu != null && item.submenuTrigger === "button";
return (
const rightSlot =
item.submenu && !hasButtonSubmenu ? (
<Icon icon="chevron_right" color="secondary" />
) : (
(item.rightSlot ?? <Hotkey variant="text" action={item.hotKeyAction ?? null} />)
);
const button = (
<Button
ref={initRef}
size="sm"
tabIndex={-1}
onMouseEnter={handleMouseEnter}
onMouseLeave={(e) => e.currentTarget.blur()}
onMouseEnter={hasButtonSubmenu ? undefined : handleMouseEnter}
onMouseLeave={hasButtonSubmenu ? undefined : (e) => e.currentTarget.blur()}
disabled={item.disabled}
onFocus={handleFocus}
onClick={handleClick}
@@ -947,6 +972,7 @@ function MenuItem({
"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",
isParentOfActiveSubmenu && "bg-surface-highlight text rounded-sm",
hasButtonSubmenu && "pr-8",
item.color === "danger" && "text-danger!",
item.color === "primary" && "text-primary!",
item.color === "success" && "text-success!",
@@ -959,6 +985,52 @@ function MenuItem({
<div className={classNames("truncate min-w-20")}>{item.label}</div>
</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 {
@@ -601,6 +601,8 @@ function getExtensions({
EditorView.contentAttributes.of({
autocapitalize: "off",
autocorrect: "off",
// Keeps macOS Writing Tools from offering to write code for us
writingsuggestions: "false",
}),
EditorView.domEventHandlers({
focus: () => {
@@ -1,9 +1,29 @@
import type { Diagnostic } from "@codemirror/lint";
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;
// jsonc-parser reports error codes, so these are the words the editor shows for them
const MESSAGES: Record<string, string> = {
InvalidSymbol: "Invalid symbol",
InvalidNumberFormat: "Invalid number format",
PropertyNameExpected: "Property name expected",
ValueExpected: "Value expected",
ColonExpected: "Colon expected",
CommaExpected: "Comma expected",
CloseBraceExpected: "Closing brace expected",
CloseBracketExpected: "Closing bracket expected",
EndOfFileExpected: "End of file expected",
InvalidCommentToken: "Comments are not allowed",
UnexpectedEndOfComment: "Unexpected end of comment",
UnexpectedEndOfString: "Unexpected end of string",
UnexpectedEndOfNumber: "Unexpected end of number",
InvalidUnicode: "Invalid unicode sequence",
InvalidEscapeCharacter: "Invalid escape character",
InvalidCharacter: "Invalid character",
};
interface JsonLintOptions {
allowComments?: boolean;
allowTrailingCommas?: boolean;
@@ -11,34 +31,28 @@ interface JsonLintOptions {
export function jsonParseLinter(options?: JsonLintOptions) {
return (view: EditorView): Diagnostic[] => {
try {
const doc = view.state.doc.toString();
// We need lint to not break on stuff like {"foo:" ${[ ... ]}} so we'll replace all template
// syntax with repeating `1` characters, so it's valid JSON and the position is still correct.
const escapedDoc = doc.replace(TEMPLATE_SYNTAX_REGEX, (m) => "1".repeat(m.length));
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 doc = view.state.doc.toString();
// We need lint to not break on stuff like {"foo:" ${[ ... ]}} so we'll replace all template
// syntax with repeating `1` characters, so it's valid JSON and the position is still correct.
const escapedDoc = doc.replace(TEMPLATE_SYNTAX_REGEX, (m) => "1".repeat(m.length));
// const line = location?.start?.line;
// const column = location?.start?.column;
if (err.location.start.offset) {
return [
{
from: err.location.start.offset,
to: err.location.start.offset,
severity: "error",
message: err.message,
},
];
}
}
return [];
const errors: ParseError[] = [];
parse(escapedDoc, errors, {
allowTrailingComma: options?.allowTrailingCommas ?? false,
disallowComments: !(options?.allowComments ?? true),
});
// Later errors are mostly consequences of the first one, so only that one is shown
const error = errors[0];
if (error == null) return [];
return [
{
from: error.offset,
to: error.offset + error.length,
severity: "error",
message: MESSAGES[printParseErrorCode(error.error)] ?? "Invalid JSON",
},
];
};
}
@@ -0,0 +1,202 @@
import { SearchQuery } from "@codemirror/search";
import { EditorState } from "@codemirror/state";
import { describe, expect, test } from "vite-plus/test";
import {
currentMatch,
literalSearch,
MAX_COUNT,
MatchCounter,
normalizeDoc,
normalizeSearch,
scanNormalized,
scanQuery,
} from "./searchMatchCount";
type QueryConfig = ConstructorParameters<typeof SearchQuery>[0];
const stateOf = (doc: string) => EditorState.create({ doc });
/**
* The matches the counter finds, having checked them against the search panel's own cursor.
*
* The cursor decides which ranges the editor highlights and which one `find next` lands on, so
* a count that doesn't agree with it is a wrong count, however fast it was to produce.
*/
function matchesOf(doc: string, config: QueryConfig) {
const state = stateOf(doc);
const query = new SearchQuery(config);
const matches = new MatchCounter().matches(state, query);
expect(matches).toEqual(scanQuery(state, query));
return matches;
}
const countOf = (doc: string, config: QueryConfig) => matchesOf(doc, config).length;
describe("counting", () => {
test("counts every match, whatever the case", () => {
expect(countOf("one Two three two", { search: "two" })).toBe(2);
expect(countOf("one Two three two", { search: "two", caseSensitive: true })).toBe(1);
});
test("skips matches overlapping an earlier one", () => {
expect(countOf("aaaaa", { search: "aa" })).toBe(2);
expect(countOf("ababa", { search: "aba" })).toBe(1);
});
test("treats a query as text, not as a pattern", () => {
expect(countOf("a.b axb", { search: "a.b" })).toBe(1);
});
test("unquotes escapes unless the query is literal", () => {
expect(countOf("one\ntwo\nthree", { search: "\\n" })).toBe(2);
expect(countOf("one\\ntwo", { search: "\\n", literal: true })).toBe(1);
});
test("counts regexp and whole word queries through the cursor", () => {
expect(literalSearch(new SearchQuery({ search: "a", regexp: true }))).toBe(null);
expect(literalSearch(new SearchQuery({ search: "a", wholeWord: true }))).toBe(null);
expect(countOf("a1 b2 c3", { search: "[a-z]\\d", regexp: true })).toBe(3);
expect(countOf("cat cats cat", { search: "cat", wholeWord: true })).toBe(2);
});
test("stops counting at the cap", () => {
expect(countOf("x".repeat(MAX_COUNT + 100), { search: "x" })).toBe(MAX_COUNT + 1);
});
test("reports where the matches are", () => {
expect(matchesOf("ab..ab", { search: "ab" })).toEqual([
{ from: 0, to: 2 },
{ from: 4, to: 6 },
]);
});
test("finds nothing to match with an empty needle", () => {
expect(scanNormalized(normalizeDoc("abc", false), "")).toEqual([]);
});
});
describe("normalization", () => {
test("finds what a character decomposes into", () => {
// The é is one character holding an `e`, and the match covers the whole of it
expect(matchesOf("café", { search: "e" })).toEqual([{ from: 3, to: 4 }]);
expect(matchesOf("file", { search: "fi" })).toEqual([{ from: 0, to: 1 }]);
expect(matchesOf("a…b", { search: "..." })).toEqual([{ from: 1, to: 2 }]);
expect(countOf("one two", { search: "one two" })).toBe(1);
expect(countOf("full width", { search: "full" })).toBe(1);
});
test("matches a decomposed query against composed text, and the reverse", () => {
expect(countOf("café", { search: "café" })).toBe(1);
expect(countOf("café", { search: "café" })).toBe(1);
expect(countOf("café", { search: "café" })).toBe(1);
});
test("keeps offsets straight after an expansion", () => {
expect(matchesOf("é.é.end", { search: "end" })).toEqual([{ from: 4, to: 7 }]);
expect(matchesOf("fififi stop", { search: "stop" })).toEqual([{ from: 4, to: 8 }]);
});
test("normalizes the query whole, the document by character", () => {
expect(normalizeSearch("CAFÉ", false)).toBe("café");
expect(normalizeSearch("CAFÉ", true)).toBe("CAFÉ");
// Whole-string NFKD would fold this to a final sigma, which the cursor never does
expect(normalizeDoc("ΟΔΟΣ", false).text).toBe("οδοσ");
});
test("leaves a document that normalizes to itself untouched", () => {
const { text, expansions } = normalizeDoc("plain 日本 🎉 text", false);
expect(text).toBe("plain 日本 🎉 text");
expect(expansions).toEqual([]);
});
});
describe("current match", () => {
const matches = [
{ from: 0, to: 2 },
{ from: 4, to: 6 },
{ from: 8, to: 10 },
];
test("counts from one, and reports 0 off a match", () => {
expect(currentMatch(matches, { from: 4, to: 6 })).toBe(2);
expect(currentMatch(matches, { from: 8, to: 10 })).toBe(3);
expect(currentMatch(matches, { from: 5, to: 5 })).toBe(2);
expect(currentMatch(matches, { from: 2, to: 3 })).toBe(0);
expect(currentMatch(matches, { from: 4, to: 7 })).toBe(0);
expect(currentMatch([], { from: 0, to: 0 })).toBe(0);
});
test("moving the selection doesn't scan again", () => {
const state = stateOf("a1 b2 c3");
const query = new SearchQuery({ search: "\\d", regexp: true });
const counter = new MatchCounter();
const found = counter.matches(state, query);
// The document a selection-only transaction leaves behind is the one already scanned
const moved = state.update({ selection: { anchor: 4, head: 5 } }).state;
expect(counter.matches(moved, query)).toBe(found);
expect(currentMatch(found, moved.selection.main)).toBe(2);
});
});
/**
* The mapping from normalized offsets back to document offsets is the part of this that can go
* quietly wrong, and only on input nobody thinks to write a case for. So generate the input.
*/
describe("against the cursor, on awkward text", () => {
const ALPHABET = [
..."abcABC .\\\n".split(""),
"é",
"é",
"fi",
"…",
" ",
"İ",
"Σ",
"ς",
"日",
"🎉",
"Ⅻ",
"",
"①",
"́",
];
/** Seeded, so a failure is the same failure next run */
function random(seed: number) {
let state = seed;
return () => {
state = (state * 1664525 + 1013904223) >>> 0;
return state / 2 ** 32;
};
}
for (const caseSensitive of [false, true]) {
test(`agrees on every generated document (caseSensitive: ${caseSensitive})`, () => {
const next = random(caseSensitive ? 20260831 : 7);
for (let round = 0; round < 400; round++) {
const doc = Array.from(
{ length: 2 + Math.floor(next() * 60) },
() => ALPHABET[Math.floor(next() * ALPHABET.length)]!,
).join("");
// Half the queries are lifted out of the document, so matches are actually found
const start = Math.floor(next() * doc.length);
const search =
next() < 0.5
? doc.slice(start, start + 1 + Math.floor(next() * 3))
: Array.from(
{ length: 1 + Math.floor(next() * 2) },
() => ALPHABET[Math.floor(next() * ALPHABET.length)]!,
).join("");
if (search === "") continue;
const state = stateOf(doc);
const query = new SearchQuery({ search, caseSensitive });
const where = `doc=${JSON.stringify(doc)} search=${JSON.stringify(search)}`;
expect(new MatchCounter().matches(state, query), where).toEqual(scanQuery(state, query));
}
});
}
});
@@ -1,7 +1,232 @@
import { getSearchQuery, searchPanelOpen } from "@codemirror/search";
import type { Extension } from "@codemirror/state";
import { getSearchQuery, type SearchQuery, searchPanelOpen } from "@codemirror/search";
import type { EditorState, Extension, Text } from "@codemirror/state";
import { type EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
/** Matches are counted no further than this, since an exact total stops being useful long before */
export const MAX_COUNT = 9999;
/** What normalizing rewrites: anything outside ASCII, plus the case it folds */
const REWRITTEN = /\P{ASCII}|[A-Z]+/gu;
const REWRITTEN_CASE_SENSITIVE = /\P{ASCII}/gu;
export interface Match {
from: number;
to: number;
}
/** A character whose normalized form is a different length, shifting every offset past it */
interface Expansion {
normFrom: number;
normTo: number;
docFrom: number;
docTo: number;
}
/** A document as SearchCursor compares it, with what's needed to get back to real offsets */
export interface NormalizedDoc {
text: string;
expansions: Expansion[];
}
/**
* Rewrites a document the way SearchCursor does — NFKD, then a case fold unless the search is
* case-sensitive — in a single pass rather than one call per code point.
*
* The cursor spends 90% of its time asking ICU about one character at a time, which is what
* makes counting matches in a large response slow. Doing it a character at a time still matters
* for the result, since it keeps NFKD from reordering marks across characters, so the
* granularity stays and only the repeated work goes: each distinct character is normalized once
* and the answer reused, and ASCII runs never reach ICU at all.
*/
export function normalizeDoc(text: string, caseSensitive: boolean): NormalizedDoc {
const rewritten = new Map<string, string>();
const expansions: Expansion[] = [];
let shift = 0;
const normalized = text.replace(
caseSensitive ? REWRITTEN_CASE_SENSITIVE : REWRITTEN,
(chunk: string, at: number) => {
// An ASCII run only ever folds case, which can't change its length
if (chunk.charCodeAt(0) < 0x80) return chunk.toLowerCase();
let out = rewritten.get(chunk);
if (out === undefined) {
out = chunk.normalize("NFKD");
if (!caseSensitive) out = out.toLowerCase();
rewritten.set(chunk, out);
}
if (out.length !== chunk.length) {
expansions.push({
normFrom: at + shift,
normTo: at + shift + out.length,
docFrom: at,
docTo: at + chunk.length,
});
shift += out.length - chunk.length;
}
return out;
},
);
return { text: normalized, expansions };
}
/** The query as SearchCursor compares it, which it normalizes whole rather than by character */
export function normalizeSearch(search: string, caseSensitive: boolean): string {
const normalized = search.normalize("NFKD");
return caseSensitive ? normalized : normalized.toLowerCase();
}
/** Every occurrence of `needle`, skipping matches that overlap an earlier one */
export function scanNormalized(doc: NormalizedDoc, needle: string): Match[] {
const matches: Match[] = [];
if (needle === "") return matches;
const { text } = doc;
let pos = text.indexOf(needle);
while (pos >= 0) {
let end = pos + needle.length;
// However the query was cut, a match ends on a whole code point, as the cursor's do
if (isLowSurrogate(text.charCodeAt(end))) end++;
matches.push({ from: docStart(doc, pos), to: docEnd(doc, end) });
if (matches.length > MAX_COUNT) break;
pos = text.indexOf(needle, resumeAfter(doc, end));
}
return matches;
}
/** The same through the query's own cursor, which handles regexps and whole words */
export function scanQuery(state: EditorState, query: SearchQuery): Match[] {
const matches: Match[] = [];
const cursor = query.getCursor(state);
for (let result = cursor.next(); !result.done; result = cursor.next()) {
matches.push({ from: result.value.from, to: result.value.to });
if (matches.length > MAX_COUNT) break;
}
return matches;
}
const isLowSurrogate = (code: number) => code >= 0xdc00 && code <= 0xdfff;
/** The last character expansion beginning at or before `offset`, if there is one */
function expansionAt({ expansions }: NormalizedDoc, offset: number): Expansion | null {
let low = 0;
let high = expansions.length - 1;
let found: Expansion | null = null;
while (low <= high) {
const mid = (low + high) >> 1;
if (expansions[mid]!.normFrom <= offset) {
found = expansions[mid]!;
low = mid + 1;
} else {
high = mid - 1;
}
}
return found;
}
function docStart(doc: NormalizedDoc, offset: number): number {
const expansion = expansionAt(doc, offset);
if (expansion == null) return offset;
// A match starting inside a character's expansion starts at the character
return offset < expansion.normTo
? expansion.docFrom
: offset - (expansion.normTo - expansion.docTo);
}
/**
* Where scanning picks up after a match ending at `offset`.
*
* The cursor moves through the document a character at a time, so once a match ends inside a
* character's expansion the rest of that expansion is behind it — "…" holds three dots but only
* ever counts as one match of ".".
*/
function resumeAfter(doc: NormalizedDoc, offset: number): number {
const expansion = expansionAt(doc, offset);
return expansion != null && offset > expansion.normFrom && offset < expansion.normTo
? expansion.normTo
: offset;
}
function docEnd(doc: NormalizedDoc, offset: number): number {
const expansion = expansionAt(doc, offset);
if (expansion == null) return offset;
if (offset <= expansion.normFrom) return expansion.docFrom;
// A match ending inside a character's expansion covers the whole character
return offset < expansion.normTo
? expansion.docTo
: offset - (expansion.normTo - expansion.docTo);
}
/** Position of the match holding the selection, counting from one, or 0 when it isn't on one */
export function currentMatch(matches: Match[], selection: { from: number; to: number }): number {
let index = 0;
for (const match of matches) {
index++;
if (match.from <= selection.from && match.to >= selection.to) return index;
}
return 0;
}
/** The text a plain query looks for, or null when only the cursor can answer it */
export function literalSearch(query: SearchQuery): string | null {
if (query.regexp || query.wholeWord || query.test != null) return null;
// Mirrors SearchQuery's own unquoting, which the published type doesn't expose
return query.literal
? query.search
: query.search.replace(/\\([nrt\\])/g, (_, ch) =>
ch === "n" ? "\n" : ch === "r" ? "\r" : ch === "t" ? "\t" : "\\",
);
}
/**
* Finds the matches for the search panel, keeping the normalized document and the matches it
* last found, so neither moving the selection nor typing another character starts over.
*/
export class MatchCounter {
private doc: { doc: Text; caseSensitive: boolean; normalized: NormalizedDoc } | null = null;
private last: { doc: Text; query: SearchQuery; matches: Match[] } | null = null;
matches(state: EditorState, query: SearchQuery): Match[] {
const last = this.last;
if (last != null && last.doc === state.doc && last.query.eq(query)) {
return last.matches;
}
const matches = this.scan(state, query);
this.last = { doc: state.doc, query, matches };
return matches;
}
private scan(state: EditorState, query: SearchQuery): Match[] {
const search = literalSearch(query);
if (search == null) return scanQuery(state, query);
const doc = this.normalizedDoc(state.doc, query.caseSensitive);
return scanNormalized(doc, normalizeSearch(search, query.caseSensitive));
}
private normalizedDoc(doc: Text, caseSensitive: boolean): NormalizedDoc {
const cached = this.doc;
if (cached != null && cached.doc === doc && cached.caseSensitive === caseSensitive) {
return cached.normalized;
}
const normalized = normalizeDoc(doc.toString(), caseSensitive);
this.doc = { doc, caseSensitive, normalized };
return normalized;
}
}
/**
* A CodeMirror extension that displays the total number of search matches
* inside the built-in search panel.
@@ -10,6 +235,7 @@ export function searchMatchCount(): Extension {
return ViewPlugin.fromClass(
class {
private countEl: HTMLElement | null = null;
private counter = new MatchCounter();
constructor(private view: EditorView) {
this.updateCount();
@@ -38,38 +264,21 @@ export function searchMatchCount(): Extension {
}
this.ensureCountEl();
if (this.countEl == null) return;
if (!query.search) {
if (this.countEl) {
this.countEl.textContent = "0/0";
}
this.countEl.textContent = "0/0";
return;
}
const selection = state.selection.main;
let count = 0;
let currentIndex = 0;
const MAX_COUNT = 9999;
const cursor = query.getCursor(state);
for (let result = cursor.next(); !result.done; result = cursor.next()) {
count++;
const match = result.value;
if (match.from <= selection.from && match.to >= selection.to) {
currentIndex = count;
}
if (count > MAX_COUNT) break;
}
if (this.countEl) {
if (count > MAX_COUNT) {
this.countEl.textContent = `${MAX_COUNT}+`;
} else if (count === 0) {
this.countEl.textContent = "0/0";
} else if (currentIndex > 0) {
this.countEl.textContent = `${currentIndex}/${count}`;
} else {
this.countEl.textContent = `0/${count}`;
}
const matches = this.counter.matches(state, query);
if (matches.length > MAX_COUNT) {
this.countEl.textContent = `${MAX_COUNT}+`;
} else if (matches.length === 0) {
this.countEl.textContent = "0/0";
} else {
const current = currentMatch(matches, state.selection.main);
this.countEl.textContent = `${current}/${matches.length}`;
}
}
@@ -9,7 +9,6 @@ import {
useSensor,
useSensors,
} from "@dnd-kit/core";
import { basename } from "@tauri-apps/api/path";
import classNames from "classnames";
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { WrappedEnvironmentVariable } from "../../hooks/useEnvironmentVariables";
@@ -34,6 +33,7 @@ import { Input } from "./Input";
import { ensurePairId } from "./PairEditor.util";
import type { RadioDropdownItem } from "./RadioDropdown";
import { RadioDropdown } from "./RadioDropdown";
import { platform } from "@yaakapp-internal/platform";
export interface PairEditorHandle {
/**
@@ -113,7 +113,15 @@ function toPairData({ commitName: _commitName, ...pair }: EditablePairWithId): P
/** Max number of pairs to show before prompting the user to reveal the rest */
const MAX_INITIAL_PAIRS = 30;
export function PairEditor({
// Keyed on `stateKey` so no state survives a change of owner. Row ids alone can't tell owners
// apart — two pair sets can share ids (eg. one duplicated from the other), and the same-rows
// fast path below would swap in the new data without rebuilding the row editors, leaving any
// still-focused input showing the old owner's text.
export function PairEditor(props: PairEditorProps) {
return <PairEditorInner key={props.stateKey} {...props} />;
}
function PairEditorInner({
allowFileValues,
allowMultilineValues,
className,
@@ -851,7 +859,7 @@ function FileActionsDropdown({
leftSlot: <Icon icon="file_code" />,
onSelect: async () => {
console.log("PAIR", pair);
const defaultFilename = await basename(pair.value ?? "");
const defaultFilename = await platform.files.basename(pair.value ?? "");
const filename = await showPrompt({
id: "filename",
title: "Override Filename",
@@ -6,6 +6,7 @@ import { useStateWithDeps } from "../../hooks/useStateWithDeps";
import { generateId } from "../../lib/generateId";
import { Button } from "./Button";
import { IconButton, type IconButtonProps } from "./IconButton";
import { IconTooltip } from "./IconTooltip";
import { Label } from "./Label";
interface Props<T extends string> {
@@ -36,11 +37,15 @@ export function SegmentedControl<T extends string>({
const containerRef = useRef<HTMLDivElement>(null);
const id = useRef(`input-${generateId()}`);
// A visually hidden label has nowhere to show the help, so the last option carries it
const inlineHelp =
hideLabel && help ? <IconTooltip tabIndex={-1} content={help} iconSize="xs" /> : null;
return (
<div className="w-full grid">
<Label
htmlFor={id.current}
help={help}
help={hideLabel ? undefined : help}
visuallyHidden={hideLabel}
className={classNames(labelClassName)}
>
@@ -78,9 +83,10 @@ export function SegmentedControl<T extends string>({
}
}}
>
{options.map((o) => {
{options.map((o, i) => {
const isSelected = selectedValue === o.value;
const isActive = value === o.value;
const rightSlot = i === options.length - 1 ? inlineHelp : null;
if (o.icon == null) {
return (
<Button
@@ -95,6 +101,7 @@ export function SegmentedControl<T extends string>({
isActive && "text-text!",
"focus:ring-1 focus:ring-border-focus",
)}
rightSlot={rightSlot}
onClick={() => onChange(o.value)}
>
{o.label}
@@ -117,6 +124,7 @@ export function SegmentedControl<T extends string>({
)}
title={o.label}
icon={o.icon}
rightSlot={rightSlot}
onClick={() => onChange(o.value)}
/>
);
+8 -4
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 classNames from "classnames";
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}
</Label>
{type() === "macos" && !filterable ? (
{platform.osType() === "macos" && !filterable ? (
<HStack
space={2}
className={classNames(
@@ -119,9 +119,13 @@ export function Select<T extends string>({
)}
>
<Button
className="w-full text-sm font-mono"
className={classNames(
"w-full text-sm font-mono",
disabled && "border-dotted",
isInvalidSelection && "border-danger",
)}
justify="start"
variant="border"
variant="input"
size={size}
leftSlot={leftSlot}
disabled={disabled}
@@ -1,13 +1,31 @@
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 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 {
orientation?: "horizontal" | "vertical";
dashed?: boolean;
className?: string;
children?: ReactNode;
color?: Color;
action?: SeparatorAction;
}
export function Separator({
@@ -16,15 +34,31 @@ export function Separator({
dashed,
orientation = "horizontal",
children,
action,
}: Props) {
return (
<div role="presentation" className={classNames(className, "flex items-center w-full")}>
{children && (
<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
className={classNames(
"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 === "primary" && "border-primary",
color === "secondary" && "border-secondary",
@@ -21,6 +21,8 @@ import { CommercialUseBanner } from "../CommercialUseBanner";
import { Button } from "../core/Button";
import type { CheckboxProps } from "../core/Checkbox";
import { Checkbox } from "../core/Checkbox";
import type { CheckboxTreeNode } from "../core/CheckboxTree";
import { CheckboxTree } from "../core/CheckboxTree";
import { DiffViewer } from "../core/Editor/DiffViewer";
import { Input } from "../core/Input";
import { Separator } from "../core/Separator";
@@ -43,10 +45,7 @@ interface CommitTreeNode {
export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
const callbacks = useGitCallbacks(syncDir);
const [{ status }, { commit, commitAndPush, add, unstage, restore }] = useGit(
syncDir,
callbacks,
);
const [{ status }, { commit, commitAndPush, add, unstage, restore }] = useGit(syncDir, callbacks);
const [isPushing, setIsPushing] = useState(false);
const [commitError, setCommitError] = useState<string | null>(null);
const [message, setMessage] = useState<string>("");
@@ -143,6 +142,15 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
return next(workspace, []);
}, [workspace, internalEntries]);
const treeNode: CheckboxTreeNode<CommitTreeNode> | null = useMemo(() => {
const toTreeNode = (n: CommitTreeNode): CheckboxTreeNode<CommitTreeNode> => ({
key: n.status.relaPath + n.status.status + n.status.staged,
data: n,
children: n.children.map(toTreeNode),
});
return tree == null ? null : toTreeNode(tree);
}, [tree]);
const checkNode = useCallback(
(treeNode: CommitTreeNode) => {
const checked = nodeCheckedStatus(treeNode);
@@ -190,7 +198,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
[restore],
);
if (tree == null) {
if (tree == null || treeNode == null) {
return null;
}
@@ -221,12 +229,18 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
style={innerStyle}
className="h-full overflow-y-auto pb-3 pr-0.5 transform-cpu"
>
<TreeNodeChildren
node={tree}
depth={0}
onCheck={checkNode}
onSelect={handleSelectChild}
selectedPath={selectedEntry?.relaPath ?? null}
<CheckboxTree
node={treeNode}
checked={(n) => nodeCheckedStatus(n.data)}
onCheck={(n) => checkNode(n.data)}
checkboxTitle={(n) =>
nodeCheckedStatus(n.data) ? "Unstage change" : "Stage change"
}
isRelevant={(n) => n.data.status.status !== "current"}
canSelectRow={(n) => n.data.status.status !== "current"}
onSelectRow={(n) => handleSelectChild(n.data.status)}
isRowSelected={(n) => selectedEntry?.relaPath === n.data.status.relaPath}
renderRow={(n) => <CommitTreeRow node={n.data} />}
/>
{externalEntries.find((e) => e.status !== "current") && (
<>
@@ -244,10 +258,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
</div>
)}
secondSlot={({ style: innerStyle }) => (
<div
style={innerStyle}
className="grid grid-rows-[minmax(0,1fr)_auto] gap-3 pb-2"
>
<div style={innerStyle} className="grid grid-rows-[minmax(0,1fr)_auto] gap-3 pb-2">
<Input
className="text-base! font-sans rounded-md"
placeholder="Commit message..."
@@ -301,96 +312,39 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
);
}
function TreeNodeChildren({
node,
depth,
onCheck,
onSelect,
selectedPath,
}: {
node: CommitTreeNode | null;
depth: number;
onCheck: (node: CommitTreeNode, checked: boolean) => void;
onSelect: (entry: GitStatusEntry) => void;
selectedPath: string | null;
}) {
if (node === null) return null;
if (!isNodeRelevant(node)) return null;
const checked = nodeCheckedStatus(node);
const isSelected = selectedPath === node.status.relaPath;
function CommitTreeRow({ node }: { node: CommitTreeNode }) {
return (
<div
className={classNames(
depth > 0 && "pl-4 ml-2 border-l border-dashed border-border-subtle relative",
)}
>
<div
className={classNames(
"relative flex gap-1 w-full h-xs items-center",
isSelected ? "text-text" : "text-text-subtle",
)}
>
{isSelected && (
<div className="absolute left-[-100vw] right-0 top-0 bottom-0 bg-surface-active opacity-30 -z-10" />
)}
<Checkbox
checked={checked}
title={checked ? "Unstage change" : "Stage change"}
hideLabel
onChange={(checked) => onCheck(node, checked)}
<>
{node.model.model !== "http_request" &&
node.model.model !== "grpc_request" &&
node.model.model !== "websocket_request" ? (
<Icon
color="secondary"
icon={
node.model.model === "folder"
? "folder"
: node.model.model === "environment"
? "variable"
: "house"
}
/>
<button
type="button"
className={classNames("flex-1 min-w-0 flex items-center gap-1 px-1 py-0.5 text-left")}
onClick={() => node.status.status !== "current" && onSelect(node.status)}
) : (
<span aria-hidden className="w-4" />
)}
<div className="truncate flex-1">{resolvedModelName(node.model)}</div>
{node.status.status !== "current" && (
<InlineCode
className={classNames(
"py-0 bg-transparent w-24 text-center shrink-0",
node.status.status === "modified" && "text-info",
node.status.status === "untracked" && "text-success",
node.status.status === "removed" && "text-danger",
)}
>
{node.model.model !== "http_request" &&
node.model.model !== "grpc_request" &&
node.model.model !== "websocket_request" ? (
<Icon
color="secondary"
icon={
node.model.model === "folder"
? "folder"
: node.model.model === "environment"
? "variable"
: "house"
}
/>
) : (
<span aria-hidden className="w-4" />
)}
<div className="truncate flex-1">{resolvedModelName(node.model)}</div>
{node.status.status !== "current" && (
<InlineCode
className={classNames(
"py-0 bg-transparent w-24 text-center shrink-0",
node.status.status === "modified" && "text-info",
node.status.status === "untracked" && "text-success",
node.status.status === "removed" && "text-danger",
)}
>
{node.status.status}
</InlineCode>
)}
</button>
</div>
{node.children.map((childNode) => {
return (
<TreeNodeChildren
key={childNode.status.relaPath + childNode.status.status + childNode.status.staged}
node={childNode}
depth={depth + 1}
onCheck={onCheck}
onSelect={onSelect}
selectedPath={selectedPath}
/>
);
})}
</div>
{node.status.status}
</InlineCode>
)}
</>
);
}
@@ -495,15 +449,6 @@ function setCheckedAndChildren(
if (toUnstage.length > 0) unstage({ relaPaths: toUnstage });
}
function isNodeRelevant(node: CommitTreeNode): boolean {
if (node.status.status !== "current") {
return true;
}
// Recursively check children
return node.children.some((c) => isNodeRelevant(c));
}
function DiffPanel({
entry,
onDiscardChanges,
@@ -526,13 +471,11 @@ function DiffPanel({
size="2xs"
variant="border"
onClick={() => onDiscardChanges(entry)}
>Discard Changes</Button>
>
Discard Changes
</Button>
</div>
<DiffViewer
original={prevYaml ?? ""}
modified={nextYaml ?? ""}
className="flex-1 min-h-0"
/>
<DiffViewer original={prevYaml ?? ""} modified={nextYaml ?? ""} className="flex-1 min-h-0" />
</div>
);
}
+1 -3
View File
@@ -86,10 +86,8 @@ export async function promptDivergedStrategy({
showDialog({
id: "git-diverged",
title: "Branches Diverged",
hideX: true,
size: "sm",
disableBackdropClose: true,
onClose: () => resolve("cancel"),
disableClose: true,
render: ({ hide }) =>
DivergedDialog({
remote,
@@ -1,4 +1,5 @@
import type { HttpRequest } from "@yaakapp-internal/models";
import { platform } from "@yaakapp-internal/platform";
import { useAtom } from "jotai";
import { useCallback, useEffect, useMemo } from "react";
@@ -11,6 +12,7 @@ import type { DropdownItem } from "../core/Dropdown";
import { Dropdown } from "../core/Dropdown";
import type { EditorProps } from "../core/Editor/Editor";
import { Editor } from "../core/Editor/LazyEditor";
import { IconButton } from "../core/IconButton";
import type { RadioDropdownItem } from "../core/RadioDropdown";
import { RadioDropdown } from "../core/RadioDropdown";
import { Banner, FormattedError, Icon } from "@yaakapp-internal/ui";
@@ -18,6 +20,7 @@ import { Separator } from "../core/Separator";
import { tryFormatGraphql } from "../../lib/formatters";
import { parseGraphQLOperationNames } from "../../lib/graphqlOperationNames";
import { normalizeGraphQLBody } from "../../lib/requestBodyConversion";
import { revealInFinderText } from "../../lib/reveal";
import { showGraphQLDocExplorerAtom } from "./graphqlAtoms";
type Props = Pick<EditorProps, "heightMode" | "className" | "forceUpdateKey"> & {
@@ -28,6 +31,10 @@ type Props = Pick<EditorProps, "heightMode" | "className" | "forceUpdateKey"> &
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) {
// 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.
@@ -38,9 +45,41 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
const [autoIntrospectDisabled, setAutoIntrospectDisabled] = useLocalStorage<
Record<string, boolean>
>("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],
});
// 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<{
query: string;
variables: string | undefined;
@@ -160,14 +199,37 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
...((schema != null
? [
{
label: "Clear",
label: "Clear Schema",
onSelect: clear,
color: "danger",
leftSlot: <Icon icon="trash" />,
},
{ type: "separator" },
]
: []) 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,
label: (
@@ -210,25 +272,33 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
type: "content",
},
{
hidden: schema == null,
label: `${isDocOpen ? "Hide" : "Show"} Documentation`,
leftSlot: <Icon icon="book_open_text" />,
onSelect: () => {
setGraphqlDocStateAtomValue((v) => ({
...v,
[request.id]: isDocOpen ? undefined : null,
}));
// One refresh action for either source: re-read the file, or
// re-introspect the server.
label: "Reload Schema",
leftSlot: <Icon icon="refresh" spin={isLoading} />,
keepOpenOnSelect: true,
// Failures surface through the hook's error state either way.
onSelect: async () => {
if (filePath != null) await reloadFromFile();
else await refetch();
},
},
{
label: "Introspect Schema",
leftSlot: <Icon icon="refresh" spin={isLoading} />,
keepOpenOnSelect: true,
onSelect: refetch,
label: filePath == null ? "Load Schema from File…" : "Load a Different File…",
leftSlot: <Icon icon="import" />,
onSelect: handleLoadFromFile,
},
{ type: "separator", label: "Setting" },
{
label: "Automatic Introspection",
hidden: filePath == null,
label: "Stop Using File",
leftSlot: <Icon icon="x" />,
onSelect: removeSchemaFile,
},
{ type: "separator", label: "Settings" },
{
// Governs both sources: re-introspecting the server, and
// re-reading the file when the request is opened.
label: filePath == null ? "Automatic Introspection" : "Automatic Reload",
keepOpenOnSelect: true,
onSelect: () => {
setAutoIntrospectDisabled({
@@ -261,6 +331,29 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
</Dropdown>
)}
</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,
@@ -272,6 +365,11 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
isLoading,
operationNames,
refetch,
handleLoadFromFile,
reloadFromFile,
removeSchemaFile,
filePath,
fileName,
autoIntrospectDisabled,
baseRequest.id,
setGraphqlDocStateAtomValue,
@@ -1,29 +1,29 @@
import { convertFileSrc } from "@tauri-apps/api/core";
import { useEffect, useState } from "react";
interface Props {
bodyPath?: string;
/** A URL for the body the host already stored. */
bodyUrl?: string;
data?: Uint8Array;
mimeType?: string;
}
export function AudioViewer({ bodyPath, data, mimeType }: Props) {
export function AudioViewer({ bodyUrl, data, mimeType }: Props) {
const [src, setSrc] = useState<string>();
useEffect(() => {
if (bodyPath) {
setSrc(convertFileSrc(bodyPath));
if (bodyUrl) {
setSrc(bodyUrl);
} else if (data) {
// 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
const blob = new Blob([new Uint8Array(data)], { type: mimeType ?? "audio/mpeg" });
const url = URL.createObjectURL(blob);
setSrc(url);
return () => URL.revokeObjectURL(url);
const objectUrl = URL.createObjectURL(blob);
setSrc(objectUrl);
return () => URL.revokeObjectURL(objectUrl);
} else {
setSrc(undefined);
}
}, [bodyPath, data, mimeType]);
}, [bodyUrl, data, mimeType]);
// oxlint-disable-next-line jsx-a11y/media-has-caption
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 }) {
const parsed = useMemo(() => {
if (text == null) return null;
return Papa.parse<Record<string, string>>(text, { header: true, skipEmptyLines: true });
return Papa.parse<string[]>(text, { skipEmptyLines: true });
}, [text]);
if (parsed === null) return null;
const header = parsed.data[0] ?? [];
const rows = parsed.data.slice(1);
const columnCount = parsed.data.reduce((count, row) => Math.max(count, row.length), 0);
const columnIndexes = Array.from({ length: columnCount }, (_, index) => index);
return (
<div className="overflow-auto h-full">
<Table className={classNames(className, "text-sm")}>
<TableHead>
<TableRow>
{parsed.meta.fields?.map((field) => (
<TableHeaderCell key={field}>{field}</TableHeaderCell>
{columnIndexes.map((columnIndex) => (
<TableHeaderCell key={columnIndex}>{header[columnIndex] ?? ""}</TableHeaderCell>
))}
</TableRow>
</TableHead>
<TableBody>
{parsed.data.map((row, i) => (
{rows.map((row, i) => (
// oxlint-disable-next-line react/no-array-index-key
<TableRow key={i}>
{parsed.meta.fields?.map((key) => (
<TableCell key={key}>{row[key] ?? ""}</TableCell>
{row.map((cell, columnIndex) => (
// oxlint-disable-next-line react/no-array-index-key
<TableCell key={columnIndex}>{cell}</TableCell>
))}
</TableRow>
))}
@@ -1,7 +1,9 @@
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 { useResponseBodyText } from "../../hooks/useResponseBodyText";
import { responseBodyTextQuery, useResponseBodyText } from "../../hooks/useResponseBodyText";
import { useResponseFilter } from "../../hooks/useResponseFilter";
import { useSaveResponse } from "../../hooks/useSaveResponse";
import { languageFromContentType } from "../../lib/contentType";
import { getContentTypeFromHeaders } from "../../lib/model_util";
@@ -52,30 +54,25 @@ interface HttpTextViewerProps {
}
function HttpTextViewer({ response, text, language, pretty, className }: HttpTextViewerProps) {
const [currentFilter, setCurrentFilter] = useState<string | null>(null);
const filteredBody = useResponseBodyText({ response, filter: currentFilter });
const queryClient = useQueryClient();
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 copyResponse = useCopyHttpResponse(response);
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 (
<TextViewer
text={text}
language={language}
stateKey={`response.body.${response.id}`}
filterStateKey={`response.body.${response.requestId}`}
pretty={pretty}
className={className}
footerActions={[
@@ -98,7 +95,12 @@ function HttpTextViewer({ response, text, language, pretty, className }: HttpTex
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 { useEffect, useState } from "react";
type Props = { className?: string; mimeType?: string } & (
| {
bodyPath: string;
/** A URL for the body the host already stored. */
bodyUrl: string;
}
| {
data: ArrayBuffer;
@@ -13,21 +13,21 @@ type Props = { className?: string; mimeType?: string } & (
export function ImageViewer({ className, mimeType, ...props }: Props) {
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;
useEffect(() => {
if (bodyPath != null) {
setSrc(convertFileSrc(bodyPath));
if (bodyUrl != null) {
setSrc(bodyUrl);
} else if (data != null) {
const blob = new Blob([data], { type: mimeType ?? "image/png" });
const url = URL.createObjectURL(blob);
setSrc(url);
return () => URL.revokeObjectURL(url);
const objectUrl = URL.createObjectURL(blob);
setSrc(objectUrl);
return () => URL.revokeObjectURL(objectUrl);
} else {
setSrc(undefined);
}
}, [bodyPath, data, mimeType]);
}, [bodyUrl, data, mimeType]);
return (
<img
@@ -1,6 +1,5 @@
import "react-pdf/dist/Page/TextLayer.css";
import "react-pdf/dist/Page/AnnotationLayer.css";
import { convertFileSrc } from "@tauri-apps/api/core";
import "./PdfViewer.css";
import type { PDFDocumentProxy } from "pdfjs-dist";
import { useMemo, useRef, useState } from "react";
@@ -18,7 +17,8 @@ fireAndForget(
);
interface Props {
bodyPath?: string;
/** A URL for the body the host already stored. */
bodyUrl?: string;
data?: Uint8Array;
}
@@ -27,7 +27,7 @@ const options = {
standardFontDataUrl: "/standard_fonts/",
};
export function PdfViewer({ bodyPath, data }: Props) {
export function PdfViewer({ bodyUrl, data }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
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
// `Document` renders its "Failed to load PDF file" state for that frame before recovering
const src = useMemo(() => {
if (bodyPath) {
return convertFileSrc(bodyPath);
if (bodyUrl) {
return bodyUrl;
}
if (data) {
// 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 undefined;
}, [bodyPath, data]);
}, [bodyUrl, data]);
const onDocumentLoadSuccess = ({ numPages: nextNumPages }: PDFDocumentProxy): void => {
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 { Children, useCallback, useMemo } from "react";
import { createGlobalState } from "react-use";
import { useDebouncedValue } from "@yaakapp-internal/ui";
import { Banner, HStack, Icon, InlineCode } from "@yaakapp-internal/ui";
import { useFormatText } from "../../hooks/useFormatText";
import type { ResponseFilterApi } from "../../hooks/useResponseFilter";
import { Button } from "../core/Button";
import type { EditorProps } from "../core/Editor/Editor";
import { hyperlink } from "../core/Editor/hyperlink/extension";
import { Editor } from "../core/Editor/LazyEditor";
import { IconButton } from "../core/IconButton";
import { Input } from "../core/Input";
import { RecentFiltersDropdown } from "./RecentFiltersDropdown";
const extraExtensions = [hyperlink];
@@ -16,57 +17,45 @@ interface Props {
text: string;
language: EditorProps["language"];
stateKey: string | null;
filterStateKey?: string | null;
pretty?: boolean;
className?: string;
footerActions?: ReactNode;
onFilter?: (filter: string) => {
filter?: ResponseFilterApi;
filterResult?: {
data: string | null | undefined;
isPending: boolean;
error: boolean;
};
}
const useFilterText = createGlobalState<Record<string, string | null>>({});
export function TextViewer({
language,
text,
stateKey,
filterStateKey,
pretty,
className,
footerActions,
onFilter,
filter,
filterResult,
}: Props) {
const filterKey = filterStateKey ?? stateKey;
const [filterTextMap, setFilterTextMap] = useFilterText();
const filterText = filterKey ? (filterTextMap[filterKey] ?? null) : null;
const debouncedFilterText = useDebouncedValue(filterText);
const setFilterText = useCallback(
(v: string | null) => {
if (!filterKey) return;
setFilterTextMap((m) => ({ ...m, [filterKey]: v }));
const canFilter =
filter != null && (language === "json" || language === "xml" || language === "html");
const isSearching = filter?.isSearching ?? false;
const appliedFilter = filter?.appliedFilter ?? null;
const resultError = filterResult?.error ?? false;
const handleFilterKeyDown = useCallback(
(e: KeyboardEvent) => {
if (filter == null) return;
if (e.key === "Escape") {
filter.toggleSearch();
} else if (e.key === "Enter" && filter.filterText != null) {
filter.applyFilter(filter.filterText);
}
},
[filterKey, setFilterTextMap],
[filter],
);
const isSearching = filterText != null;
const filteredResponse =
onFilter && debouncedFilterText
? onFilter(debouncedFilterText)
: { data: null, isPending: false, error: false };
const toggleSearch = useCallback(() => {
if (isSearching) {
setFilterText(null);
} else {
setFilterText("");
}
}, [isSearching, setFilterText]);
const canFilter = onFilter && (language === "json" || language === "xml" || language === "html");
const actions = useMemo<ReactNode[]>(() => {
const nodes: ReactNode[] = isSearching ? [] : Children.toArray(footerActions);
@@ -76,8 +65,8 @@ export function TextViewer({
nodes.push(
<div key="input" className="w-full opacity-100!">
<Input
key={filterKey ?? "filter"}
validate={!filteredResponse.error}
key={filter.stateKey ?? "filter"}
validate={!resultError}
hideLabel
autoFocus
containerClassName="bg-surface"
@@ -85,39 +74,62 @@ export function TextViewer({
placeholder={language === "json" ? "JSONPath expression" : "XPath expression"}
label="Filter expression"
name="filter"
defaultValue={filterText}
onKeyDown={(e) => e.key === "Escape" && toggleSearch()}
onChange={setFilterText}
stateKey={filterKey ? `filter.${filterKey}` : null}
defaultValue={filter.filterText}
forceUpdateKey={filter.filterUpdateKey}
onKeyDown={handleFilterKeyDown}
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>,
);
} else {
nodes.push(
<IconButton
key="icon"
size="sm"
isLoading={filterResult?.isPending ?? false}
icon="filter"
title="Filter response"
onClick={filter.toggleSearch}
className="border border-border-subtle!"
/>,
);
}
nodes.push(
<IconButton
key="icon"
size="sm"
isLoading={filteredResponse.isPending}
icon={isSearching ? "x" : "filter"}
title={isSearching ? "Close filter" : "Filter response"}
onClick={toggleSearch}
className={classNames("border border-border-subtle!", isSearching && "opacity-100!")}
/>,
);
return nodes;
}, [
canFilter,
footerActions,
filterKey,
filterText,
filteredResponse.error,
filteredResponse.isPending,
filter,
filterResult?.isPending,
resultError,
isSearching,
language,
setFilterText,
toggleSearch,
handleFilterKeyDown,
]);
const formattedBody = useFormatText({ text, language, pretty: pretty ?? false });
@@ -126,11 +138,11 @@ export function TextViewer({
}
let body: string;
if (isSearching && filterText?.length > 0) {
if (filteredResponse.error) {
if (appliedFilter) {
if (resultError) {
body = "";
} else {
body = filteredResponse.data != null ? filteredResponse.data : "";
body = filterResult?.data != null ? filterResult.data : "";
}
} else {
body = formattedBody;
@@ -143,15 +155,61 @@ export function TextViewer({
}
return (
<Editor
readOnly
className={className}
defaultValue={body}
language={language}
actions={actions}
extraExtensions={extraExtensions}
stateKey={stateKey}
/>
<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
readOnly
className={className}
defaultValue={body}
language={language}
actions={actions}
extraExtensions={extraExtensions}
stateKey={stateKey}
/>
</div>
);
}
/**
* Shows what's actually filtering the body, which the filter box below can't convey
* once it holds an edited expression that hasn't been applied yet.
*/
function AppliedFilterBar({
filter,
error,
onClear,
}: {
filter: string;
error: boolean;
onClear: () => void;
}) {
return (
<Banner color={error ? "danger" : "info"} className="py-1! mb-2! text-sm">
<HStack space={2} className="min-w-0">
<Icon icon="filter" size="xs" className="shrink-0 opacity-70" />
<span className="truncate min-w-0" title={filter}>
Response filtered by <InlineCode>{filter}</InlineCode>
{error && " (invalid expression)"}
</span>
<Button
size="2xs"
variant="border"
color={error ? "danger" : "info"}
className="ml-auto shrink-0"
onClick={onClear}
>
Clear
</Button>
</HStack>
</Banner>
);
}
@@ -1,28 +1,28 @@
import { convertFileSrc } from "@tauri-apps/api/core";
import { useEffect, useState } from "react";
interface Props {
bodyPath?: string;
/** A URL for the body the host already stored. */
bodyUrl?: string;
data?: Uint8Array;
mimeType?: string;
}
export function VideoViewer({ bodyPath, data, mimeType }: Props) {
export function VideoViewer({ bodyUrl, data, mimeType }: Props) {
const [src, setSrc] = useState<string>();
useEffect(() => {
if (bodyPath) {
setSrc(convertFileSrc(bodyPath));
if (bodyUrl) {
setSrc(bodyUrl);
} else if (data) {
// 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 url = URL.createObjectURL(blob);
setSrc(url);
return () => URL.revokeObjectURL(url);
const objectUrl = URL.createObjectURL(blob);
setSrc(objectUrl);
return () => URL.revokeObjectURL(objectUrl);
} else {
setSrc(undefined);
}
}, [bodyPath, data, mimeType]);
}, [bodyUrl, data, mimeType]);
// oxlint-disable-next-line jsx-a11y/media-has-caption
return <video className="w-full" controls src={src} />;
+8 -6
View File
@@ -1,5 +1,5 @@
// 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 { fireAndForget } from "./lib/fireAndForget";
import { getSettings } from "./lib/settings";
@@ -8,10 +8,12 @@ function setFontSizeOnDocument(fontSize: number) {
document.documentElement.style.fontSize = `${fontSize}px`;
}
listen<ModelPayload>("model_write", async (event) => {
if (event.payload.change.type !== "upsert") return;
if (event.payload.model.model !== "settings") return;
setFontSizeOnDocument(event.payload.model.interfaceFontSize);
}).catch(console.error);
platform.listen<ModelPayload[]>("model_writes", (payloads) => {
for (const payload of payloads) {
if (payload.change.type !== "upsert") continue;
if (payload.model.model !== "settings") continue;
setFontSizeOnDocument(payload.model.interfaceFontSize);
}
});
fireAndForget(getSettings().then((settings) => setFontSizeOnDocument(settings.interfaceFontSize)));
+8 -6
View File
@@ -1,5 +1,5 @@
// 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 { fireAndForget } from "./lib/fireAndForget";
import { getSettings } from "./lib/settings";
@@ -12,10 +12,12 @@ function setFonts(settings: Settings) {
);
}
listen<ModelPayload>("model_write", async (event) => {
if (event.payload.change.type !== "upsert") return;
if (event.payload.model.model !== "settings") return;
setFonts(event.payload.model);
}).catch(console.error);
platform.listen<ModelPayload[]>("model_writes", (payloads) => {
for (const payload of payloads) {
if (payload.change.type !== "upsert") continue;
if (payload.model.model !== "settings") continue;
setFonts(payload.model);
}
});
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";
export function useCancelHttpResponse(id: string | null) {
return useFastMutation<void>({
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 { appInfo } from "../lib/appInfo";
import { minPromiseMillis } from "../lib/minPromiseMillis";
import { invokeCmd } from "../lib/tauri";
import { rpc } from "../lib/rpc";
export function useCheckForUpdates() {
return useMutation({
mutationKey: ["check_for_updates"],
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) {
showAlert({
id: "no-updates",
@@ -1,4 +1,4 @@
import { invokeCmd } from "../lib/tauri";
import { rpc } from "../lib/rpc";
import { useFastMutation } from "./useFastMutation";
export function useDeleteGrpcConnections(requestId?: string) {
@@ -6,7 +6,7 @@ export function useDeleteGrpcConnections(requestId?: string) {
mutationKey: ["delete_grpc_connections", requestId],
mutationFn: async () => {
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";
export function useDeleteHttpResponses(requestId?: string) {
@@ -6,7 +6,7 @@ export function useDeleteHttpResponses(requestId?: string) {
mutationKey: ["delete_http_responses", requestId],
mutationFn: async () => {
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 { jotaiStore } from "../lib/jotai";
import { pluralizeCount } from "../lib/pluralize";
import { invokeCmd } from "../lib/tauri";
import { rpc } from "../lib/rpc";
import { activeWorkspaceIdAtom } from "./useActiveWorkspace";
import { useFastMutation } from "./useFastMutation";
@@ -45,7 +45,7 @@ export function useDeleteSendHistory() {
if (!confirmed) return false;
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
await invokeCmd("cmd_delete_send_history", { workspaceId });
await rpc("cmd_delete_send_history", { workspaceId });
return true;
},
});
+3 -3
View File
@@ -6,7 +6,7 @@ import type {
GetFolderActionsResponse,
} from "@yaakapp-internal/plugins";
import { useMemo } from "react";
import { invokeCmd } from "../lib/tauri";
import { rpc } from "../lib/rpc";
import { usePluginsKey } from "./usePlugins";
export type CallableFolderAction = Pick<FolderAction, "label" | "icon"> & {
@@ -30,7 +30,7 @@ export function useFolderActions() {
}
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) =>
r.actions.map((a, i) => ({
label: a.label,
@@ -41,7 +41,7 @@ export async function getFolderActions() {
pluginRefId: r.pluginRefId,
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 { emit } from "@tauri-apps/api/event";
import { platform } from "@yaakapp-internal/platform";
import type { GrpcConnection, GrpcRequest } from "@yaakapp-internal/models";
import { flushAllModelWrites } from "@yaakapp-internal/models";
import { jotaiStore } from "../lib/jotai";
import { minPromiseMillis } from "../lib/minPromiseMillis";
import { invokeCmd } from "../lib/tauri";
import { rpc } from "../lib/rpc";
import { activeEnvironmentIdAtom, useActiveEnvironment } from "./useActiveEnvironment";
import { useDebouncedValue } from "@yaakapp-internal/ui";
@@ -25,7 +25,7 @@ export function useGrpc(
mutationKey: ["grpc_go", conn?.id],
mutationFn: async () => {
await flushAllModelWrites(); // The backend reads the request from the DB
return invokeCmd<void>("cmd_grpc_go", {
return rpc<void>("cmd_grpc_go", {
requestId,
environmentId: environment?.id,
protoFiles,
@@ -36,17 +36,17 @@ export function useGrpc(
const send = useMutation({
mutationKey: ["grpc_send", conn?.id],
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({
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({
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);
@@ -61,7 +61,7 @@ export function useGrpc(
queryFn: () => {
const environmentId = jotaiStore.get(activeEnvironmentIdAtom);
return minPromiseMillis<ReflectResponseService[]>(
invokeCmd("cmd_grpc_reflect", { requestId, protoFiles, environmentId }),
rpc("cmd_grpc_reflect", { requestId, protoFiles, environmentId }),
300,
);
},
@@ -6,7 +6,7 @@ import type {
GrpcRequestAction,
} from "@yaakapp-internal/plugins";
import { useMemo } from "react";
import { invokeCmd } from "../lib/tauri";
import { rpc } from "../lib/rpc";
import { getGrpcProtoFiles } from "./useGrpcProtoFiles";
import { usePluginsKey } from "./usePlugins";
@@ -33,7 +33,7 @@ export function useGrpcRequestActions() {
}
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) =>
r.actions.map((a, i) => ({
@@ -46,7 +46,7 @@ export async function getGrpcRequestActions() {
pluginRefId: r.pluginRefId,
args: { grpcRequest, protoFiles },
};
await invokeCmd("cmd_call_grpc_request_action", { req: payload });
await rpc("cmd_call_grpc_request_action", { req: payload });
},
})),
);
+29 -15
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 { settingsAtom } from "@yaakapp-internal/models";
import { atom, useAtomValue } from "jotai";
@@ -102,7 +102,7 @@ const defaultHotkeysOther: Record<HotkeyAction, string[]> = {
/** Get the default hotkeys for the current platform */
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 */
export const hotkeysAtom = atom((get) => {
@@ -112,9 +112,12 @@ export const hotkeysAtom = atom((get) => {
// Merge default hotkeys with custom hotkeys from settings
// Custom hotkeys override defaults for the same action
// An empty array means the hotkey is intentionally disabled
const merged: Record<HotkeyAction, string[]> = { ...defaultHotkeys };
const merged: Partial<Record<HotkeyAction, string[]>> = {};
for (const action of hotkeyActions) {
merged[action] = defaultHotkeys[action];
}
for (const [action, keys] of Object.entries(customHotkeys)) {
if (action in defaultHotkeys && Array.isArray(keys)) {
if (action in merged && Array.isArray(keys)) {
merged[action as HotkeyAction] = keys;
}
}
@@ -122,7 +125,7 @@ export const hotkeysAtom = atom((get) => {
});
/** Helper function to get current hotkeys from the store */
function getHotkeys(): Record<HotkeyAction, string[]> {
function getHotkeys(): Partial<Record<HotkeyAction, string[]>> {
return jotaiStore.get(hotkeysAtom);
}
@@ -165,16 +168,25 @@ const layoutInsensitiveKeys = [
"Space",
];
/** Zoom is the browser's own on these keys, so the app has no such action there. */
const ZOOM_ACTIONS: HotkeyAction[] = ["app.zoom_in", "app.zoom_out", "app.zoom_reset"];
/**
* The actions this host actually has. An action left out of here has no keys in
* `hotkeysAtom`, so it never matches and never claims the keystroke.
*/
export const hotkeyActions: HotkeyAction[] = (
Object.keys(defaultHotkeys) as (keyof typeof defaultHotkeys)[]
).sort((a, b) => {
const scopeA = a.split(".")[0] || "";
const scopeB = b.split(".")[0] || "";
if (scopeA !== scopeB) {
return scopeA.localeCompare(scopeB);
}
return hotkeyLabels[a].localeCompare(hotkeyLabels[b]);
});
)
.filter((a) => platform.capabilities.interfaceZoom || !ZOOM_ACTIONS.includes(a))
.sort((a, b) => {
const scopeA = a.split(".")[0] || "";
const scopeB = b.split(".")[0] || "";
if (scopeA !== scopeB) {
return scopeA.localeCompare(scopeB);
}
return hotkeyLabels[a].localeCompare(hotkeyLabels[b]);
});
export type HotKeyOptions = {
enable?: boolean | (() => boolean);
@@ -318,7 +330,7 @@ export function getHotkeyScope(action: HotkeyAction): string {
}
export function formatHotkeyString(trigger: string): string[] {
const os = type();
const os = platform.osType();
const parts = trigger.split("+");
const labelParts: string[] = [];
@@ -333,7 +345,9 @@ export function formatHotkeyString(trigger: string): string[] {
} else if (p === "Alt") {
labelParts.push("⌥");
} 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") {
labelParts.push("⇥");
} else if (p === "Backspace") {
@@ -3,7 +3,7 @@ import type { GetHttpAuthenticationSummaryResponse } from "@yaakapp-internal/plu
import { atom, useAtomValue } from "jotai";
import { useState } from "react";
import { jotaiStore } from "../lib/jotai";
import { invokeCmd } from "../lib/tauri";
import { rpc } from "../lib/rpc";
import { showErrorToast } from "../lib/toast";
import { usePluginsKey } from "./usePlugins";
@@ -31,7 +31,7 @@ export function useSubscribeHttpAuthentication() {
placeholderData: (prev) => prev, // Keep previous data on refetch
queryFn: async () => {
try {
const result = await invokeCmd<GetHttpAuthenticationSummaryResponse[]>(
const result = await rpc<GetHttpAuthenticationSummaryResponse[]>(
"cmd_get_http_authentication_summaries",
);
setNumResults(result.length);
@@ -11,7 +11,7 @@ import type { GetHttpAuthenticationConfigResponse, JsonPrimitive } from "@yaakap
import { useAtomValue } from "jotai";
import { md5 } from "js-md5";
import { useState } from "react";
import { invokeCmd } from "../lib/tauri";
import { rpc } from "../lib/rpc";
import { activeEnvironmentIdAtom } from "./useActiveEnvironment";
import { activeWorkspaceIdAtom } from "./useActiveWorkspace";
@@ -48,7 +48,7 @@ export function useHttpAuthenticationConfig(
placeholderData: (prev) => prev, // Keep previous data on refetch
queryFn: async () => {
if (authName == null || authName === "inherit") return null;
const config = await invokeCmd<GetHttpAuthenticationConfigResponse>(
const config = await rpc<GetHttpAuthenticationConfigResponse>(
"cmd_get_http_authentication_config",
{
authName,
@@ -65,7 +65,7 @@ export function useHttpAuthenticationConfig(
call: async (
model: HttpRequest | GrpcRequest | WebsocketRequest | Folder | Workspace,
) => {
await invokeCmd("cmd_call_http_authentication_action", {
await rpc("cmd_call_http_authentication_action", {
pluginRefId: config.pluginRefId,
actionIndex: i,
authName,
@@ -6,7 +6,7 @@ import type {
HttpRequestAction,
} from "@yaakapp-internal/plugins";
import { useMemo } from "react";
import { invokeCmd } from "../lib/tauri";
import { rpc } from "../lib/rpc";
import { usePluginsKey } from "./usePlugins";
export type CallableHttpRequestAction = Pick<HttpRequestAction, "label" | "icon"> & {
@@ -30,7 +30,7 @@ export function useHttpRequestActions() {
}
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) =>
r.actions.map((a, i) => ({
label: a.label,
@@ -41,7 +41,7 @@ export async function getHttpRequestActions() {
pluginRefId: r.pluginRefId,
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 type { HttpResponse } from "@yaakapp-internal/models";
import { invokeCmd } from "../lib/tauri";
import { rpc } from "../lib/rpc";
export function useHttpRequestBody(response: HttpResponse | null) {
return useQuery({
@@ -18,7 +18,7 @@ export async function getRequestBodyText(response: HttpResponse | 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,
});
@@ -1,4 +1,3 @@
import { invoke } from "@tauri-apps/api/core";
import type { HttpResponse, HttpResponseEvent } from "@yaakapp-internal/models";
import {
httpResponseEventsAtom,
@@ -8,6 +7,7 @@ import {
import { useAtomValue } from "jotai";
import { useEffect } from "react";
import { fireAndForget } from "../lib/fireAndForget";
import { rpc } from "../lib/rpc";
export function useHttpResponseEvents(response: HttpResponse | null) {
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
fireAndForget(
invoke<HttpResponseEvent[]>("cmd_get_http_response_events", { responseId: response.id }).then(
rpc<HttpResponseEvent[]>("cmd_get_http_response_events", { responseId: response.id }).then(
(events) =>
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 { invoke } from "@tauri-apps/api/core";
import type { GraphQlIntrospection, HttpRequest } from "@yaakapp-internal/models";
import { platform } from "@yaakapp-internal/platform";
import type { GraphQLSchema, IntrospectionQuery } 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 { getResponseBodyText } from "../lib/responseBody";
import { sendEphemeralRequest } from "../lib/sendEphemeralRequest";
import { useActiveEnvironment } from "./useActiveEnvironment";
import { useGraphQLSchemaFile } from "./useGraphQLSchemaFile";
import { useDebouncedValue } from "@yaakapp-internal/ui";
import { rpc } from "../lib/rpc";
const introspectionRequestBody = JSON.stringify({
query: getIntrospectionQuery(),
@@ -30,9 +32,14 @@ export function useIntrospectGraphQL(
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(
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,
workspaceId: baseRequest.workspaceId,
content: content ?? "",
@@ -54,7 +61,7 @@ export function useIntrospectGraphQL(
bodyType: "application/json",
body: { text: introspectionRequestBody },
};
const response = await minPromiseMillis(
const { response, body } = await minPromiseMillis(
sendEphemeralRequest(args, activeEnvironment?.id ?? null),
700,
);
@@ -63,14 +70,16 @@ export function useIntrospectGraphQL(
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) {
return setError(
`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");
}
@@ -90,15 +99,109 @@ export function useIntrospectGraphQL(
return;
}
refetch().catch(console.error);
}, [baseRequest.id, debouncedRequest.url, debouncedRequest.method, activeEnvironment?.id]);
// A request pointed at a file gets its schema from that file. Introspecting
// 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 () => {
setError("");
setSchema(null);
await upsertIntrospection(null);
}, [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(() => {
if (introspection.data?.content == null || introspection.data.content === "") {
return;
@@ -112,14 +215,24 @@ export function useIntrospectGraphQL(
}
}, [introspection.data?.content]);
return { schema, isLoading, error, refetch, clear };
return {
schema,
isLoading,
error,
refetch,
clear,
loadFromFile,
reloadFromFile,
removeSchemaFile,
filePath,
};
}
function useIntrospectionResult(request: HttpRequest) {
return useQuery({
queryKey: ["introspection", request.id],
queryFn: async () =>
invoke<GraphQlIntrospection | null>("models_get_graphql_introspection", {
rpc<GraphQlIntrospection | null>("models_get_graphql_introspection", {
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 {
grpcConnectionsAtom,
@@ -11,6 +10,7 @@ import { useEffect, useMemo } from "react";
import { fireAndForget } from "../lib/fireAndForget";
import { atomWithKVStorage } from "../lib/atoms/atomWithKVStorage";
import { activeRequestIdAtom } from "./useActiveRequestId";
import { rpc } from "../lib/rpc";
const pinnedGrpcConnectionIdsAtom = atomWithKVStorage<Record<string, string | null>>(
"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
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),
),
);
@@ -1,4 +1,3 @@
import { invoke } from "@tauri-apps/api/core";
import type { WebsocketConnection, WebsocketEvent } from "@yaakapp-internal/models";
import {
mergeModelsInStore,
@@ -12,6 +11,7 @@ import { fireAndForget } from "../lib/fireAndForget";
import { atomWithKVStorage } from "../lib/atoms/atomWithKVStorage";
import { jotaiStore } from "../lib/jotai";
import { activeRequestIdAtom } from "./useActiveRequestId";
import { rpc } from "../lib/rpc";
const pinnedWebsocketConnectionIdAtom = atomWithKVStorage<Record<string, string | null>>(
"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
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),
),
);
@@ -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 { useAtomValue } from "jotai";
import { queryClient } from "../lib/queryClient";
import { invokeCmd } from "../lib/tauri";
import { rpc } from "../lib/rpc";
function pluginInfoKey(id: string | null, plugin: Plugin | null) {
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
queryFn: () => {
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 { jotaiStore } from "../lib/jotai";
import { minPromiseMillis } from "../lib/minPromiseMillis";
import { invokeCmd } from "../lib/tauri";
import { rpc } from "../lib/rpc";
import { activeWorkspaceIdAtom } from "./useActiveWorkspace";
import { useDebouncedValue } from "@yaakapp-internal/ui";
import { invalidateAllPluginInfo } from "./usePluginInfo";
@@ -26,7 +26,7 @@ export function useRefreshPlugins() {
mutationFn: async () => {
await minPromiseMillis(
(async () => {
await invokeCmd("cmd_reload_plugins");
await rpc("cmd_reload_plugins");
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
await changeModelStoreWorkspace(workspaceId); // Force refresh models
invalidateAllPluginInfo();
@@ -1,9 +1,15 @@
import { useEffect, useState } from "react";
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() {
const [preferredAppearance, setPreferredAppearance] = useState<Appearance>(getCSSAppearance());
const [preferredAppearance, setPreferredAppearance] = useState<Appearance>(
getSystemAppearance() ?? getCSSAppearance(),
);
useEffect(() => subscribeToPreferredAppearance(setPreferredAppearance), []);
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 { useAtomValue } from "jotai";
import { minPromiseMillis } from "../lib/minPromiseMillis";
import { invokeCmd } from "../lib/tauri";
import { rpc } from "../lib/rpc";
import { useActiveEnvironment } from "./useActiveEnvironment";
import { activeWorkspaceIdAtom } from "./useActiveWorkspace";
@@ -49,7 +49,7 @@ export async function renderTemplate({
purpose: RenderPurpose;
ignoreError?: boolean;
}): Promise<string> {
return invokeCmd("cmd_render_template", {
return rpc("cmd_render_template", {
template,
workspaceId,
environmentId,
@@ -67,5 +67,5 @@ export async function decryptTemplate({
workspaceId: string;
environmentId: string | null;
}): Promise<string> {
return invokeCmd("cmd_decrypt_template", { template, workspaceId, environmentId });
return rpc("cmd_decrypt_template", { template, workspaceId, environmentId });
}
+17 -10
View File
@@ -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 { atom, useAtomValue } from "jotai";
import { generateId } from "../lib/generateId";
@@ -6,25 +6,32 @@ import { jotaiStore } from "../lib/jotai";
const requestUpdateKeyAtom = atom<Record<string, string>>({});
getCurrentWebviewWindow()
.listen<ModelPayload>("model_write", ({ payload }) => {
if (payload.change.type !== "upsert") return;
platform.listen<ModelPayload[]>("model_writes", (payloads) => {
const changedIds: string[] = [];
for (const payload of payloads) {
if (payload.change.type !== "upsert") continue;
if (
(payload.model.model === "http_request" ||
payload.model.model === "grpc_request" ||
payload.model.model === "websocket_request") &&
((payload.updateSource.type === "window" &&
payload.updateSource.label !== getCurrentWebviewWindow().label) ||
payload.updateSource.label !== platform.window.label) ||
payload.updateSource.type !== "window")
) {
wasUpdatedExternally(payload.model.id);
changedIds.push(payload.model.id);
}
})
.catch(console.error);
}
if (changedIds.length > 0) wasUpdatedExternally(changedIds);
});
export function wasUpdatedExternally(changedRequestId: string) {
jotaiStore.set(requestUpdateKeyAtom, (m) => ({ ...m, [changedRequestId]: generateId() }));
export function wasUpdatedExternally(changedRequestIds: string | string[]) {
const ids = Array.isArray(changedRequestIds) ? changedRequestIds : [changedRequestIds];
jotaiStore.set(requestUpdateKeyAtom, (m) => {
const next = { ...m };
for (const id of ids) next[id] = generateId();
return next;
});
}
export function useRequestUpdateKey(requestId: string | null) {
+20 -8
View File
@@ -2,6 +2,25 @@ import { useQuery } from "@tanstack/react-query";
import type { HttpResponse } from "@yaakapp-internal/models";
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({
response,
filter,
@@ -11,14 +30,7 @@ export function useResponseBodyText({
}) {
return useQuery({
placeholderData: (prev) => prev, // Keep previous data on refetch
queryKey: [
"response_body_text",
response.id,
response.updatedAt,
response.contentLength,
filter ?? "",
],
queryFn: () => getResponseBodyText({ response, filter }),
...responseBodyTextQuery({ 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 { getModel } from "@yaakapp-internal/models";
import mime from "mime";
import slugify from "slugify";
import { InlineCode } from "@yaakapp-internal/ui";
import { getContentTypeFromHeaders } from "../lib/model_util";
import { invokeCmd } from "../lib/tauri";
import { rpc } from "../lib/rpc";
import { showToast } from "../lib/toast";
import { useFastMutation } from "./useFastMutation";
import { platform } from "@yaakapp-internal/platform";
export function useSaveResponse(response: HttpResponse | null) {
return useFastMutation({
@@ -21,11 +21,15 @@ export function useSaveResponse(response: HttpResponse | null) {
const contentType = getContentTypeFromHeaders(response.headers) ?? "unknown";
const ext = mime.getExtension(contentType);
const slug = slugify(request.name || "response", { lower: true });
const filepath = await save({
const filepath = await platform.dialog.save({
defaultPath: ext ? `${slug}.${ext}` : slug,
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({
message: (
<>

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