mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
Compare commits
325
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69b7c9921c | ||
|
|
eba8dd5864 | ||
|
|
c0bfe7a97a | ||
|
|
1a9b3f09a5 | ||
|
|
f7402f5f4f | ||
|
|
ca1494b54a | ||
|
|
a14fc18b4b | ||
|
|
62a15af395 | ||
|
|
d12beda8a9 | ||
|
|
a4061d5dee | ||
|
|
bba92c5a23 | ||
|
|
a4355b956a | ||
|
|
e0b435c416 | ||
|
|
29a173a07e | ||
|
|
700003e9a2 | ||
|
|
529be76378 | ||
|
|
6baa230f92 | ||
|
|
7ece5fa5df | ||
|
|
cc83c7cd1e | ||
|
|
c2d4c39c62 | ||
|
|
fd89201ccb | ||
|
|
5e37fca58a | ||
|
|
80b587f507 | ||
|
|
185a4e30df | ||
|
|
9ee37e4015 | ||
|
|
78351f620e | ||
|
|
ba403307d8 | ||
|
|
4e63ba2ce7 | ||
|
|
e870604607 | ||
|
|
b5951b36ae | ||
|
|
a43c3f7c2c | ||
|
|
3f3fd15959 | ||
|
|
52d9c9596b | ||
|
|
4fc95d264c | ||
|
|
13ef9c60f1 | ||
|
|
c78732ecaf | ||
|
|
77226dbedb | ||
|
|
7453869ac6 | ||
|
|
2ca2bc5f00 | ||
|
|
4538123d88 | ||
|
|
91696c2d1f | ||
|
|
cc382b9226 | ||
|
|
f63586f9f0 | ||
|
|
c629d4c66c | ||
|
|
c09ee5cc53 | ||
|
|
603a4ce364 | ||
|
|
1129f7aa93 | ||
|
|
08a98d5f82 | ||
|
|
4ad0533901 | ||
|
|
87a30200f9 | ||
|
|
c9ad753672 | ||
|
|
4e7c0095d8 | ||
|
|
07ed9d75fa | ||
|
|
8c0c76e812 | ||
|
|
60ff38cbdc | ||
|
|
405b8b2567 | ||
|
|
489955a913 | ||
|
|
11cbb96746 | ||
|
|
f1ae62b2dc | ||
|
|
4bfb1a106a | ||
|
|
bf47095cc0 | ||
|
|
0489b67f6c | ||
|
|
143acdbd7e | ||
|
|
27698b15bb | ||
|
|
48a9c7e81f | ||
|
|
3c5429897e | ||
|
|
53e766b2a9 | ||
|
|
cd431186b6 | ||
|
|
f7aea62c07 | ||
|
|
4d38a482f6 | ||
|
|
e82ffc53ad | ||
|
|
96ab0a8a60 | ||
|
|
974b9de673 | ||
|
|
e33aeee5ff | ||
|
|
39a7397ebb | ||
|
|
1a994f7532 | ||
|
|
4abc882e13 | ||
|
|
11aca4539d | ||
|
|
9bbd28ce4a | ||
|
|
686294939b | ||
|
|
6132171791 | ||
|
|
349e6f9197 | ||
|
|
1248112fe0 | ||
|
|
8a85fb3dcd | ||
|
|
277bbe1baa | ||
|
|
62f76c5c36 | ||
|
|
98cf213b9e | ||
|
|
c5662a2851 | ||
|
|
d534e88c26 | ||
|
|
ae354c3261 | ||
|
|
26cbd45f76 | ||
|
|
c81e8ffd5c | ||
|
|
29d54cda0d | ||
|
|
b01c9c34c7 | ||
|
|
9b3715024d | ||
|
|
19908b1902 | ||
|
|
72b43f7110 | ||
|
|
b6c1f700f1 | ||
|
|
b5508e6bb1 | ||
|
|
694c0272cd | ||
|
|
602c6e1083 | ||
|
|
80a707ccc0 | ||
|
|
9d59577dcd | ||
|
|
290fdc3f93 | ||
|
|
c03b483fde | ||
|
|
2c097eba2e | ||
|
|
5664f9b0bb | ||
|
|
ce2064cab6 | ||
|
|
80bf2240f8 | ||
|
|
23fb222957 | ||
|
|
c29a62ba6f | ||
|
|
f8b1785866 | ||
|
|
d2f14c65b3 | ||
|
|
f467e22f3f | ||
|
|
0ca5fc1e2d | ||
|
|
9bda8cea1e | ||
|
|
919e58fc9d | ||
|
|
b4417add33 | ||
|
|
7890281db3 | ||
|
|
d44d75dcef | ||
|
|
7f30253616 | ||
|
|
6c2a18d506 | ||
|
|
0a57d8eb61 | ||
|
|
411aa262c7 | ||
|
|
34815f327a | ||
|
|
6fc43a60e2 | ||
|
|
23d369d84e | ||
|
|
6e7f23a778 | ||
|
|
b03c41b767 | ||
|
|
a0d71eb486 | ||
|
|
38c562f775 | ||
|
|
1249d15f2c | ||
|
|
1707c387ea | ||
|
|
a497d9f018 | ||
|
|
c13e26656b | ||
|
|
dfc974db95 | ||
|
|
0b86d3ca3f | ||
|
|
9e51e95f9e | ||
|
|
aa10c44a45 | ||
|
|
46464cfacf | ||
|
|
263a215f7a | ||
|
|
646d969649 | ||
|
|
4913711968 | ||
|
|
35d4c9d79d | ||
|
|
bbe8d1eef5 | ||
|
|
1e3dfff01e | ||
|
|
fccff5b7db | ||
|
|
6ed25b9f42 | ||
|
|
4a4d3b4301 | ||
|
|
25a464546a | ||
|
|
2c51fc92d6 | ||
|
|
f827aae46d | ||
|
|
caeebebc75 | ||
|
|
621f9c3b8f | ||
|
|
77b18d3477 | ||
|
|
a8c845b091 | ||
|
|
bcbf6d32a7 | ||
|
|
e30b289ed7 | ||
|
|
85904566e7 | ||
|
|
0b6803c6c0 | ||
|
|
edeb433c79 | ||
|
|
12e6e8a977 | ||
|
|
ab616fd915 | ||
|
|
4859aba142 | ||
|
|
cef569ee15 | ||
|
|
ae518dfb4c | ||
|
|
c9990d8492 | ||
|
|
309818200a | ||
|
|
06142d007a | ||
|
|
8a6e4810fd | ||
|
|
5a793e1d42 | ||
|
|
3d6c31440c | ||
|
|
31d55440c1 | ||
|
|
a822cc93d4 | ||
|
|
48889b6550 | ||
|
|
881cff472d | ||
|
|
d465c5679a | ||
|
|
0a68dea133 | ||
|
|
17a44f34a3 | ||
|
|
8c8be31aa2 | ||
|
|
ff5db718ce | ||
|
|
fd95b4cba3 | ||
|
|
7c660f4528 | ||
|
|
0337ef35c9 | ||
|
|
602bd1bb45 | ||
|
|
57ab349901 | ||
|
|
7a6d275fdb | ||
|
|
7d0926c961 | ||
|
|
522b898620 | ||
|
|
cb7ed9264d | ||
|
|
90cb578e26 | ||
|
|
ff7eebf3cd | ||
|
|
eff0c06cb0 | ||
|
|
c6e09a5af3 | ||
|
|
b05071ef4b | ||
|
|
a44f31eeb2 | ||
|
|
1103d05915 | ||
|
|
2f5639a26f | ||
|
|
7f8b7bf567 | ||
|
|
096e83a7bd | ||
|
|
89ce84cefe | ||
|
|
d5d2a64c32 | ||
|
|
d2d4b80a09 | ||
|
|
d461c982ec | ||
|
|
8eebee460e | ||
|
|
81a2a5d955 | ||
|
|
e63a87718c | ||
|
|
222ea9cb83 | ||
|
|
360c098b09 | ||
|
|
fce1039bac | ||
|
|
f18f93d613 | ||
|
|
661a384bed | ||
|
|
50cccf1d25 | ||
|
|
0aae516f3a | ||
|
|
b7b8ae5f94 | ||
|
|
6777003b70 | ||
|
|
426c6d5eb6 | ||
|
|
068afe325e | ||
|
|
cef1129d4b | ||
|
|
9a7bcf73bb | ||
|
|
aa76d501f0 | ||
|
|
ce8cb5e7bc | ||
|
|
66c13d7e13 | ||
|
|
388dd8815f | ||
|
|
cb295b7102 | ||
|
|
4e835cf7e8 | ||
|
|
2350689d7a | ||
|
|
4eebd606ef | ||
|
|
c4f96f3f11 | ||
|
|
71c217d3f0 | ||
|
|
d89831a84d | ||
|
|
3332ae263f | ||
|
|
df1fd864b2 | ||
|
|
a2d54ca774 | ||
|
|
36fec8b005 | ||
|
|
115615d994 | ||
|
|
538f782068 | ||
|
|
3f202ff664 | ||
|
|
131b7e5ab1 | ||
|
|
69083918f9 | ||
|
|
4b2dcf9a1a | ||
|
|
569f552d79 | ||
|
|
33f32cccf6 | ||
|
|
7ca772347f | ||
|
|
b89c448345 | ||
|
|
2021df112a | ||
|
|
2d2a390bfd | ||
|
|
5cce23566a | ||
|
|
e99f6d2bc7 | ||
|
|
bea58b16b4 | ||
|
|
93fba4d9b4 | ||
|
|
b9071eafe0 | ||
|
|
778c74c635 | ||
|
|
0f434361a7 | ||
|
|
d27d11af7c | ||
|
|
1a19a06a23 | ||
|
|
07a9a6c6c0 | ||
|
|
e54240d579 | ||
|
|
8bca013ab4 | ||
|
|
10e962a0e6 | ||
|
|
78954e10c8 | ||
|
|
9eb7a001da | ||
|
|
6a02cbe525 | ||
|
|
6f91f76064 | ||
|
|
32e92d484b | ||
|
|
cdbbef34f8 | ||
|
|
4838353585 | ||
|
|
93001e3da7 | ||
|
|
b31c066717 | ||
|
|
5d1d24870a | ||
|
|
6f0d0ef275 | ||
|
|
85a9b2a908 | ||
|
|
f3f05502d1 | ||
|
|
2e0f7d1818 | ||
|
|
dc793181bb | ||
|
|
7dfa7e07e3 | ||
|
|
23e7229e63 | ||
|
|
1f91cddab9 | ||
|
|
be004425fa | ||
|
|
4f03c5c390 | ||
|
|
cb43f0b9b9 | ||
|
|
68671377fd | ||
|
|
2383f06e71 | ||
|
|
0068be9ffc | ||
|
|
def7d752db | ||
|
|
85aed740a8 | ||
|
|
d3055c64b3 | ||
|
|
912aaeb7a7 | ||
|
|
d0af512a0c | ||
|
|
e032c4c8d6 | ||
|
|
c3fc3fb68a | ||
|
|
7e2db7799b | ||
|
|
c596e25e5e | ||
|
|
74d1b5d6ce | ||
|
|
b9d4f76193 | ||
|
|
d4a963e216 | ||
|
|
be765d7bcc | ||
|
|
d43de3d95a | ||
|
|
bc0494fe0e | ||
|
|
c57a64d455 | ||
|
|
5bebd74b09 | ||
|
|
a6dd058a3d | ||
|
|
1fe8261fbd | ||
|
|
0e4c355e8b | ||
|
|
f6d926f4b9 | ||
|
|
a6be9dbaee | ||
|
|
67a628d67a | ||
|
|
0bf7eaed81 | ||
|
|
784a3d3a32 | ||
|
|
077bfa87b8 | ||
|
|
733f2e5929 | ||
|
|
0f994f89ac | ||
|
|
24f76398f9 | ||
|
|
14475915df | ||
|
|
a50e04f565 | ||
|
|
32fbd66912 | ||
|
|
3503a9da8e | ||
|
|
cef6abf5d0 | ||
|
|
3f098f95fe | ||
|
|
195f89337f | ||
|
|
57fffe5a41 | ||
|
|
0c24d6562a | ||
|
|
b1ea3e3560 | ||
|
|
d72b7d7d30 | ||
|
|
b40e2cdc1b |
No files matched your search
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "yaak",
|
||||
"interface": {
|
||||
"displayName": "Yaak"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "yaak",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./crates-cli/yaak-cli"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL"
|
||||
},
|
||||
"category": "Developer Tools"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/claude-code-marketplace.json",
|
||||
"name": "yaak",
|
||||
"description": "Plugins for Yaak, the desktop API client",
|
||||
"owner": {
|
||||
"name": "Yaak",
|
||||
"url": "https://yaak.app"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "yaak",
|
||||
"source": "./crates-cli/yaak-cli",
|
||||
"description": "Build, save, and send API requests with the yaak CLI, with its MCP server for agents",
|
||||
"category": "development",
|
||||
"tags": ["api", "http", "rest", "graphql", "grpc", "openapi", "api-client"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
**/node_modules
|
||||
dist
|
||||
**/dist
|
||||
target
|
||||
**/target
|
||||
.claude
|
||||
vendored
|
||||
**/vendored
|
||||
*.log
|
||||
.git
|
||||
@@ -5,3 +5,4 @@ crates/yaak-templates/pkg/* linguist-generated=true
|
||||
|
||||
# Ensure consistent line endings for test files that check exact content
|
||||
crates/yaak-http/tests/test.txt text eol=lf
|
||||
crates-proxy/yaak-proxy-lib/tests/fixtures/export.har text eol=lf
|
||||
@@ -0,0 +1,55 @@
|
||||
version: 2
|
||||
|
||||
updates:
|
||||
- package-ecosystem: npm
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
open-pull-requests-limit: 5
|
||||
ignore:
|
||||
# Build tooling majors are opted into, not queued. These never reach a
|
||||
# user, so the cost of being a release behind is low and the cost of a
|
||||
# surprise is a broken build. Note this also suppresses security PRs for
|
||||
# these, so Dependabot alerts are the backstop.
|
||||
- dependency-name: typescript
|
||||
update-types: ["version-update:semver-major"]
|
||||
- dependency-name: vitest
|
||||
update-types: ["version-update:semver-major"]
|
||||
- dependency-name: "@types/*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
- dependency-name: "vite-plugin-*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
# 0.8 dropped stateExtensions and updateSchema from the package root and
|
||||
# exposes no subpath to reach them, which GrpcEditor needs. Unpin once
|
||||
# that editor moves to the jsonSchema helper.
|
||||
- dependency-name: codemirror-json-schema
|
||||
groups:
|
||||
npm-production:
|
||||
dependency-type: production
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
npm-development:
|
||||
dependency-type: development
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
|
||||
- package-ecosystem: cargo
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
open-pull-requests-limit: 5
|
||||
ignore:
|
||||
# Held at 11.10.0 to pin what rolldown_resolver ("^11") resolves to; it
|
||||
# does not build against 11.11. See crates-cli/yaak-cli/Cargo.toml.
|
||||
- dependency-name: oxc_resolver
|
||||
groups:
|
||||
cargo:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
@@ -630,6 +630,22 @@ async function checkPullRequest({
|
||||
const pr = response.data;
|
||||
const issueNumber = pr.number;
|
||||
|
||||
if (pr.user.type === "Bot") {
|
||||
core.notice(
|
||||
`Skipping contribution policy for bot PR #${pr.number} from @${pr.user.login}.`,
|
||||
);
|
||||
return {
|
||||
blocked: false,
|
||||
number: pr.number,
|
||||
summary: summarizeResult({
|
||||
pr,
|
||||
skipped: true,
|
||||
skipReason: `bot @${pr.user.login}`,
|
||||
}),
|
||||
skipped: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
minimumAutomaticPullNumber != null &&
|
||||
pr.number < minimumAutomaticPullNumber
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// Retain only distributable bundle files, never the target tree or signing material.
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
copyFileSync,
|
||||
createReadStream,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
|
||||
const env = process.env;
|
||||
const bundle = join("target", env.RELEASE_TARGET || "", "release", "bundle");
|
||||
const destination = join(env.RUNNER_TEMP, "yaak-release-artifacts");
|
||||
const extensions =
|
||||
/\.(dmg|deb|rpm|AppImage|exe|msi|app\.tar\.gz|AppImage\.tar\.gz|nsis\.zip|msi\.zip)(\.sig)?$/;
|
||||
const files = [];
|
||||
|
||||
function snapshot(directory) {
|
||||
if (!existsSync(directory)) return;
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
const source = join(directory, entry.name);
|
||||
// Only descend through the bundler's immediate output folders. This excludes
|
||||
// .app trees and the extracted CEF deb contents (which contain executables).
|
||||
if (entry.isDirectory() && directory === bundle) {
|
||||
snapshot(source);
|
||||
} else if (
|
||||
entry.isFile() &&
|
||||
(extensions.test(entry.name) || /^yaak-cef_.*_linux_.*\.tar\.gz$/.test(entry.name))
|
||||
) {
|
||||
// Match tauri-action's default macOS updater asset naming exactly.
|
||||
const arch = env.RELEASE_ARCH === "arm64" ? "aarch64" : "x64";
|
||||
const assetName = entry.name.replace(/\.app\.tar\.gz(\.sig)?$/, `_${arch}.app.tar.gz$1`);
|
||||
mkdirSync(destination, { recursive: true });
|
||||
const retained = join(destination, assetName);
|
||||
// The second snapshot must not overwrite the original user installer/signature.
|
||||
if (!existsSync(retained)) copyFileSync(source, retained);
|
||||
files.push({ source: relative(".", source), assetName });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
snapshot(bundle);
|
||||
if (existsSync(destination)) {
|
||||
const assets = readdirSync(destination)
|
||||
.filter((name) => name !== "provenance.json")
|
||||
.sort();
|
||||
const checksums = [];
|
||||
for (const assetName of assets) {
|
||||
const hash = createHash("sha256");
|
||||
for await (const chunk of createReadStream(join(destination, assetName))) hash.update(chunk);
|
||||
checksums.push({ assetName, sha256: hash.digest("hex") });
|
||||
}
|
||||
writeFileSync(
|
||||
join(destination, "provenance.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
repository: env.GITHUB_REPOSITORY,
|
||||
tag: env.GITHUB_REF_NAME,
|
||||
sha: env.GITHUB_SHA,
|
||||
runId: env.GITHUB_RUN_ID,
|
||||
runAttempt: env.GITHUB_RUN_ATTEMPT,
|
||||
workflowRef: env.GITHUB_WORKFLOW_REF,
|
||||
workflowSha: env.GITHUB_WORKFLOW_SHA,
|
||||
tauriAction: "tauri-apps/tauri-action@v0",
|
||||
platform: env.RELEASE_PLATFORM,
|
||||
runtime: env.RELEASE_RUNTIME,
|
||||
arch: env.RELEASE_ARCH,
|
||||
target: env.RELEASE_TARGET,
|
||||
args: env.RELEASE_ARGS,
|
||||
assets: checksums,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
);
|
||||
}
|
||||
console.log(`Found ${files.length} distributable bundle files in ${bundle}`);
|
||||
@@ -31,3 +31,33 @@ 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
|
||||
|
||||
test-windows:
|
||||
name: Rust Tests (Windows)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: voidzero-dev/setup-vp@v1
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: true
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
shared-key: ci
|
||||
cache-on-failure: true
|
||||
- name: Install Protoc for plugin-runtime
|
||||
uses: arduino/setup-protoc@v3
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- run: vp install
|
||||
- run: npm run bootstrap
|
||||
env:
|
||||
YAAK_TARGET_ARCH: x64
|
||||
- name: Run Rust Tests
|
||||
run: cargo test --all --exclude yaak-cli --features yaak-app-client/wry
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
node-version: "24"
|
||||
|
||||
- name: Install source generators
|
||||
run: |
|
||||
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
runtime: "wry"
|
||||
targets: "aarch64-pc-windows-msvc"
|
||||
runs-on: ${{ matrix.platform }}
|
||||
timeout-minutes: 40
|
||||
timeout-minutes: 360
|
||||
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
|
||||
@@ -160,7 +172,8 @@ jobs:
|
||||
codesign --force --options runtime --entitlements crates-tauri/yaak-app-client/macos/entitlements.yaakprotoc.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app-client/vendored/protoc/yaakprotoc || true
|
||||
codesign --force --options runtime --entitlements crates-tauri/yaak-app-client/macos/entitlements.yaaknode.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app-client/vendored/node/yaaknode || true
|
||||
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
- name: Build and upload Tauri bundles
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
|
||||
|
||||
@@ -182,6 +195,8 @@ jobs:
|
||||
AZURE_CLIENT_SECRET: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_TENANT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_TENANT_ID }}
|
||||
with:
|
||||
# Also retries builds; upload retries alone cannot clean up starter assets.
|
||||
retryAttempts: 3
|
||||
tauriScript: "node ../../node_modules/@tauri-apps/cli/tauri.js"
|
||||
tagName: "v__VERSION__"
|
||||
releaseName: "Release __VERSION__"
|
||||
@@ -191,6 +206,17 @@ jobs:
|
||||
projectPath: ./crates-tauri/yaak-app-client
|
||||
args: "${{ matrix.args }}"
|
||||
|
||||
# Snapshot before the per-machine build overwrites the regular NSIS bundle/signature.
|
||||
- name: Snapshot Tauri bundles
|
||||
if: ${{ !cancelled() }}
|
||||
run: node .github/scripts/preserve-release-artifacts.mjs
|
||||
env:
|
||||
RELEASE_TARGET: ${{ matrix.targets }}
|
||||
RELEASE_PLATFORM: ${{ matrix.platform }}
|
||||
RELEASE_RUNTIME: ${{ matrix.runtime }}
|
||||
RELEASE_ARCH: ${{ matrix.yaak_arch }}
|
||||
RELEASE_ARGS: ${{ matrix.args }}
|
||||
|
||||
- name: Build and upload CEF tarball from deb (Linux only)
|
||||
if: matrix.os == 'ubuntu' && matrix.runtime == 'cef'
|
||||
env:
|
||||
@@ -205,7 +231,13 @@ jobs:
|
||||
mkdir -p "$extract_dir"
|
||||
dpkg-deb -x "$deb" "$extract_dir"
|
||||
tar -C "$extract_dir" -czf "$tarball" .
|
||||
gh release upload "${{ github.ref_name }}" "$tarball" --clobber
|
||||
for attempt in 1 2 3 4; do
|
||||
if gh release upload "$GITHUB_REF_NAME" "$tarball" --clobber; then
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -eq 4 ]; then exit 1; fi
|
||||
sleep 10
|
||||
done
|
||||
|
||||
# Build a per-machine NSIS installer for enterprise deployment (PDQ, SCCM, Intune)
|
||||
- name: Build and upload machine-wide installer (Windows only)
|
||||
@@ -223,6 +255,7 @@ jobs:
|
||||
Get-ChildItem -Recurse -Path target -File -Filter "*.exe.sig" | Remove-Item -Force
|
||||
Push-Location crates-tauri/yaak-app-client
|
||||
npx tauri bundle ${{ matrix.args }} --bundles nsis --config '{"bundle":{"createUpdaterArtifacts":true,"windows":{"nsis":{"installMode":"perMachine"}}}}'
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
Pop-Location
|
||||
$setup = Get-ChildItem -Recurse -Path target -Filter "*setup*.exe" | Select-Object -First 1
|
||||
$setupSig = "$($setup.FullName).sig"
|
||||
@@ -230,5 +263,30 @@ jobs:
|
||||
$destSig = "$dest.sig"
|
||||
Copy-Item $setup.FullName $dest
|
||||
Copy-Item $setupSig $destSig
|
||||
gh release upload "${{ github.ref_name }}" "$dest" --clobber
|
||||
gh release upload "${{ github.ref_name }}" "$destSig" --clobber
|
||||
foreach ($asset in @($dest, $destSig)) {
|
||||
for ($attempt = 1; $attempt -le 4; $attempt++) {
|
||||
gh release upload "$env:GITHUB_REF_NAME" "$asset" --clobber
|
||||
if ($LASTEXITCODE -eq 0) { break }
|
||||
if ($attempt -eq 4) { exit $LASTEXITCODE }
|
||||
Start-Sleep -Seconds 10
|
||||
}
|
||||
}
|
||||
|
||||
- name: Snapshot additional release bundles
|
||||
if: ${{ !cancelled() }}
|
||||
run: node .github/scripts/preserve-release-artifacts.mjs
|
||||
env:
|
||||
RELEASE_TARGET: ${{ matrix.targets }}
|
||||
RELEASE_PLATFORM: ${{ matrix.platform }}
|
||||
RELEASE_RUNTIME: ${{ matrix.runtime }}
|
||||
RELEASE_ARCH: ${{ matrix.yaak_arch }}
|
||||
RELEASE_ARGS: ${{ matrix.args }}
|
||||
|
||||
- name: Retain release bundles for recovery
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-${{ github.ref_name }}-${{ matrix.platform }}-${{ matrix.runtime }}-${{ matrix.yaak_arch }}-${{ github.run_id }}-attempt-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/yaak-release-artifacts/
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
@@ -0,0 +1,123 @@
|
||||
name: Release web image
|
||||
|
||||
# Builds ghcr.io/mountain-loop/yaak-web: the browser client and the server that serves it.
|
||||
# One image per architecture on its own native runner (emulating a Rust release build is hours),
|
||||
# joined into one multi-arch tag at the end.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: [v*]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: Version to publish, without the v (e.g. 2026.2.0). Empty publishes main and sha tags only.
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
IMAGE: ghcr.io/mountain-loop/yaak-web
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: github.repository == 'mountain-loop/yaak'
|
||||
name: Build ${{ matrix.platform }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-22.04
|
||||
arch: amd64
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-22.04-arm
|
||||
arch: arm64
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.web
|
||||
platforms: ${{ matrix.platform }}
|
||||
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p "${{ runner.temp }}/digests"
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digest-${{ matrix.arch }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
publish:
|
||||
name: Publish manifest
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digest-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# `latest` follows a release tag, and a manual run that names a version — the way to
|
||||
# publish before the first release. A prerelease (v2026.2.1-beta.1) never takes it.
|
||||
- name: Tags
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.IMAGE }}
|
||||
flavor: latest=false
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=raw,value=${{ inputs.version }},enable=${{ inputs.version != '' }}
|
||||
type=raw,value=latest,enable=${{ inputs.version != '' || (github.event_name == 'push' && !contains(github.ref_name, '-')) }}
|
||||
type=ref,event=branch
|
||||
type=sha,format=short
|
||||
|
||||
- name: Create and push the manifest
|
||||
working-directory: ${{ runner.temp }}/digests
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.IMAGE }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect
|
||||
run: docker buildx imagetools inspect ${{ env.IMAGE }}:${{ steps.meta.outputs.version }}
|
||||
@@ -58,3 +58,7 @@ flatpak/node-sources.json
|
||||
|
||||
# Claude Code local settings
|
||||
.claude/settings.local.json
|
||||
.claude/worktrees/
|
||||
|
||||
# Headless screenshots taken while working
|
||||
.shots/
|
||||
@@ -1,3 +0,0 @@
|
||||
**/bindings/**
|
||||
**/routeTree.gen.ts
|
||||
crates/yaak-templates/pkg/**
|
||||
+2
-1
@@ -3,6 +3,7 @@
|
||||
"ignorePatterns": [
|
||||
"**/bindings/**",
|
||||
"crates/yaak-templates/pkg/**",
|
||||
"apps/yaak-client/routeTree.gen.ts"
|
||||
"apps/yaak-client/routeTree.gen.ts",
|
||||
"plugins/importer-bruno/tests/fixtures/upstream/**"
|
||||
]
|
||||
}
|
||||
Generated
+2240
-1465
File diff suppressed because it is too large.
Load diff
+27
-2
@@ -2,27 +2,37 @@
|
||||
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",
|
||||
"crates/yaak-crypto",
|
||||
"crates/yaak-filter",
|
||||
"crates/yaak-git",
|
||||
"crates/yaak-grpc",
|
||||
"crates/yaak-http",
|
||||
"crates/yaak-lifecycle",
|
||||
"crates/yaak-mcp",
|
||||
"crates/yaak-models",
|
||||
"crates/yaak-plugins",
|
||||
"crates/yaak-query",
|
||||
"crates/yaak-sse",
|
||||
"crates/yaak-sync",
|
||||
"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",
|
||||
"crates-server/yaak-playground",
|
||||
# CLI crates
|
||||
"crates-cli/yaak-cli",
|
||||
# Tauri-specific crates
|
||||
@@ -31,6 +41,7 @@ members = [
|
||||
"crates-tauri/yaak-fonts",
|
||||
"crates-tauri/yaak-license",
|
||||
"crates-tauri/yaak-mac-window",
|
||||
"crates-tauri/yaak-proxy-helper",
|
||||
"crates-tauri/yaak-system-appearance",
|
||||
"crates-tauri/yaak-tauri-utils",
|
||||
"crates-tauri/yaak-window",
|
||||
@@ -41,9 +52,16 @@ chrono = "0.4.42"
|
||||
hex = "0.4.3"
|
||||
keyring = "3.6.3"
|
||||
log = "0.4.29"
|
||||
reqwest = "0.12.20"
|
||||
reqwest = { version = "0.13.5", default-features = false, features = [
|
||||
# reqwest 0.13 switched `default-tls` from native-tls to rustls; keep the
|
||||
# rest of the old default set and stay on native-tls.
|
||||
"charset",
|
||||
"http2",
|
||||
"system-proxy",
|
||||
"native-tls",
|
||||
] }
|
||||
rustls = { version = "0.23.34", default-features = false }
|
||||
rustls-platform-verifier = "0.6.2"
|
||||
rustls-platform-verifier = "0.7.1"
|
||||
schemars = { version = "0.8.22", features = ["chrono"] }
|
||||
serde = "1.0.228"
|
||||
serde_json = "1.0.145"
|
||||
@@ -63,17 +81,23 @@ 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-filter = { path = "crates/yaak-filter" }
|
||||
yaak-git = { path = "crates/yaak-git" }
|
||||
yaak-grpc = { path = "crates/yaak-grpc" }
|
||||
yaak-http = { path = "crates/yaak-http" }
|
||||
yaak-lifecycle = { path = "crates/yaak-lifecycle" }
|
||||
yaak-mcp = { path = "crates/yaak-mcp" }
|
||||
yaak-models = { path = "crates/yaak-models" }
|
||||
yaak-plugins = { path = "crates/yaak-plugins" }
|
||||
yaak-query = { path = "crates/yaak-query" }
|
||||
yaak-sse = { path = "crates/yaak-sse" }
|
||||
yaak-sync = { path = "crates/yaak-sync" }
|
||||
yaak-templates = { path = "crates/yaak-templates" }
|
||||
@@ -89,6 +113,7 @@ yaak-proxy-lib = { path = "crates-proxy/yaak-proxy-lib" }
|
||||
yaak-fonts = { path = "crates-tauri/yaak-fonts" }
|
||||
yaak-license = { path = "crates-tauri/yaak-license" }
|
||||
yaak-mac-window = { path = "crates-tauri/yaak-mac-window" }
|
||||
yaak-proxy-helper = { path = "crates-tauri/yaak-proxy-helper" }
|
||||
yaak-system-appearance = { path = "crates-tauri/yaak-system-appearance" }
|
||||
yaak-tauri-utils = { path = "crates-tauri/yaak-tauri-utils" }
|
||||
yaak-window = { path = "crates-tauri/yaak-window" }
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# The playground API behind yaak.run. See crates-server/yaak-playground/README.md.
|
||||
|
||||
FROM rust:1-bookworm AS build
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN cargo build --release -p yaak-playground
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
COPY --from=build /app/target/release/yaak-playground /usr/local/bin/yaak-playground
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=8080
|
||||
EXPOSE 8080
|
||||
USER nobody
|
||||
CMD ["yaak-playground"]
|
||||
@@ -0,0 +1,49 @@
|
||||
# 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
|
||||
# 0.0.0.0 so the port is reachable from outside the container. PORT is the variable a
|
||||
# platform-as-a-service assigns, so a deploy that sets it needs nothing else changed.
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=8080
|
||||
EXPOSE 8080
|
||||
USER nobody
|
||||
# Overriding the command (dropping --serve) leaves the stateless send executor:
|
||||
# docker run ghcr.io/mountain-loop/yaak-web yaak-web
|
||||
CMD ["yaak-web", "--serve", "/srv"]
|
||||
@@ -17,14 +17,18 @@
|
||||
<br>
|
||||
|
||||
<p align="center">
|
||||
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https://github.com/MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a> <a href="https://github.com/dharsanb"><img src="https://github.com/dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a> <a href="https://github.com/railwayapp"><img src="https://github.com/railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a> <a href="https://github.com/caseyamcl"><img src="https://github.com/caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a> <a href="https://github.com/bytebase"><img src="https://github.com/bytebase.png" width="80px" alt="User avatar: bytebase" /></a> <a href="https://github.com/"><img src="https://raw.githubusercontent.com/JamesIves/github-sponsors-readme-action/dev/.github/assets/placeholder.png" width="80px" alt="User avatar: " /></a> <!-- sponsors-premium -->
|
||||
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https://github.com/MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a> <a href="https://github.com/dharsanb"><img src="https://github.com/dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a> <a href="https://github.com/railwayapp"><img src="https://github.com/railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a> <a href="https://github.com/caseyamcl"><img src="https://github.com/caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a> <a href="https://github.com/bytebase"><img src="https://github.com/bytebase.png" width="80px" alt="User avatar: bytebase" /></a> <a href="https://github.com/rauchg"><img src="https://github.com/rauchg.png" width="80px" alt="User avatar: rauchg" /></a> <a href="https://github.com/"><img src="https://raw.githubusercontent.com/JamesIves/github-sponsors-readme-action/dev/.github/assets/placeholder.png" width="80px" alt="User avatar: " /></a> <!-- sponsors-premium -->
|
||||
</p>
|
||||
<p align="center">
|
||||
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https://github.com/seanwash.png" width="50px" alt="User avatar: seanwash" /></a> <a href="https://github.com/jerath"><img src="https://github.com/jerath.png" width="50px" alt="User avatar: jerath" /></a> <a href="https://github.com/itsa-sh"><img src="https://github.com/itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a> <a href="https://github.com/dmmulroy"><img src="https://github.com/dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a> <a href="https://github.com/timcole"><img src="https://github.com/timcole.png" width="50px" alt="User avatar: timcole" /></a> <a href="https://github.com/VLZH"><img src="https://github.com/VLZH.png" width="50px" alt="User avatar: VLZH" /></a> <a href="https://github.com/terasaka2k"><img src="https://github.com/terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a> <a href="https://github.com/andriyor"><img src="https://github.com/andriyor.png" width="50px" alt="User avatar: andriyor" /></a> <a href="https://github.com/majudhu"><img src="https://github.com/majudhu.png" width="50px" alt="User avatar: majudhu" /></a> <a href="https://github.com/axelrindle"><img src="https://github.com/axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a> <a href="https://github.com/jirizverina"><img src="https://github.com/jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a> <a href="https://github.com/chip-well"><img src="https://github.com/chip-well.png" width="50px" alt="User avatar: chip-well" /></a> <a href="https://github.com/GRAYAH"><img src="https://github.com/GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a> <a href="https://github.com/flashblaze"><img src="https://github.com/flashblaze.png" width="50px" alt="User avatar: flashblaze" /></a> <a href="https://github.com/Frostist"><img src="https://github.com/Frostist.png" width="50px" alt="User avatar: Frostist" /></a> <!-- sponsors-base -->
|
||||
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https://github.com/seanwash.png" width="50px" alt="User avatar: seanwash" /></a> <a href="https://github.com/jerath"><img src="https://github.com/jerath.png" width="50px" alt="User avatar: jerath" /></a> <a href="https://github.com/itsa-sh"><img src="https://github.com/itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a> <a href="https://github.com/dmmulroy"><img src="https://github.com/dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a> <a href="https://github.com/timcole"><img src="https://github.com/timcole.png" width="50px" alt="User avatar: timcole" /></a> <a href="https://github.com/VLZH"><img src="https://github.com/VLZH.png" width="50px" alt="User avatar: VLZH" /></a> <a href="https://github.com/terasaka2k"><img src="https://github.com/terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a> <a href="https://github.com/andriyor"><img src="https://github.com/andriyor.png" width="50px" alt="User avatar: andriyor" /></a> <a href="https://github.com/majudhu"><img src="https://github.com/majudhu.png" width="50px" alt="User avatar: majudhu" /></a> <a href="https://github.com/axelrindle"><img src="https://github.com/axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a> <a href="https://github.com/jirizverina"><img src="https://github.com/jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a> <a href="https://github.com/chip-well"><img src="https://github.com/chip-well.png" width="50px" alt="User avatar: chip-well" /></a> <a href="https://github.com/GRAYAH"><img src="https://github.com/GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a> <a href="https://github.com/flashblaze"><img src="https://github.com/flashblaze.png" width="50px" alt="User avatar: flashblaze" /></a> <a href="https://github.com/Frostist"><img src="https://github.com/Frostist.png" width="50px" alt="User avatar: Frostist" /></a> <a href="https://github.com/PurplProto"><img src="https://github.com/PurplProto.png" width="50px" alt="User avatar: PurplProto" /></a> <!-- sponsors-base -->
|
||||
</p>
|
||||
|
||||

|
||||
|
||||
## Install
|
||||
|
||||
Download Yaak for macOS, Windows, or Linux from [yaak.app/download](https://yaak.app/download). Package managers and other install methods are in the [installation docs](https://yaak.app/docs/getting-started/installation).
|
||||
|
||||
## Features
|
||||
|
||||
Yaak is an offline-first API client designed to stay out of your way while giving you everything you need when you need it.
|
||||
@@ -68,3 +72,9 @@ Built with [Tauri](https://tauri.app), Rust, and React, it’s fast, lightweight
|
||||
- [Yaak vs Postman](https://yaak.app/alternatives/postman)
|
||||
- [Yaak vs Bruno](https://yaak.app/alternatives/bruno)
|
||||
- [Yaak vs Insomnia](https://yaak.app/alternatives/insomnia)
|
||||
|
||||
## License
|
||||
|
||||
The source is [MIT licensed](LICENSE) and can be built and run for free, for both personal and commercial use.
|
||||
|
||||
The prebuilt binaries follow Yaak's [pricing terms](https://yaak.app/pricing): free for personal use, license required for commercial use.
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { FunctionComponent, ReactElement } from "react";
|
||||
import { beforeEach, expect, test, vi } from "vite-plus/test";
|
||||
import type { SyncOp } from "@yaakapp-internal/sync/bindings/gen_sync";
|
||||
import type { DialogInstance } from "../components/Dialogs";
|
||||
import { syncWorkspace } from "./commands";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
calculateSync: vi.fn(),
|
||||
applySync: vi.fn(),
|
||||
showDialog: vi.fn<(dialog: DialogInstance) => void>(),
|
||||
hideDialog: vi.fn(),
|
||||
showToast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@yaakapp-internal/sync", () => mocks);
|
||||
vi.mock("@yaakapp-internal/models", () => ({
|
||||
createWorkspaceModel: vi.fn(),
|
||||
modelTypeLabel: () => "Request",
|
||||
}));
|
||||
vi.mock("@yaakapp-internal/ui", () => ({
|
||||
Banner: "div",
|
||||
InlineCode: "code",
|
||||
Table: "table",
|
||||
TableBody: "tbody",
|
||||
TableCell: "td",
|
||||
TableHead: "thead",
|
||||
TableHeaderCell: "th",
|
||||
TableRow: "tr",
|
||||
TruncatedWideTableCell: "td",
|
||||
}));
|
||||
vi.mock("../components/core/Button", () => ({ Button: "button" }));
|
||||
vi.mock("../hooks/useActiveWorkspace", () => ({ activeWorkspaceIdAtom: null }));
|
||||
vi.mock("../lib/dialog", () => mocks);
|
||||
vi.mock("../lib/jotai", () => ({ jotaiStore: { get: vi.fn() } }));
|
||||
vi.mock("../lib/prompt", () => ({ showPrompt: vi.fn() }));
|
||||
vi.mock("../lib/resolvedModelName", () => ({ resolvedModelNameWithFolders: () => "Request" }));
|
||||
vi.mock("../lib/toast", () => mocks);
|
||||
|
||||
const staleToast = expect.objectContaining({
|
||||
message: "Files changed while syncing. Review the changes again.",
|
||||
});
|
||||
|
||||
const params = { workspaceId: "wk_test", syncDir: "/sync" };
|
||||
const deletion = {
|
||||
type: "dbDelete",
|
||||
model: { id: "rq_test", model: "http_request" },
|
||||
state: {},
|
||||
} as Extract<SyncOp, { type: "dbDelete" }>;
|
||||
const update = {
|
||||
type: "dbUpdate",
|
||||
model: { id: "rq_test", model: "http_request" },
|
||||
fs: { model: { id: "rq_test", model: "http_request", name: "Updated" } },
|
||||
state: {},
|
||||
} as Extract<SyncOp, { type: "dbUpdate" }>;
|
||||
|
||||
async function submit(dialog: DialogInstance) {
|
||||
const render = dialog.render as FunctionComponent<{ hide: () => void }>;
|
||||
const form = render({ hide: () => mocks.hideDialog(dialog.id) }) as ReactElement<{
|
||||
onSubmit: (event: { preventDefault: () => void }) => Promise<void>;
|
||||
}>;
|
||||
await form.props.onSubmit({ preventDefault: vi.fn() });
|
||||
}
|
||||
|
||||
function latestDialog() {
|
||||
const dialog = mocks.showDialog.mock.calls.at(-1)?.[0];
|
||||
if (dialog == null) throw new Error("Expected a sync dialog");
|
||||
return dialog;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mocks.applySync.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
test("closes an obsolete dialog when the restored file leaves no changes", async () => {
|
||||
mocks.calculateSync.mockResolvedValueOnce([deletion]).mockResolvedValueOnce([]);
|
||||
await syncWorkspace.mutateAsync(params);
|
||||
mocks.applySync.mockResolvedValueOnce(false);
|
||||
|
||||
await submit(latestDialog());
|
||||
|
||||
expect(mocks.applySync).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.applySync).toHaveBeenCalledWith(params.workspaceId, params.syncDir, [deletion]);
|
||||
expect(mocks.hideDialog).toHaveBeenCalledWith("commit-sync");
|
||||
expect(mocks.showDialog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("requires another confirmation for a changed plan", async () => {
|
||||
mocks.calculateSync.mockResolvedValueOnce([deletion]).mockResolvedValueOnce([update]);
|
||||
await syncWorkspace.mutateAsync(params);
|
||||
const original = latestDialog();
|
||||
mocks.applySync.mockResolvedValueOnce(false);
|
||||
|
||||
await submit(original);
|
||||
|
||||
expect(mocks.applySync).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.showDialog).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.showToast).toHaveBeenCalledWith(staleToast);
|
||||
expect(mocks.hideDialog).not.toHaveBeenCalled();
|
||||
await submit(latestDialog());
|
||||
expect(mocks.applySync).toHaveBeenLastCalledWith(params.workspaceId, params.syncDir, [update]);
|
||||
expect(mocks.hideDialog).toHaveBeenCalledWith("commit-sync");
|
||||
});
|
||||
|
||||
test("applies an unchanged reviewed plan and closes the dialog", async () => {
|
||||
mocks.calculateSync.mockResolvedValue([update]);
|
||||
await syncWorkspace.mutateAsync(params);
|
||||
await submit(latestDialog());
|
||||
|
||||
expect(mocks.calculateSync).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.applySync).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.hideDialog).toHaveBeenCalledWith("commit-sync");
|
||||
});
|
||||
|
||||
test("forced sync retries the current plan without requesting another confirmation", async () => {
|
||||
mocks.calculateSync.mockResolvedValueOnce([deletion]).mockResolvedValueOnce([update]);
|
||||
mocks.applySync.mockResolvedValueOnce(false);
|
||||
|
||||
await syncWorkspace.mutateAsync({ ...params, force: true });
|
||||
|
||||
expect(mocks.applySync).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.applySync).toHaveBeenLastCalledWith(params.workspaceId, params.syncDir, [update]);
|
||||
expect(mocks.showDialog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("outbound-only changes clear the old dialog and sync automatically", async () => {
|
||||
const outbound = { type: "fsUpdate", model: update.model, state: {} } as SyncOp;
|
||||
mocks.calculateSync.mockResolvedValueOnce([deletion]).mockResolvedValueOnce([outbound]);
|
||||
await syncWorkspace.mutateAsync(params);
|
||||
await syncWorkspace.mutateAsync(params);
|
||||
|
||||
expect(mocks.applySync).toHaveBeenCalledWith(params.workspaceId, params.syncDir, [outbound]);
|
||||
expect(mocks.hideDialog).toHaveBeenCalledWith("commit-sync");
|
||||
expect(mocks.showDialog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("stops retrying a plan that keeps going stale", async () => {
|
||||
mocks.calculateSync.mockResolvedValue([update]);
|
||||
mocks.applySync.mockResolvedValue(false);
|
||||
|
||||
await syncWorkspace.mutateAsync({ ...params, force: true });
|
||||
|
||||
expect(mocks.calculateSync).toHaveBeenCalledTimes(3);
|
||||
expect(mocks.applySync).toHaveBeenCalledTimes(3);
|
||||
expect(mocks.showToast).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.showToast).toHaveBeenCalledWith(staleToast);
|
||||
expect(mocks.showDialog).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -14,11 +14,12 @@ import {
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
||||
import { createFastMutation } from "../hooks/useFastMutation";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import { hideDialog, showDialog } from "../lib/dialog";
|
||||
import { jotaiStore } from "../lib/jotai";
|
||||
import { pluralizeCount } from "../lib/pluralize";
|
||||
import { showPrompt } from "../lib/prompt";
|
||||
import { resolvedModelNameWithFolders } from "../lib/resolvedModelName";
|
||||
import { showToast } from "../lib/toast";
|
||||
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
|
||||
|
||||
export const createFolder = createFastMutation<
|
||||
string | null,
|
||||
@@ -52,24 +53,48 @@ export const createFolder = createFastMutation<
|
||||
},
|
||||
});
|
||||
|
||||
// Files can keep changing under a plan, so stop recalculating after a few stale applies.
|
||||
const MAX_SYNC_ATTEMPTS = 3;
|
||||
|
||||
function showStaleSyncToast() {
|
||||
showToast({
|
||||
id: "sync-stale",
|
||||
color: "warning",
|
||||
message: "Files changed while syncing. Review the changes again.",
|
||||
});
|
||||
}
|
||||
|
||||
export const syncWorkspace = createFastMutation<
|
||||
void,
|
||||
void,
|
||||
{ workspaceId: string; syncDir: string; force?: boolean }
|
||||
{ workspaceId: string; syncDir: string; force?: boolean; attempt?: number }
|
||||
>({
|
||||
mutationKey: [],
|
||||
mutationFn: async ({ workspaceId, syncDir, force }) => {
|
||||
mutationFn: async ({ workspaceId, syncDir, force, attempt = 1 }) => {
|
||||
const ops = (await calculateSync(workspaceId, syncDir)) ?? [];
|
||||
if (ops.length === 0) {
|
||||
console.log("Nothing to sync", workspaceId, syncDir);
|
||||
hideDialog("commit-sync");
|
||||
return;
|
||||
}
|
||||
console.log("Syncing workspace", workspaceId, syncDir, ops);
|
||||
|
||||
const dbOps = ops.filter((o) => o.type.startsWith("db"));
|
||||
|
||||
if (dbOps.length === 0) {
|
||||
await applySync(workspaceId, syncDir, ops);
|
||||
const apply = async () => {
|
||||
if (await applySync(workspaceId, syncDir, ops)) {
|
||||
hideDialog("commit-sync");
|
||||
} else if (attempt < MAX_SYNC_ATTEMPTS) {
|
||||
await syncWorkspace.mutateAsync({ workspaceId, syncDir, force, attempt: attempt + 1 });
|
||||
} else {
|
||||
hideDialog("commit-sync");
|
||||
showStaleSyncToast();
|
||||
}
|
||||
};
|
||||
|
||||
if (dbOps.length === 0 || force) {
|
||||
hideDialog("commit-sync");
|
||||
await apply();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -79,9 +104,8 @@ export const syncWorkspace = createFastMutation<
|
||||
|
||||
console.log("Directory changes detected", { dbOps, ops });
|
||||
|
||||
if (force) {
|
||||
await applySync(workspaceId, syncDir, ops);
|
||||
return;
|
||||
if (attempt > 1) {
|
||||
showStaleSyncToast();
|
||||
}
|
||||
|
||||
showDialog({
|
||||
@@ -93,8 +117,7 @@ export const syncWorkspace = createFastMutation<
|
||||
className="h-full grid grid-rows-[auto_auto_minmax(0,1fr)_auto] gap-3"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
await applySync(workspaceId, syncDir, ops);
|
||||
hide();
|
||||
await apply();
|
||||
}}
|
||||
>
|
||||
{isDeletingWorkspace ? (
|
||||
|
||||
@@ -6,11 +6,12 @@ import { showDialog } from "../lib/dialog";
|
||||
import { jotaiStore } from "../lib/jotai";
|
||||
import { setWorkspaceSearchParams } from "../lib/setWorkspaceSearchParams";
|
||||
|
||||
export const createSubEnvironmentAndActivate = createFastMutation<
|
||||
string | null,
|
||||
unknown,
|
||||
Environment | null
|
||||
>({
|
||||
/**
|
||||
* Prompt for and create a sub-environment of the given base environment, resolving with the new
|
||||
* environment's ID (or null if the dialog was dismissed). This does not change the workspace's
|
||||
* active environment.
|
||||
*/
|
||||
export const createSubEnvironment = createFastMutation<string | null, unknown, Environment | null>({
|
||||
mutationKey: ["create_environment"],
|
||||
mutationFn: async (baseEnvironment) => {
|
||||
if (baseEnvironment == null) {
|
||||
@@ -41,6 +42,17 @@ export const createSubEnvironmentAndActivate = createFastMutation<
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
/** Same as {@link createSubEnvironment}, but also makes the new environment the active one. */
|
||||
export const createSubEnvironmentAndActivate = createFastMutation<
|
||||
string | null,
|
||||
unknown,
|
||||
Environment | null
|
||||
>({
|
||||
mutationKey: ["create_environment_and_activate"],
|
||||
mutationFn: (baseEnvironment) => createSubEnvironment.mutateAsync(baseEnvironment),
|
||||
disableToastError: true, // Already shown by createSubEnvironment
|
||||
onSuccess: async (environmentId) => {
|
||||
if (environmentId == null) {
|
||||
return; // Was not created
|
||||
|
||||
@@ -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`,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -3,9 +3,9 @@ import type { GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-intern
|
||||
import { MoveToWorkspaceDialog } from "../components/MoveToWorkspaceDialog";
|
||||
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
||||
import { createFastMutation } from "../hooks/useFastMutation";
|
||||
import { pluralizeCount } from "../lib/pluralize";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import { jotaiStore } from "../lib/jotai";
|
||||
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
|
||||
|
||||
export const moveToWorkspace = createFastMutation({
|
||||
mutationKey: ["move_workspace"],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -17,7 +17,7 @@ export const openWorkspaceFromSyncDir = createFastMutation<void, void, string>({
|
||||
return;
|
||||
}
|
||||
|
||||
await applySync(workspace.id, dir, ops);
|
||||
await applySync(workspace.id, dir, ops, { fsOnly: true });
|
||||
|
||||
await router.navigate({
|
||||
to: "/workspaces/$workspaceId",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import {
|
||||
Banner,
|
||||
type EditorHost,
|
||||
EditorHostProvider,
|
||||
EmptyStateText,
|
||||
type HostBodyViewerArgs,
|
||||
type HttpBodyLoader,
|
||||
LoadingIcon,
|
||||
type StoredValue,
|
||||
type StoredValueNamespace,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { lazy, type ReactNode, Suspense } from "react";
|
||||
import { useKeyValue } from "../hooks/useKeyValue";
|
||||
import { copyToClipboard } from "../lib/copy";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import { showPrompt } from "../lib/prompt";
|
||||
import { useDetailsBannerStoredValue } from "./core/DetailsBanner";
|
||||
import { Editor } from "./core/Editor/LazyEditor";
|
||||
import { Input } from "./core/Input";
|
||||
import { ErrorBoundary } from "./ErrorBoundary";
|
||||
import { MultipartViewer } from "./responseViewers/MultipartViewer";
|
||||
import { TextViewer } from "./responseViewers/TextViewer";
|
||||
import { SelectFile } from "./SelectFile";
|
||||
|
||||
const PdfViewer = lazy(() =>
|
||||
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
|
||||
);
|
||||
|
||||
function useClientStoredValue<T extends StoredValue>(
|
||||
key: string | string[] | null,
|
||||
fallback: T,
|
||||
namespace: StoredValueNamespace = "global",
|
||||
) {
|
||||
// A null key is never written by the shared components, so any stable key will do
|
||||
const { value, set } = useKeyValue<T>({ namespace, key: key ?? ["tabs", "default"], fallback });
|
||||
return [value ?? fallback, set] as const;
|
||||
}
|
||||
|
||||
function renderBodyViewer({ source, mimeType, body, complete }: HostBodyViewerArgs) {
|
||||
if (!mimeType?.match(/pdf/i)) return null;
|
||||
return source === "response" ? (
|
||||
<PdfResponseBody body={body} complete={complete} />
|
||||
) : (
|
||||
<PdfRequestBody body={body} />
|
||||
);
|
||||
}
|
||||
|
||||
/** Waits for the whole response, then loads the PDF from its file */
|
||||
function PdfResponseBody({ body, complete }: { body: HttpBodyLoader; complete: boolean }) {
|
||||
const bodyUrl = body.useUrl(complete);
|
||||
|
||||
if (!complete || bodyUrl.isLoading) {
|
||||
return (
|
||||
<EmptyStateText>
|
||||
<LoadingIcon />
|
||||
</EmptyStateText>
|
||||
);
|
||||
}
|
||||
if (bodyUrl.error) {
|
||||
return <Banner color="danger">{String(bodyUrl.error)}</Banner>;
|
||||
}
|
||||
if (bodyUrl.data == null) {
|
||||
return <div>Empty response body</div>;
|
||||
}
|
||||
return <PdfViewer bodyUrl={bodyUrl.data} />;
|
||||
}
|
||||
|
||||
function PdfRequestBody({ body }: { body: HttpBodyLoader }) {
|
||||
const bytes = body.useBytes();
|
||||
if (bytes.data == null) return null;
|
||||
return (
|
||||
<Suspense fallback={<LoadingIcon />}>
|
||||
<PdfViewer data={bytes.data} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
const clientEditorHost: EditorHost = {
|
||||
Editor,
|
||||
Input,
|
||||
SelectFile,
|
||||
showDialog,
|
||||
showPrompt,
|
||||
basename: (path) => platform.files.basename(path),
|
||||
useStoredValue: useClientStoredValue,
|
||||
useDetailsBannerStoredValue,
|
||||
ErrorBoundary,
|
||||
TextViewer,
|
||||
MultipartViewer,
|
||||
renderBodyViewer,
|
||||
openUrl: (url) => platform.openUrl(url),
|
||||
copy: (text) => copyToClipboard(text, { disableToast: true }),
|
||||
};
|
||||
|
||||
/** Gives the shared editing components the client's templated editor, dialogs and storage */
|
||||
export function ClientEditorHost({ children }: { children: ReactNode }) {
|
||||
return <EditorHostProvider value={clientEditorHost}>{children}</EditorHostProvider>;
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { gitClone } from "@yaakapp-internal/git";
|
||||
import { Banner, VStack } from "@yaakapp-internal/ui";
|
||||
import { useState } from "react";
|
||||
import { useId, useState } from "react";
|
||||
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
|
||||
import { appInfo } from "../lib/appInfo";
|
||||
import { CommercialUseBanner } from "./CommercialUseBanner";
|
||||
import { showErrorToast } from "../lib/toast";
|
||||
import { Button } from "./core/Button";
|
||||
import { Checkbox } from "./core/Checkbox";
|
||||
import { DialogFooter } from "./core/Dialog";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import { promptCredentials } from "./git/credentials";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
|
||||
interface Props {
|
||||
hide: () => void;
|
||||
@@ -29,6 +29,7 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
|
||||
const [subdirectory, setSubdirectory] = useState<string>("");
|
||||
const [isCloning, setIsCloning] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const formId = useId();
|
||||
|
||||
const repoName = extractRepoName(url);
|
||||
const sep = getPathSeparator(baseDirectory);
|
||||
@@ -38,7 +39,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,
|
||||
@@ -83,7 +84,7 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack as="form" space={3} alignItems="start" className="pb-3" onSubmit={handleClone}>
|
||||
<VStack as="form" id={formId} space={3} alignItems="start" onSubmit={handleClone}>
|
||||
{error && (
|
||||
<Banner color="danger" className="w-full">
|
||||
{error}
|
||||
@@ -132,15 +133,17 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
color="primary"
|
||||
className="w-full mt-3"
|
||||
disabled={!url || !directory || isCloning}
|
||||
isLoading={isCloning}
|
||||
>
|
||||
{isCloning ? "Cloning..." : "Clone Repository"}
|
||||
</Button>
|
||||
<DialogFooter
|
||||
actions={[
|
||||
{
|
||||
label: isCloning ? "Cloning..." : "Clone Repository",
|
||||
color: "primary",
|
||||
form: formId,
|
||||
disabled: !url || !directory || isCloning,
|
||||
isLoading: isCloning,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,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 {
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||
import { patchModel } from "@yaakapp-internal/models";
|
||||
import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui";
|
||||
import { Banner, HStack, InlineCode, SizeTag } from "@yaakapp-internal/ui";
|
||||
import type { ReactNode } from "react";
|
||||
import { useToggle } from "../hooks/useToggle";
|
||||
import { showConfirm } from "../lib/confirm";
|
||||
import { Button } from "./core/Button";
|
||||
import { Link } from "./core/Link";
|
||||
import { SizeTag } from "./core/SizeTag";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||
import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui";
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import { useSaveResponse } from "../hooks/useSaveResponse";
|
||||
import { useToggle } from "../hooks/useToggle";
|
||||
import { isProbablyTextContentType } from "../lib/contentType";
|
||||
import { getContentTypeFromHeaders } from "../lib/model_util";
|
||||
import { getResponseBodyText } from "../lib/responseBody";
|
||||
import { CopyButton } from "./CopyButton";
|
||||
import { Button } from "./core/Button";
|
||||
import { SizeTag } from "./core/SizeTag";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
response: HttpResponse;
|
||||
}
|
||||
|
||||
const LARGE_BYTES = 2 * 1000 * 1000;
|
||||
|
||||
export function ConfirmLargeResponse({ children, response }: Props) {
|
||||
const { mutate: saveResponse } = useSaveResponse(response);
|
||||
const [showLargeResponse, toggleShowLargeResponse] = useToggle();
|
||||
const isProbablyText = useMemo(() => {
|
||||
const contentType = getContentTypeFromHeaders(response.headers);
|
||||
return isProbablyTextContentType(contentType);
|
||||
}, [response.headers]);
|
||||
|
||||
const contentLength = response.contentLength ?? 0;
|
||||
const isLarge = contentLength > LARGE_BYTES;
|
||||
if (!showLargeResponse && isLarge) {
|
||||
return (
|
||||
<Banner color="primary" className="flex flex-col gap-3">
|
||||
<p>
|
||||
Showing responses over{" "}
|
||||
<InlineCode>
|
||||
<SizeTag contentLength={LARGE_BYTES} />
|
||||
</InlineCode>{" "}
|
||||
may impact performance
|
||||
</p>
|
||||
<HStack wrap space={2}>
|
||||
<Button color="primary" size="xs" onClick={toggleShowLargeResponse}>
|
||||
Reveal Response
|
||||
</Button>
|
||||
<Button color="secondary" variant="border" size="xs" onClick={() => saveResponse()}>
|
||||
Save to File
|
||||
</Button>
|
||||
{isProbablyText && (
|
||||
<CopyButton
|
||||
color="secondary"
|
||||
variant="border"
|
||||
size="xs"
|
||||
text={() => getResponseBodyText({ response, filter: null })}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||
import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui";
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import { getRequestBodyText as getHttpResponseRequestBodyText } from "../hooks/useHttpRequestBody";
|
||||
import { useToggle } from "../hooks/useToggle";
|
||||
import { isProbablyTextContentType } from "../lib/contentType";
|
||||
import { getContentTypeFromHeaders } from "../lib/model_util";
|
||||
import { CopyButton } from "./CopyButton";
|
||||
import { Button } from "./core/Button";
|
||||
import { SizeTag } from "./core/SizeTag";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
response: HttpResponse;
|
||||
}
|
||||
|
||||
const LARGE_BYTES = 2 * 1000 * 1000;
|
||||
|
||||
export function ConfirmLargeResponseRequest({ children, response }: Props) {
|
||||
const [showLargeResponse, toggleShowLargeResponse] = useToggle();
|
||||
const isProbablyText = useMemo(() => {
|
||||
const contentType = getContentTypeFromHeaders(response.headers);
|
||||
return isProbablyTextContentType(contentType);
|
||||
}, [response.headers]);
|
||||
|
||||
const contentLength = response.requestContentLength ?? 0;
|
||||
const isLarge = contentLength > LARGE_BYTES;
|
||||
if (!showLargeResponse && isLarge) {
|
||||
return (
|
||||
<Banner color="primary" className="flex flex-col gap-3">
|
||||
<p>
|
||||
Showing content over{" "}
|
||||
<InlineCode>
|
||||
<SizeTag contentLength={LARGE_BYTES} />
|
||||
</InlineCode>{" "}
|
||||
may impact performance
|
||||
</p>
|
||||
<HStack wrap space={2}>
|
||||
<Button color="primary" size="xs" onClick={toggleShowLargeResponse}>
|
||||
Reveal Request Body
|
||||
</Button>
|
||||
{isProbablyText && (
|
||||
<CopyButton
|
||||
color="secondary"
|
||||
variant="border"
|
||||
size="xs"
|
||||
text={() => getHttpResponseRequestBodyText(response).then((d) => d?.bodyText ?? "")}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useAtomValue } from "jotai";
|
||||
import { contextMenusAtom, hideContextMenu } from "../lib/contextMenu";
|
||||
import { ContextMenu } from "./core/Dropdown";
|
||||
import { ErrorBoundary } from "./ErrorBoundary";
|
||||
|
||||
/** Renders menus opened by {@link showContextMenu}, the way {@link Dialogs} renders dialogs. */
|
||||
export function ContextMenus() {
|
||||
const menus = useAtomValue(contextMenusAtom);
|
||||
return (
|
||||
<>
|
||||
{menus.map(({ id, items, triggerPosition, triggerRect, triggerEl }) => (
|
||||
<ErrorBoundary key={id} name={`ContextMenu ${id}`}>
|
||||
<ContextMenu
|
||||
items={items}
|
||||
triggerPosition={triggerPosition}
|
||||
triggerRect={triggerRect}
|
||||
// The trigger isn't a React component here, so it arrives as an element and gets
|
||||
// wrapped to look like the ref the menu expects
|
||||
triggerRef={{ current: triggerEl ?? null }}
|
||||
onClose={() => hideContextMenu(id)}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -25,12 +25,13 @@ import {
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
TruncatedWideTableCell,
|
||||
KeyValueRows,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { Checkbox } from "./core/Checkbox";
|
||||
import classNames from "classnames";
|
||||
import { EventDetailHeader } from "./core/EventViewer";
|
||||
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
|
||||
import { KeyValueRow } from "./core/KeyValueRow";
|
||||
import { EmptyStateText } from "./EmptyStateText";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import { Select } from "./core/Select";
|
||||
|
||||
@@ -1,31 +1,22 @@
|
||||
import { IconButton, type IconButtonProps, useTimedBoolean } from "@yaakapp-internal/ui";
|
||||
import {
|
||||
CopyIconButton as BaseCopyIconButton,
|
||||
type CopyIconButtonProps,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { copyToClipboard } from "../lib/copy";
|
||||
import { showToast } from "../lib/toast";
|
||||
|
||||
interface Props extends Omit<IconButtonProps, "onClick" | "icon"> {
|
||||
text: string | (() => Promise<string | null>);
|
||||
export function CopyIconButton(props: Omit<CopyIconButtonProps, "copy" | "onCopyFailed">) {
|
||||
return <BaseCopyIconButton {...props} copy={copy} onCopyFailed={showCopyFailed} />;
|
||||
}
|
||||
|
||||
export function CopyIconButton({ text, ...props }: Props) {
|
||||
const [copied, setCopied] = useTimedBoolean();
|
||||
return (
|
||||
<IconButton
|
||||
{...props}
|
||||
icon={copied ? "check" : "copy"}
|
||||
showConfirm
|
||||
onClick={async () => {
|
||||
const content = typeof text === "function" ? await text() : text;
|
||||
if (content == null) {
|
||||
showToast({
|
||||
id: "failed-to-copy",
|
||||
color: "danger",
|
||||
message: "Failed to copy",
|
||||
});
|
||||
} else {
|
||||
copyToClipboard(content, { disableToast: true });
|
||||
setCopied();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
function copy(text: string) {
|
||||
copyToClipboard(text, { disableToast: true });
|
||||
}
|
||||
|
||||
function showCopyFailed() {
|
||||
showToast({
|
||||
id: "failed-to-copy",
|
||||
color: "danger",
|
||||
message: "Failed to copy",
|
||||
});
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { createWorkspaceModel } from "@yaakapp-internal/models";
|
||||
import { useState } from "react";
|
||||
import { useId, useState } from "react";
|
||||
import { useToggle } from "../hooks/useToggle";
|
||||
import { ColorIndicator } from "./ColorIndicator";
|
||||
import { Button } from "./core/Button";
|
||||
import { Checkbox } from "./core/Checkbox";
|
||||
import { ColorPickerWithThemeColors } from "./core/ColorPicker";
|
||||
import { Label } from "./core/Label";
|
||||
import { DialogFooter } from "./core/Dialog";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import { Label } from "@yaakapp-internal/ui";
|
||||
|
||||
interface Props {
|
||||
onCreate: (id: string) => void;
|
||||
@@ -18,9 +18,11 @@ export function CreateEnvironmentDialog({ workspaceId, hide, onCreate }: Props)
|
||||
const [name, setName] = useState<string>("");
|
||||
const [color, setColor] = useState<string | null>(null);
|
||||
const [sharable, toggleSharable] = useToggle(false);
|
||||
const formId = useId();
|
||||
return (
|
||||
<form
|
||||
className="pb-3 flex flex-col gap-3"
|
||||
id={formId}
|
||||
className="flex flex-col gap-3"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
const id = await createWorkspaceModel({
|
||||
@@ -59,10 +61,17 @@ export function CreateEnvironmentDialog({ workspaceId, hide, onCreate }: Props)
|
||||
</Label>
|
||||
<ColorPickerWithThemeColors onChange={setColor} color={color} />
|
||||
</div>
|
||||
<Button type="submit" color="secondary" className="mt-3">
|
||||
{color != null && <ColorIndicator color={color} />}
|
||||
Create Environment
|
||||
</Button>
|
||||
<DialogFooter
|
||||
inline
|
||||
actions={[
|
||||
{
|
||||
label: "Create Environment",
|
||||
color: "secondary",
|
||||
form: formId,
|
||||
leftSlot: color != null ? <ColorIndicator color={color} /> : undefined,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,14 @@
|
||||
import { gitMutations } from "@yaakapp-internal/git";
|
||||
import type { WorkspaceMeta } from "@yaakapp-internal/models";
|
||||
import { createGlobalModel, updateModel } from "@yaakapp-internal/models";
|
||||
import { VStack } from "@yaakapp-internal/ui";
|
||||
import { useState } from "react";
|
||||
import { VStack, Label } from "@yaakapp-internal/ui";
|
||||
import { useId, useState } from "react";
|
||||
import { router } from "../lib/router";
|
||||
import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
|
||||
import { invokeCmd } from "../lib/tauri";
|
||||
import { rpc } from "../lib/rpc";
|
||||
import { showErrorToast } from "../lib/toast";
|
||||
import { Button } from "./core/Button";
|
||||
import { Checkbox } from "./core/Checkbox";
|
||||
import { Label } from "./core/Label";
|
||||
import { DialogFooter } from "./core/Dialog";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import { EncryptionHelp } from "./EncryptionHelp";
|
||||
import { gitCallbacks } from "./git/callbacks";
|
||||
@@ -26,12 +25,13 @@ export function CreateWorkspaceDialog({ hide }: Props) {
|
||||
initGit?: boolean;
|
||||
}>({ filePath: null, initGit: false });
|
||||
const [setupEncryption, setSetupEncryption] = useState<boolean>(false);
|
||||
const formId = useId();
|
||||
return (
|
||||
<VStack
|
||||
as="form"
|
||||
id={formId}
|
||||
space={3}
|
||||
alignItems="start"
|
||||
className="pb-3"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
const workspaceId = await createGlobalModel({ model: "workspace", name });
|
||||
@@ -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({
|
||||
@@ -89,9 +89,7 @@ export function CreateWorkspaceDialog({ hide }: Props) {
|
||||
title="Enable Encryption"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" color="primary" className="w-full mt-3">
|
||||
Create Workspace
|
||||
</Button>
|
||||
<DialogFooter actions={[{ label: "Create Workspace", color: "primary", form: formId }]} />
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
FormInputText,
|
||||
JsonPrimitive,
|
||||
} from "@yaakapp-internal/plugins";
|
||||
import { Banner, VStack } from "@yaakapp-internal/ui";
|
||||
import { Banner, VStack, Label } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
@@ -26,7 +26,6 @@ import { Editor } from "./core/Editor/LazyEditor";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import type { InputProps } from "./core/Input";
|
||||
import { Input } from "./core/Input";
|
||||
import { Label } from "./core/Label";
|
||||
import type { Pair } from "./core/PairEditor";
|
||||
import { PairEditor } from "./core/PairEditor";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
|
||||
@@ -1,24 +1 @@
|
||||
import classNames from "classnames";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
wrapperClassName?: string;
|
||||
}
|
||||
|
||||
export function EmptyStateText({ children, className, wrapperClassName }: Props) {
|
||||
return (
|
||||
<div className={classNames("w-full h-full pb-2", wrapperClassName)}>
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
"rounded-lg border border-dashed border-border-subtle",
|
||||
"h-full py-2 text-text-subtlest flex items-center justify-center italic",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export { EmptyStateText } from "@yaakapp-internal/ui";
|
||||
@@ -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,11 +1,19 @@
|
||||
import type { Environment, Workspace } from "@yaakapp-internal/models";
|
||||
import { duplicateModel, patchModel } from "@yaakapp-internal/models";
|
||||
import type { TreeHandle, TreeNode, TreeProps } from "@yaakapp-internal/ui";
|
||||
import { Banner, Icon, InlineCode, SplitLayout, Tree } from "@yaakapp-internal/ui";
|
||||
import { Banner, Icon, InlineCode, SplitLayout, Tree, IconTooltip } from "@yaakapp-internal/ui";
|
||||
import { atom, useAtomValue } from "jotai";
|
||||
import { atomFamily } from "jotai-family";
|
||||
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createSubEnvironmentAndActivate } from "../commands/createEnvironment";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { createSubEnvironment } from "../commands/createEnvironment";
|
||||
import { activeWorkspaceAtom, activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
||||
import {
|
||||
environmentsBreakdownAtom,
|
||||
@@ -22,7 +30,6 @@ import { showColorPicker } from "../lib/showColorPicker";
|
||||
import type { ContextMenuProps, DropdownItem } from "./core/Dropdown";
|
||||
import { ContextMenu } from "./core/Dropdown";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { IconTooltip } from "./core/IconTooltip";
|
||||
import type { PairEditorHandle } from "./core/PairEditor";
|
||||
import { EnvironmentColorIndicator } from "./EnvironmentColorIndicator";
|
||||
import { EnvironmentEditor } from "./EnvironmentEditor";
|
||||
@@ -40,6 +47,9 @@ interface Props {
|
||||
|
||||
type TreeModel = Environment | Workspace;
|
||||
|
||||
/** Lets tree row slots create a sub-environment and select it in the editor. */
|
||||
const CreateSubEnvironmentContext = createContext<() => Promise<void>>(async () => {});
|
||||
|
||||
export function EnvironmentEditDialog({ initialEnvironmentId, setRef }: Props) {
|
||||
const { allEnvironments, baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown();
|
||||
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<string | null>(
|
||||
@@ -115,7 +125,7 @@ function EnvironmentEditDialogSidebar({
|
||||
const activeWorkspaceId = useAtomValue(activeWorkspaceIdAtom) ?? "";
|
||||
const treeId = `environment.${activeWorkspaceId}.sidebar`;
|
||||
const treeRef = useRef<TreeHandle>(null);
|
||||
const { baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown();
|
||||
const { allEnvironments, baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown();
|
||||
|
||||
// oxlint-disable-next-line react-hooks/exhaustive-deps -- none
|
||||
useLayoutEffect(() => {
|
||||
@@ -124,6 +134,31 @@ function EnvironmentEditDialogSidebar({
|
||||
treeRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// A just-created environment to select once it reaches the model store. Selecting it before
|
||||
// then would show a "failed to find" error in the editor, and the tree would drop the
|
||||
// selection of an item it doesn't have yet.
|
||||
const [pendingSelectionId, setPendingSelectionId] = useState<string | null>(null);
|
||||
const tree = useAtomValue(treeAtom);
|
||||
useEffect(() => {
|
||||
if (pendingSelectionId == null) return;
|
||||
if (!allEnvironments.some((e) => e.id === pendingSelectionId)) return;
|
||||
|
||||
setPendingSelectionId(null);
|
||||
setSelectedEnvironmentId(pendingSelectionId);
|
||||
|
||||
// The tree leaves out sub-environments while there are multiple base environments
|
||||
const node = tree == null ? null : findTreeNode(tree, pendingSelectionId);
|
||||
if (node == null) return;
|
||||
if (node.parent != null) {
|
||||
// Expand the parent so the new item is visible to be selected
|
||||
const parentId = node.parent.item.id;
|
||||
jotaiStore.set(collapsedFamily(treeId), (prev) => ({ ...prev, [parentId]: false }));
|
||||
}
|
||||
// Wait a frame so the tree's own pending selection fixups, which may have been scheduled
|
||||
// before the new item arrived, don't reset this selection
|
||||
requestAnimationFrame(() => treeRef.current?.selectItem(pendingSelectionId));
|
||||
}, [allEnvironments, pendingSelectionId, setSelectedEnvironmentId, tree, treeId]);
|
||||
|
||||
const handleDeleteEnvironment = useCallback(
|
||||
async (environment: Environment) => {
|
||||
await deleteModelWithConfirm(environment);
|
||||
@@ -134,6 +169,14 @@ function EnvironmentEditDialogSidebar({
|
||||
[baseEnvironment?.id, selectedEnvironmentId, setSelectedEnvironmentId],
|
||||
);
|
||||
|
||||
const handleCreateSubEnvironment = useCallback(async () => {
|
||||
const { baseEnvironment } = jotaiStore.get(environmentsBreakdownAtom);
|
||||
if (baseEnvironment == null) return;
|
||||
const id = await createSubEnvironment.mutateAsync(baseEnvironment);
|
||||
// Show the new environment in the editor without changing the workspace's active environment
|
||||
if (id != null) setPendingSelectionId(id);
|
||||
}, []);
|
||||
|
||||
const treeHasFocus = useCallback(() => treeRef.current?.hasFocus() ?? false, []);
|
||||
|
||||
const getSelectedTreeModels = useCallback(
|
||||
@@ -195,13 +238,14 @@ function EnvironmentEditDialogSidebar({
|
||||
const addEnvironmentItem: DropdownItem = {
|
||||
label: "Create Sub Environment",
|
||||
leftSlot: <Icon icon="plus" />,
|
||||
onSelect: async () => {
|
||||
await createSubEnvironment();
|
||||
},
|
||||
onSelect: handleCreateSubEnvironment,
|
||||
};
|
||||
// Sub-environments aren't shown while there are multiple base environments, same as the
|
||||
// plus button on the base environment row
|
||||
const canCreateSubEnvironment = baseEnvironments.length <= 1;
|
||||
|
||||
if (environment == null || environment.model !== "environment") {
|
||||
return [addEnvironmentItem];
|
||||
return canCreateSubEnvironment ? [addEnvironmentItem] : [];
|
||||
}
|
||||
|
||||
const singleEnvironment = items.length === 1;
|
||||
@@ -257,7 +301,7 @@ function EnvironmentEditDialogSidebar({
|
||||
];
|
||||
|
||||
// Add sub environment to base environment
|
||||
if (isBaseEnvironment(environment) && singleEnvironment) {
|
||||
if (isBaseEnvironment(environment) && singleEnvironment && canCreateSubEnvironment) {
|
||||
menuItems.push({ type: "separator" });
|
||||
menuItems.push(addEnvironmentItem);
|
||||
}
|
||||
@@ -266,6 +310,7 @@ function EnvironmentEditDialogSidebar({
|
||||
},
|
||||
[
|
||||
baseEnvironments.length,
|
||||
handleCreateSubEnvironment,
|
||||
handleDeleteEnvironment,
|
||||
handleDuplicateSelected,
|
||||
handleRenameSelected,
|
||||
@@ -317,34 +362,37 @@ function EnvironmentEditDialogSidebar({
|
||||
);
|
||||
|
||||
const renderContextMenuFn = useCallback<NonNullable<TreeProps<TreeModel>["renderContextMenu"]>>(
|
||||
({ items, position, onClose }) => (
|
||||
<ContextMenu items={items as DropdownItem[]} triggerPosition={position} onClose={onClose} />
|
||||
),
|
||||
({ items, position, onClose }) =>
|
||||
// Right-clicking empty space has nothing to offer while there are multiple base environments
|
||||
items.length === 0 ? null : (
|
||||
<ContextMenu items={items as DropdownItem[]} triggerPosition={position} onClose={onClose} />
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const tree = useAtomValue(treeAtom);
|
||||
return (
|
||||
<aside className="x-theme-sidebar h-full w-full min-w-0 grid overflow-y-auto border-r border-border-subtle ">
|
||||
{tree != null && (
|
||||
<div className="pt-2">
|
||||
<Tree
|
||||
ref={treeRef}
|
||||
treeId={treeId}
|
||||
collapsedAtom={collapsedFamily(treeId)}
|
||||
className="px-2 pb-10"
|
||||
root={tree}
|
||||
getContextMenu={getContextMenu}
|
||||
renderContextMenu={renderContextMenuFn}
|
||||
onDragEnd={handleDragEnd}
|
||||
getItemKey={(i) => `${i.id}::${i.name}`}
|
||||
ItemLeftSlotInner={ItemLeftSlotInner}
|
||||
ItemRightSlot={ItemRightSlot}
|
||||
ItemInner={ItemInner}
|
||||
onActivate={handleActivate}
|
||||
getEditOptions={getEditOptions}
|
||||
/>
|
||||
</div>
|
||||
<CreateSubEnvironmentContext.Provider value={handleCreateSubEnvironment}>
|
||||
<div className="pt-2">
|
||||
<Tree
|
||||
ref={treeRef}
|
||||
treeId={treeId}
|
||||
collapsedAtom={collapsedFamily(treeId)}
|
||||
className="px-2 pb-10"
|
||||
root={tree}
|
||||
getContextMenu={getContextMenu}
|
||||
renderContextMenu={renderContextMenuFn}
|
||||
onDragEnd={handleDragEnd}
|
||||
getItemKey={(i) => `${i.id}::${i.name}`}
|
||||
ItemLeftSlotInner={ItemLeftSlotInner}
|
||||
ItemRightSlot={ItemRightSlot}
|
||||
ItemInner={ItemInner}
|
||||
onActivate={handleActivate}
|
||||
getEditOptions={getEditOptions}
|
||||
/>
|
||||
</div>
|
||||
</CreateSubEnvironmentContext.Provider>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
@@ -384,6 +432,15 @@ const treeAtom = atom<TreeNode<TreeModel> | null>((get) => {
|
||||
return root;
|
||||
});
|
||||
|
||||
function findTreeNode(node: TreeNode<TreeModel>, id: string): TreeNode<TreeModel> | null {
|
||||
if (node.item.id === id) return node;
|
||||
for (const child of node.children ?? []) {
|
||||
const found = findTreeNode(child, id);
|
||||
if (found != null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function ItemLeftSlotInner({ item }: { item: TreeModel }) {
|
||||
const { baseEnvironments } = useEnvironmentsBreakdown();
|
||||
return baseEnvironments.length > 1 ? (
|
||||
@@ -395,6 +452,7 @@ function ItemLeftSlotInner({ item }: { item: TreeModel }) {
|
||||
|
||||
function ItemRightSlot({ item }: { item: TreeModel }) {
|
||||
const { baseEnvironments } = useEnvironmentsBreakdown();
|
||||
const handleCreateSubEnvironment = useContext(CreateSubEnvironmentContext);
|
||||
return (
|
||||
<>
|
||||
{item.model === "environment" && baseEnvironments.length <= 1 && isBaseEnvironment(item) && (
|
||||
@@ -405,7 +463,7 @@ function ItemRightSlot({ item }: { item: TreeModel }) {
|
||||
icon="plus_circle"
|
||||
className="opacity-50 hover:opacity-100"
|
||||
title="Add Sub-Environment"
|
||||
onClick={createSubEnvironment}
|
||||
onClick={handleCreateSubEnvironment}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -425,13 +483,6 @@ function ItemInner({ item }: { item: TreeModel }) {
|
||||
);
|
||||
}
|
||||
|
||||
async function createSubEnvironment() {
|
||||
const { baseEnvironment } = jotaiStore.get(environmentsBreakdownAtom);
|
||||
if (baseEnvironment == null) return;
|
||||
const id = await createSubEnvironmentAndActivate.mutateAsync(baseEnvironment);
|
||||
return id;
|
||||
}
|
||||
|
||||
function getEditOptions(item: TreeModel) {
|
||||
const options: ReturnType<NonNullable<TreeProps<TreeModel>["getEditOptions"]>> = {
|
||||
defaultValue: item.name,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IconTooltip } from "./core/IconTooltip";
|
||||
import { IconTooltip } from "@yaakapp-internal/ui";
|
||||
|
||||
export function EnvironmentSharableTooltip() {
|
||||
return (
|
||||
|
||||
@@ -1,60 +1,19 @@
|
||||
import { Banner, Button, InlineCode } from "@yaakapp-internal/ui";
|
||||
import type { ErrorInfo, ReactNode } from "react";
|
||||
import { Component, useEffect } from "react";
|
||||
import { ErrorBoundary as BaseErrorBoundary } from "@yaakapp-internal/ui";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import RouteError from "./RouteError";
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
name: string;
|
||||
children: ReactNode;
|
||||
export function ErrorBoundary({ name, children }: { name: string; children: ReactNode }) {
|
||||
return (
|
||||
<BaseErrorBoundary name={name} onShowError={showErrorDialog}>
|
||||
{children}
|
||||
</BaseErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.warn("Error caught by ErrorBoundary:", error, info);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<Banner color="danger" className="flex items-center gap-2 overflow-auto">
|
||||
<div>
|
||||
Error rendering <InlineCode>{this.props.name}</InlineCode> component
|
||||
</div>
|
||||
<Button
|
||||
className="inline-flex"
|
||||
variant="border"
|
||||
color="danger"
|
||||
size="2xs"
|
||||
onClick={() => {
|
||||
showDialog({
|
||||
id: "error-boundary",
|
||||
render: () => <RouteError error={this.state.error} />,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Show
|
||||
</Button>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
export function showErrorDialog(error: unknown) {
|
||||
showDialog({ id: "error-boundary", render: () => <RouteError error={error} /> });
|
||||
}
|
||||
|
||||
export function ErrorBoundaryTestThrow() {
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { 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";
|
||||
import { VStack } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import slugify from "slugify";
|
||||
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
|
||||
import { pluralizeCount } from "../lib/pluralize";
|
||||
import { 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 { DialogFooter } from "./core/Dialog";
|
||||
import { Link } from "./core/Link";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
|
||||
|
||||
interface Props {
|
||||
onHide: () => void;
|
||||
@@ -65,29 +65,30 @@ function ExportDataDialogContent({
|
||||
const ids = Object.keys(selectedWorkspaces).filter((k) => selectedWorkspaces[k]);
|
||||
const workspace = ids.length === 1 ? workspaces.find((w) => w.id === ids[0]) : undefined;
|
||||
const slug = workspace ? slugify(workspace.name, { lower: true }) : "workspaces";
|
||||
const exportPath = await save({
|
||||
title: "Export Data",
|
||||
defaultPath: `yaak.${slug}.json`,
|
||||
});
|
||||
if (exportPath == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await invokeCmd("cmd_export_data", {
|
||||
const document = await rpc<string>("cmd_export_data", {
|
||||
workspaceIds: ids,
|
||||
exportPath,
|
||||
includePrivateEnvironments: includePrivateEnvironments,
|
||||
});
|
||||
|
||||
const savedTo = await platform.files.save(
|
||||
`yaak.${slug}.json`,
|
||||
new TextEncoder().encode(document),
|
||||
[{ name: "JSON", extensions: ["json"] }],
|
||||
);
|
||||
if (savedTo == null) {
|
||||
return; // Cancelled
|
||||
}
|
||||
|
||||
onHide();
|
||||
onSuccess(exportPath);
|
||||
onSuccess(savedTo);
|
||||
}, [includePrivateEnvironments, onHide, onSuccess, selectedWorkspaces, workspaces]);
|
||||
|
||||
const allSelected = workspaces.every((w) => selectedWorkspaces[w.id]);
|
||||
const numSelected = Object.values(selectedWorkspaces).filter(Boolean).length;
|
||||
const noneSelected = numSelected === 0;
|
||||
return (
|
||||
<div className="h-full w-full grid grid-rows-[minmax(0,1fr)_auto] overflow-hidden rounded-b-lg">
|
||||
<VStack space={3} className="overflow-auto px-5 pb-6">
|
||||
<>
|
||||
<VStack space={3} className="pb-4">
|
||||
<CommercialUseBanner source="data-export" title="Exporting work data?" />
|
||||
|
||||
<table className="w-full mb-auto min-w-full max-w-full divide-y divide-surface-highlight">
|
||||
@@ -140,29 +141,22 @@ function ExportDataDialogContent({
|
||||
/>
|
||||
</DetailsBanner>
|
||||
</VStack>
|
||||
<footer className="px-5 grid grid-cols-[1fr_auto] items-center bg-surface py-3 border-t border-border-subtle">
|
||||
<div>
|
||||
<DialogFooter
|
||||
leftSlot={
|
||||
<Link href="https://yaak.app/button/new" noUnderline className="text-text-subtlest">
|
||||
Create Run Button
|
||||
</Link>
|
||||
</div>
|
||||
<HStack space={2} justifyContent="end">
|
||||
<Button size="sm" className="focus" variant="border" onClick={onHide}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
type="submit"
|
||||
className="focus"
|
||||
color="primary"
|
||||
disabled={noneSelected}
|
||||
onClick={() => handleExport()}
|
||||
>
|
||||
Export{" "}
|
||||
{pluralizeCount("Workspace", numSelected, { omitSingle: true, noneWord: "Nothing" })}
|
||||
</Button>
|
||||
</HStack>
|
||||
</footer>
|
||||
</div>
|
||||
}
|
||||
actions={[
|
||||
{ label: "Cancel", onClick: onHide },
|
||||
{
|
||||
label: `Export ${pluralizeCount("Workspace", numSelected, { omitSingle: true, noneWord: "Nothing" })}`,
|
||||
color: "primary",
|
||||
disabled: noneSelected,
|
||||
onClick: () => handleExport(),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { Folder, GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-internal/models";
|
||||
import { foldersAtom } from "@yaakapp-internal/models";
|
||||
import { Heading, HStack, Icon, LoadingIcon } from "@yaakapp-internal/ui";
|
||||
import {
|
||||
Heading,
|
||||
HStack,
|
||||
Icon,
|
||||
LoadingIcon,
|
||||
HttpResponseDurationTag,
|
||||
HttpStatusTag,
|
||||
SizeTag,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useAtomValue } from "jotai";
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
@@ -13,11 +21,8 @@ import { showDialog } from "../lib/dialog";
|
||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
||||
import { router } from "../lib/router";
|
||||
import { Button } from "./core/Button";
|
||||
import { HttpResponseDurationTag } from "./core/HttpResponseDurationTag";
|
||||
import { HttpStatusTag } from "./core/HttpStatusTag";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { Separator } from "./core/Separator";
|
||||
import { SizeTag } from "./core/SizeTag";
|
||||
import { HttpResponsePane } from "./HttpResponsePane";
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createWorkspaceModel, foldersAtom, patchModel } from "@yaakapp-internal/models";
|
||||
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import { HStack, Icon, InlineCode, VStack, CountBadge } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { Fragment, useMemo } from "react";
|
||||
import { useAuthTab } from "../hooks/useAuthTab";
|
||||
@@ -11,7 +11,6 @@ import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
|
||||
import { hideDialog } from "../lib/dialog";
|
||||
import { CopyIconButton } from "./CopyIconButton";
|
||||
import { Button } from "./core/Button";
|
||||
import { CountBadge } from "./core/CountBadge";
|
||||
import { Input } from "./core/Input";
|
||||
import { Link } from "./core/Link";
|
||||
import type { TabItem } from "./core/Tabs/Tabs";
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import type { Pair, PairEditorProps } from "./core/PairEditor";
|
||||
import { PairOrBulkEditor } from "./core/PairOrBulkEditor";
|
||||
|
||||
type Props = {
|
||||
forceUpdateKey: string;
|
||||
request: HttpRequest;
|
||||
onChange: (headers: HttpRequest["body"]) => void;
|
||||
};
|
||||
|
||||
export function FormUrlencodedEditor({ request, forceUpdateKey, onChange }: Props) {
|
||||
const pairs = useMemo<Pair[]>(
|
||||
() =>
|
||||
(Array.isArray(request.body.form) ? request.body.form : []).map((p) => ({
|
||||
enabled: !!p.enabled,
|
||||
name: p.name || "",
|
||||
value: p.value || "",
|
||||
id: p.id,
|
||||
})),
|
||||
[request.body.form],
|
||||
);
|
||||
|
||||
const handleChange = useCallback<PairEditorProps["onChange"]>(
|
||||
(pairs) =>
|
||||
onChange({ form: pairs.map((p) => ({ enabled: p.enabled, name: p.name, value: p.value })) }),
|
||||
[onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<PairOrBulkEditor
|
||||
allowMultilineValues
|
||||
preferenceName="form_urlencoded"
|
||||
valueAutocompleteFunctions
|
||||
valueAutocompleteVariables
|
||||
nameAutocompleteFunctions
|
||||
nameAutocompleteVariables
|
||||
namePlaceholder="entry_name"
|
||||
valuePlaceholder="Value"
|
||||
pairs={pairs}
|
||||
onChange={handleChange}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
stateKey={`urlencoded.${request.id}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { NavigateWorkspace } from "@yaakapp-internal/tauri-client";
|
||||
import { activeRequestAtom } from "../hooks/useActiveRequest";
|
||||
import { useSubscribeActiveWorkspaceId } from "../hooks/useActiveWorkspace";
|
||||
import { useActiveWorkspaceChangedToast } from "../hooks/useActiveWorkspaceChangedToast";
|
||||
@@ -5,10 +6,13 @@ import { useHotKey, useSubscribeHotKeys } from "../hooks/useHotKey";
|
||||
import { useSubscribeHttpAuthentication } from "../hooks/useHttpAuthentication";
|
||||
import { useSyncFontSizeSetting } from "../hooks/useSyncFontSizeSetting";
|
||||
import { useSyncWorkspaceChildModels } from "../hooks/useSyncWorkspaceChildModels";
|
||||
import { usePlatformEvent } from "../hooks/usePlatformEvent";
|
||||
import { useSyncZoomSetting } from "../hooks/useSyncZoomSetting";
|
||||
import { useSubscribeTemplateFunctions } from "../hooks/useTemplateFunctions";
|
||||
import { fireAndForget } from "../lib/fireAndForget";
|
||||
import { jotaiStore } from "../lib/jotai";
|
||||
import { renameModelWithPrompt } from "../lib/renameModelWithPrompt";
|
||||
import { router } from "../lib/router";
|
||||
|
||||
export function GlobalHooks() {
|
||||
useSyncZoomSetting();
|
||||
@@ -24,6 +28,20 @@ export function GlobalHooks() {
|
||||
useActiveWorkspaceChangedToast();
|
||||
useSubscribeHotKeys();
|
||||
|
||||
usePlatformEvent("show_home", () => {
|
||||
fireAndForget(router.navigate({ to: "/", search: { home: true } }));
|
||||
});
|
||||
|
||||
usePlatformEvent<NavigateWorkspace>("navigate_workspace", ({ workspaceId, environmentId }) => {
|
||||
fireAndForget(
|
||||
router.navigate({
|
||||
to: "/workspaces/$workspaceId",
|
||||
params: { workspaceId },
|
||||
search: { environment_id: environmentId },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
useHotKey(
|
||||
"request.rename",
|
||||
async () => {
|
||||
|
||||
@@ -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,15 +10,23 @@ 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 { pluralizeCount } from "../lib/pluralize";
|
||||
import type { JsonSchema } from "../lib/jsonSchemaExample";
|
||||
import { buildExampleFromSchema } from "../lib/jsonSchemaExample";
|
||||
import { queryClient } from "../lib/queryClient";
|
||||
import { Button } from "./core/Button";
|
||||
import { Dropdown } from "./core/Dropdown";
|
||||
import type { EditorProps } from "./core/Editor/Editor";
|
||||
import { Editor } from "./core/Editor/LazyEditor";
|
||||
import { GrpcProtoSelectionDialog } from "./GrpcProtoSelectionDialog";
|
||||
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
|
||||
|
||||
type Props = Pick<EditorProps, "heightMode" | "onChange" | "className" | "forceUpdateKey"> & {
|
||||
services: ReflectResponseService[] | null;
|
||||
@@ -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,146 @@ 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",
|
||||
className: "h-[30rem]",
|
||||
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,13 +1,13 @@
|
||||
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 { Banner, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import { useActiveRequest } from "../hooks/useActiveRequest";
|
||||
import { useGrpc } from "../hooks/useGrpc";
|
||||
import { useGrpcProtoFiles } from "../hooks/useGrpcProtoFiles";
|
||||
import { pluralizeCount } from "../lib/pluralize";
|
||||
import { Button } from "./core/Button";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { DialogFooter } from "./core/Dialog";
|
||||
import { PathList } from "./core/PathList";
|
||||
import { Link } from "./core/Link";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
|
||||
|
||||
interface Props {
|
||||
onDone: () => void;
|
||||
@@ -20,7 +20,10 @@ export function GrpcProtoSelectionDialog(props: Props) {
|
||||
return <GrpcProtoSelectionDialogWithRequest request={request} {...props} />;
|
||||
}
|
||||
|
||||
function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: GrpcRequest }) {
|
||||
function GrpcProtoSelectionDialogWithRequest({
|
||||
request,
|
||||
onDone,
|
||||
}: Props & { request: GrpcRequest }) {
|
||||
const protoFilesKv = useGrpcProtoFiles(request.id);
|
||||
const protoFiles = protoFilesKv.value ?? [];
|
||||
const grpc = useGrpc(request, null, protoFiles);
|
||||
@@ -38,54 +41,8 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack className="flex-col-reverse mb-3" space={3}>
|
||||
{/* Buttons on top so they get focus first */}
|
||||
<HStack space={2} justifyContent="start" className="flex-row-reverse mt-3">
|
||||
<Button
|
||||
color="primary"
|
||||
variant="border"
|
||||
onClick={async () => {
|
||||
const selected = await open({
|
||||
title: "Select Proto Files",
|
||||
multiple: true,
|
||||
filters: [{ name: "Proto Files", extensions: ["proto"] }],
|
||||
});
|
||||
if (selected == null) return;
|
||||
|
||||
const newFiles = selected.filter((p) => !protoFiles.includes(p));
|
||||
await protoFilesKv.set([...protoFiles, ...newFiles]);
|
||||
await grpc.reflect.refetch();
|
||||
}}
|
||||
>
|
||||
Add Proto Files
|
||||
</Button>
|
||||
<Button
|
||||
variant="border"
|
||||
color="primary"
|
||||
onClick={async () => {
|
||||
const selected = await open({
|
||||
title: "Select Proto Directory",
|
||||
directory: true,
|
||||
});
|
||||
if (selected == null) return;
|
||||
|
||||
await protoFilesKv.set([...protoFiles.filter((f) => f !== selected), selected]);
|
||||
await grpc.reflect.refetch();
|
||||
}}
|
||||
>
|
||||
Add Import Folders
|
||||
</Button>
|
||||
<Button
|
||||
isLoading={grpc.reflect.isFetching}
|
||||
disabled={grpc.reflect.isFetching}
|
||||
variant="border"
|
||||
color="secondary"
|
||||
onClick={() => grpc.reflect.refetch()}
|
||||
>
|
||||
Refresh Schema
|
||||
</Button>
|
||||
</HStack>
|
||||
<VStack space={5}>
|
||||
<>
|
||||
<VStack space={5} className="pb-4">
|
||||
{reflectError && (
|
||||
<Banner color="warning">
|
||||
<h1 className="font-bold">
|
||||
@@ -128,46 +85,47 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
{protoFiles.length > 0 && (
|
||||
<table className="w-full divide-y divide-surface-highlight">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-text-subtlest" colSpan={3}>
|
||||
Added File Paths
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-surface-highlight">
|
||||
{protoFiles.map((f, i) => {
|
||||
const parts = f.split("/");
|
||||
// oxlint-disable-next-line no-array-index-key -- none
|
||||
return (
|
||||
<tr key={f + i} className="group">
|
||||
<td>
|
||||
<Icon icon={f.endsWith(".proto") ? "file_code" : "folder_code"} />
|
||||
</td>
|
||||
<td className="pl-1 font-mono text-sm" title={f}>
|
||||
{parts.length > 3 && ".../"}
|
||||
{parts.slice(-3).join("/")}
|
||||
</td>
|
||||
<td className="w-0 py-0.5">
|
||||
<IconButton
|
||||
title="Remove file"
|
||||
variant="border"
|
||||
size="xs"
|
||||
icon="trash"
|
||||
className="my-0.5 ml-auto opacity-50 transition-opacity group-hover:opacity-100"
|
||||
onClick={async () => {
|
||||
await protoFilesKv.set(protoFiles.filter((p) => p !== f));
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<PathList
|
||||
items={protoFiles.map((path) => ({
|
||||
path,
|
||||
icon: path.endsWith(".proto") ? "file_code" : "folder_code",
|
||||
}))}
|
||||
onRemove={async (path) => {
|
||||
await protoFilesKv.set(protoFiles.filter((p) => p !== path));
|
||||
await grpc.reflect.refetch();
|
||||
}}
|
||||
addLabel="Add proto files or an import folder"
|
||||
addItems={[
|
||||
{
|
||||
label: "Proto files",
|
||||
icon: "file_code",
|
||||
onSelect: async () => {
|
||||
const selected = await platform.dialog.open({
|
||||
title: "Select Proto Files",
|
||||
multiple: true,
|
||||
filters: [{ name: "Proto Files", extensions: ["proto"] }],
|
||||
});
|
||||
if (selected == null) return;
|
||||
const newFiles = selected.filter((p) => !protoFiles.includes(p));
|
||||
await protoFilesKv.set([...protoFiles, ...newFiles]);
|
||||
await grpc.reflect.refetch();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Import folder",
|
||||
icon: "folder_code",
|
||||
onSelect: async () => {
|
||||
const selected = await platform.dialog.open({
|
||||
title: "Select Proto Directory",
|
||||
directory: true,
|
||||
});
|
||||
if (selected == null) return;
|
||||
await protoFilesKv.set([...protoFiles.filter((f) => f !== selected), selected]);
|
||||
await grpc.reflect.refetch();
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{reflectionUnimplemented && protoFiles.length === 0 && (
|
||||
<Banner>
|
||||
<InlineCode>{request.url}</InlineCode> doesn't implement{" "}
|
||||
@@ -178,6 +136,17 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
|
||||
</Banner>
|
||||
)}
|
||||
</VStack>
|
||||
</VStack>
|
||||
<DialogFooter
|
||||
actions={[
|
||||
{
|
||||
label: "Refresh Schema",
|
||||
isLoading: grpc.reflect.isFetching,
|
||||
disabled: grpc.reflect.isFetching,
|
||||
onClick: () => grpc.reflect.refetch(),
|
||||
},
|
||||
{ label: "Done", color: "primary", onClick: onDone },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import { type GrpcRequest, type HttpRequestHeader, patchModel } from "@yaakapp-internal/models";
|
||||
import { HStack, Icon, useContainerSize, VStack } from "@yaakapp-internal/ui";
|
||||
import {
|
||||
type GrpcRequest,
|
||||
type HttpRequestHeader,
|
||||
patchModel,
|
||||
patchModelDebounced,
|
||||
} from "@yaakapp-internal/models";
|
||||
import { HStack, Icon, useContainerSize, VStack, CountBadge } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import type { CSSProperties } from "react";
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
@@ -10,7 +15,6 @@ import { useInheritedHeaders } from "../hooks/useInheritedHeaders";
|
||||
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
|
||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
||||
import { Button } from "./core/Button";
|
||||
import { CountBadge } from "./core/CountBadge";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import { RadioDropdown } from "./core/RadioDropdown";
|
||||
@@ -75,11 +79,13 @@ export function GrpcRequestPane({
|
||||
const { width: paneWidth } = useContainerSize(urlContainerEl);
|
||||
|
||||
const handleChangeUrl = useCallback(
|
||||
(url: string) => patchModel(activeRequest, { url }),
|
||||
(url: string) => patchModelDebounced(activeRequest, { url }),
|
||||
[activeRequest],
|
||||
);
|
||||
|
||||
const handleChangeMessage = useCallback(
|
||||
// Not debounced: handleSend reads message from the store, so a pending
|
||||
// debounced patch would send stale text
|
||||
(message: string) => patchModel(activeRequest, { message }),
|
||||
[activeRequest],
|
||||
);
|
||||
@@ -126,6 +132,8 @@ export function GrpcRequestPane({
|
||||
onSend({ message: activeRequest.message });
|
||||
}, [activeRequest, onSend]);
|
||||
|
||||
const hasDescription = activeRequest.description.trim().length > 0;
|
||||
|
||||
const tabs: TabItem[] = useMemo(
|
||||
() => [
|
||||
{ value: TAB_MESSAGE, label: "Message" },
|
||||
@@ -139,19 +147,19 @@ export function GrpcRequestPane({
|
||||
{
|
||||
value: TAB_DESCRIPTION,
|
||||
label: "Info",
|
||||
rightSlot: activeRequest.description && <CountBadge count={true} />,
|
||||
rightSlot: hasDescription && <CountBadge count={true} />,
|
||||
},
|
||||
],
|
||||
[activeRequest.description, authTab, metadataTab, numSettingsOverrides],
|
||||
[authTab, hasDescription, metadataTab, numSettingsOverrides],
|
||||
);
|
||||
|
||||
const handleMetadataChange = useCallback(
|
||||
(metadata: HttpRequestHeader[]) => patchModel(activeRequest, { metadata }),
|
||||
(metadata: HttpRequestHeader[]) => patchModelDebounced(activeRequest, { metadata }),
|
||||
[activeRequest],
|
||||
);
|
||||
|
||||
const handleDescriptionChange = useCallback(
|
||||
(description: string) => patchModel(activeRequest, { description }),
|
||||
(description: string) => patchModelDebounced(activeRequest, { description }),
|
||||
[activeRequest],
|
||||
);
|
||||
|
||||
@@ -187,13 +195,6 @@ export function GrpcRequestPane({
|
||||
type: "default",
|
||||
shortLabel: o.label,
|
||||
}))}
|
||||
itemsAfter={[
|
||||
{
|
||||
label: "Refresh",
|
||||
type: "default",
|
||||
leftSlot: <Icon size="sm" icon="refresh" />,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -299,7 +300,7 @@ export function GrpcRequestPane({
|
||||
className="font-sans text-xl! px-0!"
|
||||
containerClassName="border-0"
|
||||
placeholder={resolvedModelName(activeRequest)}
|
||||
onChange={(name) => patchModel(activeRequest, { name })}
|
||||
onChange={(name) => patchModelDebounced(activeRequest, { name })}
|
||||
/>
|
||||
<MarkdownEditor
|
||||
name="request-description"
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import type { GrpcEvent, GrpcRequest } from "@yaakapp-internal/models";
|
||||
import { HStack, Icon, type IconProps, LoadingIcon, VStack } from "@yaakapp-internal/ui";
|
||||
import {
|
||||
HStack,
|
||||
Icon,
|
||||
type IconProps,
|
||||
LoadingIcon,
|
||||
VStack,
|
||||
useStateWithDeps,
|
||||
KeyValueRows,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import type { CSSProperties } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
@@ -9,13 +17,12 @@ import {
|
||||
pinnedGrpcConnectionIdAtom,
|
||||
useGrpcEvents,
|
||||
} from "../hooks/usePinnedGrpcConnection";
|
||||
import { useStateWithDeps } from "../hooks/useStateWithDeps";
|
||||
import { Button } from "./core/Button";
|
||||
import { Editor } from "./core/Editor/LazyEditor";
|
||||
import { EventDetailHeader, EventViewer } from "./core/EventViewer";
|
||||
import { EventViewerRow } from "./core/EventViewerRow";
|
||||
import { HotkeyList } from "./core/HotkeyList";
|
||||
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
|
||||
import { KeyValueRow } from "./core/KeyValueRow";
|
||||
import { EmptyStateText } from "./EmptyStateText";
|
||||
import { ErrorBoundary } from "./ErrorBoundary";
|
||||
import { RecentGrpcConnectionsDropdown } from "./RecentGrpcConnectionsDropdown";
|
||||
|
||||
@@ -1,168 +1 @@
|
||||
import type { HttpRequestHeader } from "@yaakapp-internal/models";
|
||||
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
|
||||
import { HStack } from "@yaakapp-internal/ui";
|
||||
import { charsets } from "../lib/data/charsets";
|
||||
import { connections } from "../lib/data/connections";
|
||||
import { encodings } from "../lib/data/encodings";
|
||||
import { headerNames } from "../lib/data/headerNames";
|
||||
import { mimeTypes } from "../lib/data/mimetypes";
|
||||
import { CountBadge } from "./core/CountBadge";
|
||||
import { DetailsBanner } from "./core/DetailsBanner";
|
||||
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
||||
import type { InputProps } from "./core/Input";
|
||||
import type { Pair, PairEditorProps } from "./core/PairEditor";
|
||||
import { PairEditorRow } from "./core/PairEditor";
|
||||
import { ensurePairId } from "./core/PairEditor.util";
|
||||
import { PairOrBulkEditor } from "./core/PairOrBulkEditor";
|
||||
|
||||
type Props = {
|
||||
forceUpdateKey: string;
|
||||
headers: HttpRequestHeader[];
|
||||
inheritedHeaders?: HttpRequestHeader[];
|
||||
inheritedHeadersLabel?: string;
|
||||
stateKey: string;
|
||||
onChange: (headers: HttpRequestHeader[]) => void;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export function HeadersEditor({
|
||||
stateKey,
|
||||
headers,
|
||||
inheritedHeaders,
|
||||
inheritedHeadersLabel = "Inherited",
|
||||
onChange,
|
||||
forceUpdateKey,
|
||||
}: Props) {
|
||||
// Get header names defined at current level (case-insensitive)
|
||||
const currentHeaderNames = new Set(
|
||||
headers.filter((h) => h.name).map((h) => h.name.toLowerCase()),
|
||||
);
|
||||
// Filter inherited headers: must be enabled, have content, and not be overridden by current level
|
||||
const validInheritedHeaders =
|
||||
inheritedHeaders?.filter(
|
||||
(pair) =>
|
||||
pair.enabled &&
|
||||
(pair.name || pair.value) &&
|
||||
!currentHeaderNames.has(pair.name.toLowerCase()),
|
||||
) ?? [];
|
||||
const hasInheritedHeaders = validInheritedHeaders.length > 0;
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
hasInheritedHeaders
|
||||
? "@container w-full h-full grid grid-rows-[auto_minmax(0,1fr)] gap-y-1.5"
|
||||
: "@container w-full h-full"
|
||||
}
|
||||
>
|
||||
{hasInheritedHeaders && (
|
||||
<DetailsBanner
|
||||
color="secondary"
|
||||
className="text-sm"
|
||||
summary={
|
||||
<HStack>
|
||||
{inheritedHeadersLabel} <CountBadge count={validInheritedHeaders.length} />
|
||||
</HStack>
|
||||
}
|
||||
>
|
||||
<div className="pb-2">
|
||||
{validInheritedHeaders?.map((pair, i) => (
|
||||
<PairEditorRow
|
||||
key={`${pair.id}.${i}`}
|
||||
index={i}
|
||||
disabled
|
||||
disableDrag
|
||||
className="py-1"
|
||||
pair={ensurePairId(pair)}
|
||||
stateKey={null}
|
||||
nameAutocompleteFunctions
|
||||
nameAutocompleteVariables
|
||||
valueAutocompleteFunctions
|
||||
valueAutocompleteVariables
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</DetailsBanner>
|
||||
)}
|
||||
<PairOrBulkEditor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
nameAutocomplete={nameAutocomplete}
|
||||
nameAutocompleteFunctions
|
||||
nameAutocompleteVariables
|
||||
namePlaceholder="Header-Name"
|
||||
nameValidate={validateHttpHeader}
|
||||
onChange={onChange}
|
||||
pairs={headers}
|
||||
preferenceName="headers"
|
||||
stateKey={stateKey}
|
||||
valueType={valueType}
|
||||
valueAutocomplete={valueAutocomplete}
|
||||
valueAutocompleteFunctions
|
||||
valueAutocompleteVariables
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const MIN_MATCH = 3;
|
||||
|
||||
const headerOptionsMap: Record<string, string[]> = {
|
||||
"content-type": mimeTypes,
|
||||
accept: ["*/*", ...mimeTypes],
|
||||
"accept-encoding": encodings,
|
||||
connection: connections,
|
||||
"accept-charset": charsets,
|
||||
};
|
||||
|
||||
const valueType = (pair: Pair): InputProps["type"] => {
|
||||
const name = pair.name.toLowerCase().trim();
|
||||
if (
|
||||
name.includes("authorization") ||
|
||||
name.includes("api-key") ||
|
||||
name.includes("access-token") ||
|
||||
name.includes("auth") ||
|
||||
name.includes("secret") ||
|
||||
name.includes("token") ||
|
||||
name === "cookie" ||
|
||||
name === "set-cookie"
|
||||
) {
|
||||
return "password";
|
||||
}
|
||||
return "text";
|
||||
};
|
||||
|
||||
const valueAutocomplete = (headerName: string): GenericCompletionConfig | undefined => {
|
||||
const name = headerName.toLowerCase().trim();
|
||||
const options: GenericCompletionOption[] =
|
||||
headerOptionsMap[name]?.map((o) => ({
|
||||
label: o,
|
||||
type: "constant",
|
||||
boost: 1, // Put above other completions
|
||||
})) ?? [];
|
||||
return { minMatch: MIN_MATCH, options };
|
||||
};
|
||||
|
||||
const nameAutocomplete: PairEditorProps["nameAutocomplete"] = {
|
||||
minMatch: MIN_MATCH,
|
||||
options: headerNames.map((t) =>
|
||||
typeof t === "string"
|
||||
? {
|
||||
label: t,
|
||||
type: "constant",
|
||||
boost: 1, // Put above other completions
|
||||
}
|
||||
: {
|
||||
...t,
|
||||
boost: 1, // Put above other completions
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
const validateHttpHeader = (v: string) => {
|
||||
if (v === "") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Template strings are not allowed so we replace them with a valid example string
|
||||
const withoutTemplateStrings = v.replace(/\$\{\[\s*[^\]\s]+\s*]}/gi, "123");
|
||||
return withoutTemplateStrings.match(/^[a-zA-Z0-9-_]+$/) !== null;
|
||||
};
|
||||
export { HeadersEditor } from "@yaakapp-internal/ui";
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
Workspace,
|
||||
} from "@yaakapp-internal/models";
|
||||
import { patchModel } from "@yaakapp-internal/models";
|
||||
import { HStack, Icon, InlineCode } from "@yaakapp-internal/ui";
|
||||
import { HStack, Icon, InlineCode, SegmentedControl } from "@yaakapp-internal/ui";
|
||||
import { useCallback } from "react";
|
||||
import { openFolderSettings } from "../commands/openFolderSettings";
|
||||
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
|
||||
@@ -21,7 +21,6 @@ import { IconButton } from "./core/IconButton";
|
||||
import { Input, type InputProps } from "./core/Input";
|
||||
import { Link } from "./core/Link";
|
||||
import { RadioDropdown } from "./core/RadioDropdown";
|
||||
import { SegmentedControl } from "./core/SegmentedControl";
|
||||
import { DynamicForm } from "./DynamicForm";
|
||||
import { EmptyStateText } from "./EmptyStateText";
|
||||
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||
import { patchModel } from "@yaakapp-internal/models";
|
||||
import { getModel, patchModel, patchModelDebounced } from "@yaakapp-internal/models";
|
||||
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
|
||||
import classNames from "classnames";
|
||||
import {
|
||||
CountBadge,
|
||||
type EditablePair,
|
||||
type ExtraPaneTab,
|
||||
HttpRequestPaneView,
|
||||
InlineCode,
|
||||
languageFromContentType,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { atom, useAtomValue } from "jotai";
|
||||
import type { CSSProperties } from "react";
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { lazy, Suspense, useCallback, useMemo, useRef, useState } from "react";
|
||||
import { activeRequestIdAtom } from "../hooks/useActiveRequestId";
|
||||
import { allRequestsAtom } from "../hooks/useAllRequests";
|
||||
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";
|
||||
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
|
||||
import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
|
||||
import { deepEqualAtom } from "../lib/atoms";
|
||||
import { languageFromContentType } from "../lib/contentType";
|
||||
import { generateId } from "../lib/generateId";
|
||||
import { extractPathPlaceholders } from "../lib/pathPlaceholders";
|
||||
import { convertRequestBody } from "../lib/requestBodyConversion";
|
||||
import { fireAndForget } from "../lib/fireAndForget";
|
||||
import { httpRequestToView } from "../lib/httpViews";
|
||||
import {
|
||||
BODY_TYPE_BINARY,
|
||||
BODY_TYPE_FORM_MULTIPART,
|
||||
@@ -32,30 +35,24 @@ import {
|
||||
BODY_TYPE_XML,
|
||||
getContentTypeFromHeaders,
|
||||
} from "../lib/model_util";
|
||||
import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
|
||||
import { prepareImportQuerystring } from "../lib/prepareImportQuerystring";
|
||||
import { convertRequestBody } from "../lib/requestBodyConversion";
|
||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
||||
import { showToast } from "../lib/toast";
|
||||
import { BinaryFileEditor } from "./BinaryFileEditor";
|
||||
import { ConfirmLargeRequestBody } from "./ConfirmLargeRequestBody";
|
||||
import { CountBadge } from "./core/CountBadge";
|
||||
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
||||
import { Editor } from "./core/Editor/LazyEditor";
|
||||
import { InlineCode } from "@yaakapp-internal/ui";
|
||||
import type { Pair } from "./core/PairEditor";
|
||||
import { getUrlCompletionConfig } from "./core/Editor/url/completion";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
|
||||
import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs";
|
||||
import { EmptyStateText } from "./EmptyStateText";
|
||||
import { FormMultipartEditor } from "./FormMultipartEditor";
|
||||
import { FormUrlencodedEditor } from "./FormUrlencodedEditor";
|
||||
import { HeadersEditor } from "./HeadersEditor";
|
||||
import { setActiveTab } from "./core/Tabs/Tabs";
|
||||
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
|
||||
import { JsonBodyEditor } from "./JsonBodyEditor";
|
||||
import { MarkdownEditor } from "./MarkdownEditor";
|
||||
import { RequestMethodDropdown } from "./RequestMethodDropdown";
|
||||
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
|
||||
import { UrlBar } from "./UrlBar";
|
||||
import { RequestMethodDropdown } from "./RequestMethodDropdown";
|
||||
import { UrlParametersEditor } from "./UrlParameterEditor";
|
||||
import { generateId } from "@yaakapp-internal/lib/generateId";
|
||||
|
||||
const GraphQLEditor = lazy(() =>
|
||||
import("./graphql/GraphQLEditor").then((m) => ({ default: m.GraphQLEditor })),
|
||||
@@ -76,15 +73,12 @@ const TAB_SETTINGS = "settings";
|
||||
const TAB_DESCRIPTION = "description";
|
||||
const TABS_STORAGE_KEY = "http_request_tabs";
|
||||
|
||||
const nonActiveRequestUrlsAtom = atom((get) => {
|
||||
const activeRequestId = get(activeRequestIdAtom);
|
||||
const requests = get(allRequestsAtom);
|
||||
return requests
|
||||
.filter((r) => r.id !== activeRequestId)
|
||||
.map((r): GenericCompletionOption => ({ type: "constant", label: r.url }));
|
||||
});
|
||||
|
||||
const memoNotActiveRequestUrlsAtom = deepEqualAtom(nonActiveRequestUrlsAtom);
|
||||
// Derived from the identity-stable URL list so this only recomputes when a URL
|
||||
// actually changes. The active request's own URL is included, but exact matches
|
||||
// are filtered out at completion time by genericCompletion.
|
||||
const requestUrlOptionsAtom = atom((get): GenericCompletionOption[] =>
|
||||
get(allRequestUrlsAtom).map((url) => ({ type: "constant", label: url })),
|
||||
);
|
||||
|
||||
export function HttpRequestPane({ style, fullHeight, className, activeRequest }: Props) {
|
||||
const activeRequestId = activeRequest.id;
|
||||
@@ -132,20 +126,33 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
[activeRequest],
|
||||
);
|
||||
|
||||
const { urlParameterPairs, urlParametersKey } = useMemo(() => {
|
||||
const placeholderNames = extractPathPlaceholders(activeRequest.url);
|
||||
const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value);
|
||||
const items: Pair[] = [...nonEmptyParameters];
|
||||
for (const name of placeholderNames) {
|
||||
const item = items.find((p) => p.name === name);
|
||||
if (item) {
|
||||
item.readOnlyName = true;
|
||||
} else {
|
||||
items.push({ name, value: "", enabled: true, readOnlyName: true, id: generateId() });
|
||||
}
|
||||
}
|
||||
return { urlParameterPairs: items, urlParametersKey: placeholderNames.join(",") };
|
||||
}, [activeRequest.url, activeRequest.urlParameters]);
|
||||
// Renaming a path placeholder has to rewrite the URL and rename the parameter together, or the
|
||||
// value detaches from the placeholder.
|
||||
// NOTE: Reads the request fresh rather than closing over `activeRequest`. The row that calls this
|
||||
// holds onto it until the URL's placeholders change, so a captured request would go stale and
|
||||
// patch its parameter list back over newer edits.
|
||||
const handleRenamePathPlaceholder = useCallback(
|
||||
(oldName: string, newName: string) => {
|
||||
const request = getModel("http_request", activeRequestId);
|
||||
if (request == null) return false;
|
||||
|
||||
const patch = renamePathPlaceholder(request, oldName, newName);
|
||||
if (patch == null) return false; // Unusable name, so the editor reverts the field
|
||||
void patchModel(request, patch);
|
||||
return true;
|
||||
},
|
||||
[activeRequestId],
|
||||
);
|
||||
|
||||
const { urlParameterPairs, urlParametersKey } = useMemo(
|
||||
() =>
|
||||
derivePathPlaceholderPairs(
|
||||
activeRequest.url,
|
||||
activeRequest.urlParameters,
|
||||
handleRenamePathPlaceholder,
|
||||
),
|
||||
[activeRequest.url, activeRequest.urlParameters, handleRenamePathPlaceholder],
|
||||
);
|
||||
|
||||
let numParams = 0;
|
||||
if (
|
||||
@@ -157,9 +164,11 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
: 0;
|
||||
}
|
||||
|
||||
const tabs = useMemo<TabItem[]>(
|
||||
() => [
|
||||
{
|
||||
const hasDescription = activeRequest.description.trim().length > 0;
|
||||
|
||||
const bodyTab = useMemo<TabItem>(
|
||||
() =>
|
||||
({
|
||||
value: TAB_BODY,
|
||||
rightSlot: numParams > 0 ? <CountBadge count={numParams} /> : null,
|
||||
options: {
|
||||
@@ -236,72 +245,36 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
value: TAB_PARAMS,
|
||||
rightSlot: <CountBadge count={urlParameterPairs.length} />,
|
||||
label: "Params",
|
||||
},
|
||||
...headersTab,
|
||||
...authTab,
|
||||
{
|
||||
value: TAB_SETTINGS,
|
||||
label: "Settings",
|
||||
rightSlot: <CountBadge count={numSettingsOverrides} />,
|
||||
},
|
||||
{
|
||||
value: TAB_DESCRIPTION,
|
||||
label: "Info",
|
||||
},
|
||||
],
|
||||
[
|
||||
activeRequest,
|
||||
authTab,
|
||||
contentType,
|
||||
handleContentTypeChange,
|
||||
headersTab,
|
||||
numParams,
|
||||
numSettingsOverrides,
|
||||
urlParameterPairs.length,
|
||||
],
|
||||
}) satisfies TabItem,
|
||||
[activeRequest, contentType, handleContentTypeChange, numParams],
|
||||
);
|
||||
|
||||
const { mutate: sendRequest } = useSendAnyHttpRequest();
|
||||
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"]) => patchModel(activeRequest, { body }),
|
||||
(body: HttpRequest["body"]) => patchModelDebounced(activeRequest, { body }),
|
||||
[activeRequest],
|
||||
);
|
||||
|
||||
const handleBodyTextChange = useCallback(
|
||||
(text: string) => patchModel(activeRequest, { body: { ...activeRequest.body, text } }),
|
||||
(text: string) => patchModelDebounced(activeRequest, { body: { ...activeRequest.body, text } }),
|
||||
[activeRequest],
|
||||
);
|
||||
|
||||
const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom);
|
||||
const autocompleteUrls = useAtomValue(requestUrlOptionsAtom);
|
||||
|
||||
const autocomplete: GenericCompletionConfig = useMemo(
|
||||
() => ({
|
||||
minMatch: 3,
|
||||
options:
|
||||
autocompleteUrls.length > 0
|
||||
? autocompleteUrls
|
||||
: [
|
||||
{ label: "http://", type: "constant" },
|
||||
{ label: "https://", type: "constant" },
|
||||
],
|
||||
}),
|
||||
() => getUrlCompletionConfig(autocompleteUrls),
|
||||
[autocompleteUrls],
|
||||
);
|
||||
|
||||
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) {
|
||||
@@ -323,7 +296,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
}
|
||||
}
|
||||
},
|
||||
[activeRequest, activeRequestId, forceParamsRefresh, forceUrlRefresh, importCurl],
|
||||
[activeRequest, activeRequestId, forceParamsRefresh, forceUrlRefresh],
|
||||
);
|
||||
const handleSend = useCallback(
|
||||
() => sendRequest(activeRequest.id ?? null),
|
||||
@@ -331,158 +304,156 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
);
|
||||
|
||||
const handleUrlChange = useCallback(
|
||||
(url: string) => patchModel(activeRequest, { url }),
|
||||
(url: string) => patchModelDebounced(activeRequest, { url }),
|
||||
[activeRequest],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
className={classNames(className, "h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1")}
|
||||
>
|
||||
{activeRequest && (
|
||||
<>
|
||||
<UrlBar
|
||||
stateKey={`url.${activeRequest.id}`}
|
||||
key={forceUpdateKey + urlKey}
|
||||
url={activeRequest.url}
|
||||
placeholder="https://example.com"
|
||||
onPasteOverwrite={handlePaste}
|
||||
autocomplete={autocomplete}
|
||||
onSend={handleSend}
|
||||
onCancel={cancelResponse}
|
||||
onUrlChange={handleUrlChange}
|
||||
leftSlot={
|
||||
<div className="py-0.5">
|
||||
<RequestMethodDropdown request={activeRequest} className="ml-0.5 h-full!" />
|
||||
</div>
|
||||
}
|
||||
forceUpdateKey={updateKey}
|
||||
isLoading={activeResponse != null && activeResponse.state !== "closed"}
|
||||
const handleJsonOrBodyTextChange = useCallback(
|
||||
(text: string, bodyType: string | null) => {
|
||||
// JSON writes at once, as its editor always has; other text debounces
|
||||
if (bodyType === BODY_TYPE_JSON) {
|
||||
return patchModel(activeRequest, { body: { ...activeRequest.body, text } });
|
||||
}
|
||||
return handleBodyTextChange(text);
|
||||
},
|
||||
[activeRequest, handleBodyTextChange],
|
||||
);
|
||||
|
||||
const handleSendJsonCommentsChange = useCallback(
|
||||
(sendJsonComments: boolean) => {
|
||||
const newBody = { ...activeRequest.body };
|
||||
if (sendJsonComments) {
|
||||
newBody.sendJsonComments = true;
|
||||
} else {
|
||||
delete newBody.sendJsonComments;
|
||||
}
|
||||
fireAndForget(patchModel(activeRequest, { body: newBody }));
|
||||
},
|
||||
[activeRequest],
|
||||
);
|
||||
|
||||
const requestView = useMemo(() => httpRequestToView(activeRequest), [activeRequest]);
|
||||
|
||||
const extraTabs = useMemo<ExtraPaneTab[]>(
|
||||
() => [
|
||||
{
|
||||
tab: {
|
||||
value: TAB_SETTINGS,
|
||||
label: "Settings",
|
||||
rightSlot: <CountBadge count={numSettingsOverrides} />,
|
||||
},
|
||||
content: <ModelSettingsEditor model={activeRequest} />,
|
||||
},
|
||||
{
|
||||
tab: {
|
||||
value: TAB_DESCRIPTION,
|
||||
label: "Info",
|
||||
rightSlot: hasDescription && <CountBadge count={true} />,
|
||||
},
|
||||
content: (
|
||||
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full">
|
||||
<PlainInput
|
||||
label="Request Name"
|
||||
hideLabel
|
||||
forceUpdateKey={updateKey}
|
||||
defaultValue={activeRequest.name}
|
||||
className="font-sans text-xl! px-0!"
|
||||
containerClassName="border-0"
|
||||
placeholder={resolvedModelName(activeRequest)}
|
||||
onChange={(name) => patchModel(activeRequest, { name })}
|
||||
/>
|
||||
<MarkdownEditor
|
||||
name="request-description"
|
||||
placeholder="Request description"
|
||||
defaultValue={activeRequest.description}
|
||||
stateKey={`description.${activeRequest.id}`}
|
||||
forceUpdateKey={updateKey}
|
||||
onChange={(description) => patchModel(activeRequest, { description })}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[activeRequest, hasDescription, numSettingsOverrides, updateKey],
|
||||
);
|
||||
|
||||
const renderBody = useCallback(
|
||||
(sharedEditor: ReactNode) => (
|
||||
<ConfirmLargeRequestBody request={activeRequest}>
|
||||
{activeRequest.bodyType === BODY_TYPE_GRAPHQL ? (
|
||||
<Suspense>
|
||||
<GraphQLEditor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
baseRequest={activeRequest}
|
||||
request={activeRequest}
|
||||
onChange={handleBodyChange}
|
||||
/>
|
||||
</Suspense>
|
||||
) : activeRequest.bodyType === BODY_TYPE_BINARY ? (
|
||||
<BinaryFileEditor
|
||||
requestId={activeRequest.id}
|
||||
contentType={contentType}
|
||||
body={activeRequest.body}
|
||||
onChange={(body) => patchModelDebounced(activeRequest, { body })}
|
||||
onChangeContentType={handleContentTypeChange}
|
||||
/>
|
||||
<Tabs
|
||||
ref={tabsRef}
|
||||
label="Request"
|
||||
tabs={tabs}
|
||||
tabListClassName="mt-1 -mb-1.5"
|
||||
storageKey={TABS_STORAGE_KEY}
|
||||
activeTabKey={activeRequestId}
|
||||
>
|
||||
<TabContent value={TAB_AUTH}>
|
||||
<HttpAuthenticationEditor model={activeRequest} />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_HEADERS}>
|
||||
<HeadersEditor
|
||||
inheritedHeaders={inheritedHeaders}
|
||||
forceUpdateKey={`${forceUpdateHeaderEditorKey}::${forceUpdateKey}`}
|
||||
headers={activeRequest.headers}
|
||||
stateKey={`headers.${activeRequest.id}`}
|
||||
onChange={(headers) => patchModel(activeRequest, { headers })}
|
||||
/>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_PARAMS}>
|
||||
<UrlParametersEditor
|
||||
stateKey={`params.${activeRequest.id}`}
|
||||
forceUpdateKey={forceUpdateKey + urlParametersKey}
|
||||
pairs={urlParameterPairs}
|
||||
onChange={(urlParameters) => patchModel(activeRequest, { urlParameters })}
|
||||
/>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_SETTINGS}>
|
||||
<ModelSettingsEditor model={activeRequest} />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_BODY}>
|
||||
<ConfirmLargeRequestBody request={activeRequest}>
|
||||
{activeRequest.bodyType === BODY_TYPE_JSON ? (
|
||||
<JsonBodyEditor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
heightMode={fullHeight ? "full" : "auto"}
|
||||
request={activeRequest}
|
||||
/>
|
||||
) : activeRequest.bodyType === BODY_TYPE_XML ? (
|
||||
<Editor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
autocompleteFunctions
|
||||
autocompleteVariables
|
||||
placeholder="..."
|
||||
heightMode={fullHeight ? "full" : "auto"}
|
||||
defaultValue={`${activeRequest.body?.text ?? ""}`}
|
||||
language="xml"
|
||||
onChange={handleBodyTextChange}
|
||||
stateKey={`xml.${activeRequest.id}`}
|
||||
/>
|
||||
) : activeRequest.bodyType === BODY_TYPE_GRAPHQL ? (
|
||||
<Suspense>
|
||||
<GraphQLEditor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
baseRequest={activeRequest}
|
||||
request={activeRequest}
|
||||
onChange={handleBodyChange}
|
||||
/>
|
||||
</Suspense>
|
||||
) : activeRequest.bodyType === BODY_TYPE_FORM_URLENCODED ? (
|
||||
<FormUrlencodedEditor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
request={activeRequest}
|
||||
onChange={handleBodyChange}
|
||||
/>
|
||||
) : activeRequest.bodyType === BODY_TYPE_FORM_MULTIPART ? (
|
||||
<FormMultipartEditor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
request={activeRequest}
|
||||
onChange={handleBodyChange}
|
||||
/>
|
||||
) : activeRequest.bodyType === BODY_TYPE_BINARY ? (
|
||||
<BinaryFileEditor
|
||||
requestId={activeRequest.id}
|
||||
contentType={contentType}
|
||||
body={activeRequest.body}
|
||||
onChange={(body) => patchModel(activeRequest, { body })}
|
||||
onChangeContentType={handleContentTypeChange}
|
||||
/>
|
||||
) : typeof activeRequest.bodyType === "string" ? (
|
||||
<Editor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
autocompleteFunctions
|
||||
autocompleteVariables
|
||||
language={languageFromContentType(contentType)}
|
||||
placeholder="..."
|
||||
heightMode={fullHeight ? "full" : "auto"}
|
||||
defaultValue={`${activeRequest.body?.text ?? ""}`}
|
||||
onChange={handleBodyTextChange}
|
||||
stateKey={`other.${activeRequest.id}`}
|
||||
/>
|
||||
) : (
|
||||
<EmptyStateText>No Body</EmptyStateText>
|
||||
)}
|
||||
</ConfirmLargeRequestBody>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_DESCRIPTION}>
|
||||
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full">
|
||||
<PlainInput
|
||||
label="Request Name"
|
||||
hideLabel
|
||||
forceUpdateKey={updateKey}
|
||||
defaultValue={activeRequest.name}
|
||||
className="font-sans text-xl! px-0!"
|
||||
containerClassName="border-0"
|
||||
placeholder={resolvedModelName(activeRequest)}
|
||||
onChange={(name) => patchModel(activeRequest, { name })}
|
||||
/>
|
||||
<MarkdownEditor
|
||||
name="request-description"
|
||||
placeholder="Request description"
|
||||
defaultValue={activeRequest.description}
|
||||
stateKey={`description.${activeRequest.id}`}
|
||||
forceUpdateKey={updateKey}
|
||||
onChange={(description) => patchModel(activeRequest, { description })}
|
||||
/>
|
||||
</div>
|
||||
</TabContent>
|
||||
</Tabs>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
sharedEditor
|
||||
)}
|
||||
</ConfirmLargeRequestBody>
|
||||
),
|
||||
[activeRequest, contentType, forceUpdateKey, handleBodyChange, handleContentTypeChange],
|
||||
);
|
||||
|
||||
const renderUrlParameters = useCallback(
|
||||
(pairs: EditablePair[]) => (
|
||||
<UrlParametersEditor
|
||||
stateKey={`params.${activeRequest.id}`}
|
||||
forceUpdateKey={forceUpdateKey + urlParametersKey}
|
||||
pairs={pairs}
|
||||
onChange={(urlParameters) => patchModelDebounced(activeRequest, { urlParameters })}
|
||||
/>
|
||||
),
|
||||
[activeRequest, forceUpdateKey, urlParametersKey],
|
||||
);
|
||||
|
||||
return (
|
||||
<HttpRequestPaneView
|
||||
editable
|
||||
style={style}
|
||||
className={className}
|
||||
fullHeight={fullHeight}
|
||||
request={requestView}
|
||||
tabsRef={tabsRef}
|
||||
tabsStorageKey={TABS_STORAGE_KEY}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
urlBarKey={forceUpdateKey + urlKey}
|
||||
urlForceUpdateKey={updateKey}
|
||||
urlAutocomplete={autocomplete}
|
||||
onUrlChange={handleUrlChange}
|
||||
onUrlPasteOverwrite={handlePaste}
|
||||
onSend={handleSend}
|
||||
onCancel={cancelResponse}
|
||||
isSending={activeResponse != null && activeResponse.state !== "closed"}
|
||||
sendHotkeyAction="request.send"
|
||||
focusHotkeyAction="url_bar.focus"
|
||||
methodSlot={<RequestMethodDropdown request={activeRequest} className="ml-0.5 h-full!" />}
|
||||
bodyTab={bodyTab}
|
||||
renderBody={renderBody}
|
||||
onBodyChange={handleBodyChange}
|
||||
onBodyTextChange={handleJsonOrBodyTextChange}
|
||||
onSendJsonCommentsChange={handleSendJsonCommentsChange}
|
||||
jsonNoticeStorageKey={activeRequest.workspaceId}
|
||||
urlParameterPairs={urlParameterPairs}
|
||||
renderUrlParameters={renderUrlParameters}
|
||||
headersTabs={headersTab}
|
||||
inheritedHeaders={inheritedHeaders}
|
||||
headersForceUpdateKey={`${forceUpdateHeaderEditorKey}::${forceUpdateKey}`}
|
||||
onHeadersChange={(headers) => patchModelDebounced(activeRequest, { headers })}
|
||||
authTabs={authTab}
|
||||
authContent={<HttpAuthenticationEditor model={activeRequest} />}
|
||||
extraTabs={extraTabs}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,49 +1,36 @@
|
||||
import type { HttpResponse, HttpResponseEvent } from "@yaakapp-internal/models";
|
||||
import { Banner, HStack, Icon, LoadingIcon, VStack } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import type { ComponentType, CSSProperties } from "react";
|
||||
import { lazy, Suspense, useMemo } from "react";
|
||||
import {
|
||||
CountBadge,
|
||||
type ExtraPaneTab,
|
||||
type HostBodyViewerArgs,
|
||||
HttpResponsePaneView,
|
||||
Icon,
|
||||
type ResponseTextViewerExtras,
|
||||
type TextFilterApi,
|
||||
Tooltip,
|
||||
VStack,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import type { CSSProperties } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
|
||||
import { useCopyHttpResponse } from "../hooks/useCopyHttpResponse";
|
||||
import { useRequestBodyLoader, useResponseBodyLoader } from "../hooks/useHttpBodyLoaders";
|
||||
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
|
||||
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
|
||||
import { useResponseBodyBytes, useResponseBodyText } from "../hooks/useResponseBodyText";
|
||||
import { useRecentFilters } from "../hooks/useRecentFilters";
|
||||
import { useResponseViewMode } from "../hooks/useResponseViewMode";
|
||||
import { useSaveResponse } from "../hooks/useSaveResponse";
|
||||
import { useTimelineViewMode } from "../hooks/useTimelineViewMode";
|
||||
import { getMimeTypeFromContentType } from "../lib/contentType";
|
||||
import { getContentTypeFromHeaders, getCookieCounts } from "../lib/model_util";
|
||||
import { ConfirmLargeResponse } from "./ConfirmLargeResponse";
|
||||
import { ConfirmLargeResponseRequest } from "./ConfirmLargeResponseRequest";
|
||||
import { Button } from "./core/Button";
|
||||
import { CountBadge } from "./core/CountBadge";
|
||||
import { httpResponseToView } from "../lib/httpViews";
|
||||
import { getCookieCounts } from "../lib/model_util";
|
||||
import { CopyButton } from "./CopyButton";
|
||||
import { HotkeyList } from "./core/HotkeyList";
|
||||
import { HttpResponseDurationTag } from "./core/HttpResponseDurationTag";
|
||||
import { HttpStatusTag } from "./core/HttpStatusTag";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { PillButton } from "./core/PillButton";
|
||||
import { SizeTag } from "./core/SizeTag";
|
||||
import type { TabItem } from "./core/Tabs/Tabs";
|
||||
import { TabContent, Tabs } from "./core/Tabs/Tabs";
|
||||
import { Tooltip } from "./core/Tooltip";
|
||||
import { EmptyStateText } from "./EmptyStateText";
|
||||
import { ErrorBoundary } from "./ErrorBoundary";
|
||||
import { HttpResponseTimeline } from "./HttpResponseTimeline";
|
||||
import { RecentHttpResponsesDropdown } from "./RecentHttpResponsesDropdown";
|
||||
import { RequestBodyViewer } from "./RequestBodyViewer";
|
||||
import { ResponseCookies } from "./ResponseCookies";
|
||||
import { ResponseHeaders } from "./ResponseHeaders";
|
||||
import { AudioViewer } from "./responseViewers/AudioViewer";
|
||||
import { CsvViewer } from "./responseViewers/CsvViewer";
|
||||
import { EventStreamViewer } from "./responseViewers/EventStreamViewer";
|
||||
import { HTMLOrTextViewer } from "./responseViewers/HTMLOrTextViewer";
|
||||
import { ImageViewer } from "./responseViewers/ImageViewer";
|
||||
import { MultipartViewer } from "./responseViewers/MultipartViewer";
|
||||
import { SvgViewer } from "./responseViewers/SvgViewer";
|
||||
import { VideoViewer } from "./responseViewers/VideoViewer";
|
||||
|
||||
const PdfViewer = lazy(() =>
|
||||
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
|
||||
);
|
||||
import { RecentFiltersDropdown } from "./responseViewers/RecentFiltersDropdown";
|
||||
|
||||
interface Props {
|
||||
style?: CSSProperties;
|
||||
@@ -51,10 +38,6 @@ interface Props {
|
||||
activeRequestId: string;
|
||||
}
|
||||
|
||||
const TAB_BODY = "body";
|
||||
const TAB_REQUEST = "request";
|
||||
const TAB_HEADERS = "headers";
|
||||
const TAB_COOKIES = "cookies";
|
||||
const TAB_TIMELINE = "timeline";
|
||||
|
||||
export type TimelineViewMode = "timeline" | "text";
|
||||
@@ -64,12 +47,11 @@ interface RedirectDropWarning {
|
||||
droppedHeaders: string[];
|
||||
}
|
||||
|
||||
/** The shared response pane, bound to the pinned response of the active request */
|
||||
export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||
const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId);
|
||||
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
|
||||
const [timelineViewMode, setTimelineViewMode] = useTimelineViewMode();
|
||||
const contentType = getContentTypeFromHeaders(activeResponse?.headers ?? null);
|
||||
const mimeType = contentType == null ? null : getMimeTypeFromContentType(contentType).essence;
|
||||
|
||||
const responseEvents = useHttpResponseEvents(activeResponse);
|
||||
const redirectDropWarning = useMemo(
|
||||
@@ -82,280 +64,181 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||
const cookieCounts = useMemo(() => getCookieCounts(responseEvents.data), [responseEvents.data]);
|
||||
const saveResponse = useSaveResponse(activeResponse ?? null);
|
||||
const copyResponse = useCopyHttpResponse(activeResponse ?? null);
|
||||
const cancel = useCancelHttpResponse(activeResponse?.id ?? null);
|
||||
const body = useResponseBodyLoader(activeResponse ?? null);
|
||||
const requestBody = useRequestBodyLoader(activeResponse ?? null);
|
||||
const textViewer = useResponseTextViewerExtras(activeResponse ?? null);
|
||||
|
||||
const tabs = useMemo<TabItem[]>(
|
||||
() => [
|
||||
{
|
||||
value: TAB_BODY,
|
||||
label: "Response",
|
||||
options: {
|
||||
value: viewMode,
|
||||
onChange: setViewMode,
|
||||
items: [
|
||||
{ label: "Response", value: "pretty" },
|
||||
...(mimeType?.startsWith("image")
|
||||
? []
|
||||
: [{ label: "Response (Raw)", shortLabel: "Raw", value: "raw" }]),
|
||||
],
|
||||
itemsAfter: [
|
||||
{
|
||||
label: "Save to File",
|
||||
onSelect: saveResponse.mutate,
|
||||
leftSlot: <Icon icon="save" />,
|
||||
hidden: activeResponse == null || !!activeResponse.error,
|
||||
disabled: activeResponse?.state !== "closed" && (activeResponse?.status ?? 0) >= 100,
|
||||
},
|
||||
{
|
||||
label: "Copy Body",
|
||||
onSelect: copyResponse.mutate,
|
||||
leftSlot: <Icon icon="copy" />,
|
||||
hidden: activeResponse == null || !!activeResponse.error,
|
||||
disabled: activeResponse?.state !== "closed" && (activeResponse?.status ?? 0) >= 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
value: TAB_REQUEST,
|
||||
label: "Request",
|
||||
rightSlot:
|
||||
(activeResponse?.requestContentLength ?? 0) > 0 ? <CountBadge count={true} /> : null,
|
||||
},
|
||||
{
|
||||
value: TAB_HEADERS,
|
||||
label: "Headers",
|
||||
rightSlot: (
|
||||
<CountBadge
|
||||
count={activeResponse?.requestHeaders.length ?? 0}
|
||||
count2={activeResponse?.headers.length ?? 0}
|
||||
showZero
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: TAB_COOKIES,
|
||||
label: "Cookies",
|
||||
rightSlot:
|
||||
cookieCounts.sent > 0 || cookieCounts.received > 0 ? (
|
||||
<CountBadge count={cookieCounts.sent} count2={cookieCounts.received} showZero />
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
value: TAB_TIMELINE,
|
||||
rightSlot: <CountBadge count={responseEvents.data?.length ?? 0} />,
|
||||
options: {
|
||||
value: timelineViewMode,
|
||||
onChange: (v) => setTimelineViewMode((v as TimelineViewMode) ?? "timeline"),
|
||||
items: [
|
||||
{ label: "Timeline", value: "timeline" },
|
||||
{ label: "Timeline (Text)", shortLabel: "Timeline", value: "text" },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
activeResponse?.headers,
|
||||
activeResponse,
|
||||
activeResponse?.error,
|
||||
activeResponse?.requestContentLength,
|
||||
activeResponse?.requestHeaders.length,
|
||||
activeResponse?.state,
|
||||
activeResponse?.status,
|
||||
cookieCounts.sent,
|
||||
cookieCounts.received,
|
||||
copyResponse.mutate,
|
||||
mimeType,
|
||||
responseEvents.data?.length,
|
||||
saveResponse.mutate,
|
||||
setViewMode,
|
||||
viewMode,
|
||||
timelineViewMode,
|
||||
setTimelineViewMode,
|
||||
],
|
||||
const responseView = useMemo(
|
||||
() => (activeResponse == null ? null : httpResponseToView(activeResponse, responseEvents.data)),
|
||||
[activeResponse, responseEvents.data],
|
||||
);
|
||||
|
||||
const cancel = useCancelHttpResponse(activeResponse?.id ?? null);
|
||||
const extraTabs = useMemo<ExtraPaneTab[]>(
|
||||
() =>
|
||||
activeResponse == null
|
||||
? []
|
||||
: [
|
||||
{
|
||||
tab: {
|
||||
value: TAB_TIMELINE,
|
||||
rightSlot: <CountBadge count={responseEvents.data?.length ?? 0} />,
|
||||
options: {
|
||||
value: timelineViewMode,
|
||||
onChange: (v) => setTimelineViewMode((v as TimelineViewMode) ?? "timeline"),
|
||||
items: [
|
||||
{ label: "Timeline", value: "timeline" },
|
||||
{ label: "Timeline (Text)", shortLabel: "Timeline", value: "text" },
|
||||
],
|
||||
},
|
||||
},
|
||||
content: (
|
||||
<HttpResponseTimeline response={activeResponse} viewMode={timelineViewMode} />
|
||||
),
|
||||
},
|
||||
],
|
||||
[activeResponse, responseEvents.data?.length, setTimelineViewMode, timelineViewMode],
|
||||
);
|
||||
|
||||
const renderBodyViewer = useCallback(
|
||||
({ mimeType, viewMode }: HostBodyViewerArgs) =>
|
||||
activeResponse != null && mimeType?.match(/^text\/event-stream/i) && viewMode === "pretty" ? (
|
||||
<EventStreamViewer response={activeResponse} />
|
||||
) : null,
|
||||
[activeResponse],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
<HttpResponsePaneView
|
||||
style={style}
|
||||
className={classNames(
|
||||
className,
|
||||
"x-theme-responsePane",
|
||||
"max-h-full h-full",
|
||||
"bg-surface rounded-md border border-border-subtle overflow-hidden",
|
||||
"relative",
|
||||
)}
|
||||
>
|
||||
{activeResponse == null ? (
|
||||
className={className}
|
||||
response={responseView}
|
||||
body={body}
|
||||
requestBody={requestBody}
|
||||
emptyState={
|
||||
<HotkeyList hotkeys={["request.send", "model.create", "sidebar.focus", "url_bar.focus"]} />
|
||||
) : (
|
||||
<div className="h-full w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1">
|
||||
<HStack
|
||||
className={classNames(
|
||||
"text-text-subtle w-full shrink-0",
|
||||
// Remove a bit of space because the tabs have lots too
|
||||
"-mb-1.5",
|
||||
)}
|
||||
>
|
||||
{activeResponse && (
|
||||
<div
|
||||
className={classNames(
|
||||
"grid grid-cols-[auto_minmax(4rem,1fr)_auto]",
|
||||
"cursor-default select-none",
|
||||
"whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars",
|
||||
}
|
||||
viewMode={viewMode === "raw" ? "raw" : "pretty"}
|
||||
onViewModeChange={setViewMode}
|
||||
activeTabKey={activeRequestId}
|
||||
onSaveBody={saveResponse.mutate}
|
||||
onCopyBody={copyResponse.mutate}
|
||||
onCancel={cancel.mutate}
|
||||
cookieCounts={cookieCounts}
|
||||
extraTabs={extraTabs}
|
||||
textViewer={textViewer}
|
||||
renderBodyViewer={renderBodyViewer}
|
||||
renderCopyButton={renderCopyButton}
|
||||
statusLineCenter={
|
||||
shouldShowRedirectDropWarning ? (
|
||||
<Tooltip
|
||||
tabIndex={0}
|
||||
className="my-auto pl-3 shrink-0 max-w-full justify-self-end overflow-hidden"
|
||||
content={
|
||||
<VStack alignItems="start" space={1} className="text-xs">
|
||||
<span className="font-medium text-warning">Redirect changed this request</span>
|
||||
{redirectDropWarning.droppedBodyCount > 0 && (
|
||||
<span>
|
||||
Body dropped on {redirectDropWarning.droppedBodyCount}{" "}
|
||||
{redirectDropWarning.droppedBodyCount === 1 ? "redirect hop" : "redirect hops"}
|
||||
</span>
|
||||
)}
|
||||
>
|
||||
<HStack space={2} className="w-full shrink-0">
|
||||
{activeResponse.state !== "closed" && <LoadingIcon size="sm" />}
|
||||
<HttpStatusTag showReason response={activeResponse} />
|
||||
<span>•</span>
|
||||
<HttpResponseDurationTag response={activeResponse} />
|
||||
<span>•</span>
|
||||
<SizeTag
|
||||
contentLength={activeResponse.contentLength ?? 0}
|
||||
contentLengthCompressed={activeResponse.contentLengthCompressed}
|
||||
/>
|
||||
</HStack>
|
||||
{shouldShowRedirectDropWarning ? (
|
||||
<Tooltip
|
||||
tabIndex={0}
|
||||
className="my-auto pl-3 shrink-0 max-w-full justify-self-end overflow-hidden"
|
||||
content={
|
||||
<VStack alignItems="start" space={1} className="text-xs">
|
||||
<span className="font-medium text-warning">
|
||||
Redirect changed this request
|
||||
</span>
|
||||
{redirectDropWarning.droppedBodyCount > 0 && (
|
||||
<span>
|
||||
Body dropped on {redirectDropWarning.droppedBodyCount}{" "}
|
||||
{redirectDropWarning.droppedBodyCount === 1
|
||||
? "redirect hop"
|
||||
: "redirect hops"}
|
||||
</span>
|
||||
)}
|
||||
{redirectDropWarning.droppedHeaders.length > 0 && (
|
||||
<span>
|
||||
Headers dropped:{" "}
|
||||
<span className="font-mono">
|
||||
{redirectDropWarning.droppedHeaders.join(", ")}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="text-text-subtle">See Timeline for details.</span>
|
||||
</VStack>
|
||||
}
|
||||
>
|
||||
<span className="inline-flex min-w-0">
|
||||
<PillButton
|
||||
color="warning"
|
||||
className="font-sans text-sm shrink! max-w-full"
|
||||
innerClassName="flex items-center"
|
||||
leftSlot={<Icon icon="alert_triangle" size="xs" color="warning" />}
|
||||
>
|
||||
<span className="truncate">
|
||||
{getRedirectWarningLabel(redirectDropWarning)}
|
||||
</span>
|
||||
</PillButton>
|
||||
{redirectDropWarning.droppedHeaders.length > 0 && (
|
||||
<span>
|
||||
Headers dropped:{" "}
|
||||
<span className="font-mono">
|
||||
{redirectDropWarning.droppedHeaders.join(", ")}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span />
|
||||
</span>
|
||||
)}
|
||||
<div className="justify-self-end shrink-0">
|
||||
<RecentHttpResponsesDropdown
|
||||
responses={responses}
|
||||
activeResponse={activeResponse}
|
||||
onPinnedResponseId={setPinnedResponseId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
<div className="overflow-hidden flex flex-col min-h-0">
|
||||
{activeResponse?.error && (
|
||||
<Banner color="danger" className="mx-3 mt-1 shrink-0">
|
||||
{activeResponse.error}
|
||||
</Banner>
|
||||
)}
|
||||
{/* Show tabs if we have any data (headers, body, etc.) even if there's an error */}
|
||||
<Tabs
|
||||
tabs={tabs}
|
||||
label="Response"
|
||||
className="ml-3 mr-3 mb-3 min-h-0 flex-1"
|
||||
tabListClassName="mt-0.5 -mb-1.5"
|
||||
storageKey="http_response_tabs"
|
||||
activeTabKey={activeRequestId}
|
||||
>
|
||||
<TabContent value={TAB_BODY}>
|
||||
<ErrorBoundary name="Http Response Viewer">
|
||||
<Suspense>
|
||||
<ConfirmLargeResponse response={activeResponse}>
|
||||
{activeResponse.state === "initialized" ? (
|
||||
<EmptyStateText>
|
||||
<VStack space={3}>
|
||||
<HStack space={3}>
|
||||
<LoadingIcon className="text-text-subtlest" />
|
||||
Sending Request
|
||||
</HStack>
|
||||
<Button size="sm" variant="border" onClick={() => cancel.mutate()}>
|
||||
Cancel
|
||||
</Button>
|
||||
</VStack>
|
||||
</EmptyStateText>
|
||||
) : activeResponse.state === "closed" &&
|
||||
(activeResponse.contentLength ?? 0) === 0 ? (
|
||||
<EmptyStateText>Empty</EmptyStateText>
|
||||
) : mimeType?.match(/^text\/event-stream/i) && viewMode === "pretty" ? (
|
||||
<EventStreamViewer response={activeResponse} />
|
||||
) : mimeType?.match(/^image\/svg/) ? (
|
||||
<HttpSvgViewer response={activeResponse} />
|
||||
) : mimeType?.match(/^image/i) ? (
|
||||
<EnsureCompleteResponse response={activeResponse} Component={ImageViewer} />
|
||||
) : mimeType?.match(/^audio/i) ? (
|
||||
<EnsureCompleteResponse response={activeResponse} Component={AudioViewer} />
|
||||
) : mimeType?.match(/^video/i) ? (
|
||||
<EnsureCompleteResponse response={activeResponse} Component={VideoViewer} />
|
||||
) : mimeType?.match(/^multipart/i) && viewMode === "pretty" ? (
|
||||
<HttpMultipartViewer response={activeResponse} />
|
||||
) : mimeType?.match(/pdf/i) ? (
|
||||
<EnsureCompleteResponse response={activeResponse} Component={PdfViewer} />
|
||||
) : mimeType?.match(/csv|tab-separated/i) && viewMode === "pretty" ? (
|
||||
<HttpCsvViewer className="pb-2" response={activeResponse} />
|
||||
) : (
|
||||
<HTMLOrTextViewer
|
||||
textViewerClassName="-mr-2 bg-surface" // Pull to the right
|
||||
response={activeResponse}
|
||||
pretty={viewMode === "pretty"}
|
||||
/>
|
||||
)}
|
||||
</ConfirmLargeResponse>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_REQUEST}>
|
||||
<ConfirmLargeResponseRequest response={activeResponse}>
|
||||
<RequestBodyViewer response={activeResponse} />
|
||||
</ConfirmLargeResponseRequest>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_HEADERS}>
|
||||
<ResponseHeaders response={activeResponse} />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_COOKIES}>
|
||||
<ResponseCookies response={activeResponse} />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_TIMELINE}>
|
||||
<HttpResponseTimeline response={activeResponse} viewMode={timelineViewMode} />
|
||||
</TabContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-text-subtle">See Timeline for details.</span>
|
||||
</VStack>
|
||||
}
|
||||
>
|
||||
<span className="inline-flex min-w-0">
|
||||
<PillButton
|
||||
color="warning"
|
||||
className="font-sans text-sm shrink! max-w-full"
|
||||
innerClassName="flex items-center"
|
||||
leftSlot={<Icon icon="alert_triangle" size="xs" color="warning" />}
|
||||
>
|
||||
<span className="truncate">{getRedirectWarningLabel(redirectDropWarning)}</span>
|
||||
</PillButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null
|
||||
}
|
||||
statusLineRight={
|
||||
activeResponse != null && (
|
||||
<RecentHttpResponsesDropdown
|
||||
responses={responses}
|
||||
activeResponse={activeResponse}
|
||||
onPinnedResponseId={setPinnedResponseId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** The client's additions to the response text viewer: filter history, save and copy */
|
||||
function useResponseTextViewerExtras(
|
||||
response: HttpResponse | null,
|
||||
): ResponseTextViewerExtras | undefined {
|
||||
const filterStateKey = `response.body.${response?.requestId ?? ""}`;
|
||||
const { recentFilters, addFilter, removeFilter, togglePin, clearFilters } =
|
||||
useRecentFilters(filterStateKey);
|
||||
const saveResponse = useSaveResponse(response);
|
||||
const copyResponse = useCopyHttpResponse(response);
|
||||
const filterLeftSlot = useCallback(
|
||||
(filter: TextFilterApi) => (
|
||||
<div className="py-0.5 flex">
|
||||
<RecentFiltersDropdown
|
||||
recentFilters={recentFilters}
|
||||
activeFilter={filter.appliedFilter}
|
||||
onSelect={filter.replaceFilter}
|
||||
onRemove={removeFilter}
|
||||
onTogglePin={togglePin}
|
||||
onClear={clearFilters}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
[recentFilters, removeFilter, togglePin, clearFilters],
|
||||
);
|
||||
|
||||
if (response == null) return undefined;
|
||||
const actionsDisabled = response.state !== "closed" && response.status >= 100;
|
||||
return {
|
||||
filterStateKey,
|
||||
onFilterApplied: addFilter,
|
||||
filterLeftSlot,
|
||||
footerActions: [
|
||||
<IconButton
|
||||
key="save"
|
||||
size="sm"
|
||||
icon="save"
|
||||
title="Save response to file"
|
||||
disabled={actionsDisabled}
|
||||
onClick={() => saveResponse.mutate()}
|
||||
className="border !border-border-subtle"
|
||||
/>,
|
||||
<IconButton
|
||||
key="copy"
|
||||
size="sm"
|
||||
icon="copy"
|
||||
title="Copy response body"
|
||||
disabled={actionsDisabled}
|
||||
onClick={() => copyResponse.mutate()}
|
||||
className="border !border-border-subtle"
|
||||
/>,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function renderCopyButton(getText: () => Promise<string | null>) {
|
||||
return <CopyButton color="secondary" variant="border" size="xs" text={getText} />;
|
||||
}
|
||||
|
||||
function getRedirectDropWarning(
|
||||
events: HttpResponseEvent[] | undefined,
|
||||
): RedirectDropWarning | null {
|
||||
@@ -403,51 +286,3 @@ function getRedirectWarningLabel(warning: RedirectDropWarning): string {
|
||||
}
|
||||
return "Dropped headers";
|
||||
}
|
||||
|
||||
function EnsureCompleteResponse({
|
||||
response,
|
||||
Component,
|
||||
}: {
|
||||
response: HttpResponse;
|
||||
Component: ComponentType<{ bodyPath: string }>;
|
||||
}) {
|
||||
if (response.bodyPath === null) {
|
||||
return <div>Empty response body</div>;
|
||||
}
|
||||
|
||||
// Wait until the response has been fully-downloaded
|
||||
if (response.state !== "closed") {
|
||||
return (
|
||||
<EmptyStateText>
|
||||
<LoadingIcon />
|
||||
</EmptyStateText>
|
||||
);
|
||||
}
|
||||
|
||||
return <Component bodyPath={response.bodyPath} />;
|
||||
}
|
||||
|
||||
function HttpSvgViewer({ response }: { response: HttpResponse }) {
|
||||
const body = useResponseBodyText({ response, filter: null });
|
||||
|
||||
if (!body.data) return null;
|
||||
|
||||
return <SvgViewer text={body.data} />;
|
||||
}
|
||||
|
||||
function HttpCsvViewer({ response, className }: { response: HttpResponse; className?: string }) {
|
||||
const body = useResponseBodyText({ response, filter: null });
|
||||
|
||||
return <CsvViewer text={body.data ?? null} className={className} />;
|
||||
}
|
||||
|
||||
function HttpMultipartViewer({ response }: { response: HttpResponse }) {
|
||||
const body = useResponseBodyBytes({ response });
|
||||
|
||||
if (body.data == null) return null;
|
||||
|
||||
const contentTypeHeader = getContentTypeFromHeaders(response.headers);
|
||||
const boundary = contentTypeHeader?.split("boundary=")[1] ?? "unknown";
|
||||
|
||||
return <MultipartViewer data={body.data} boundary={boundary} idPrefix={response.id} />;
|
||||
}
|
||||
@@ -13,9 +13,8 @@ import { resolvedModelName } from "../lib/resolvedModelName";
|
||||
import { Editor } from "./core/Editor/LazyEditor";
|
||||
import { type EventDetailAction, EventDetailHeader, EventViewer } from "./core/EventViewer";
|
||||
import { EventViewerRow } from "./core/EventViewerRow";
|
||||
import { HttpStatusTagRaw } from "./core/HttpStatusTag";
|
||||
import { Icon, type IconProps } from "@yaakapp-internal/ui";
|
||||
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
|
||||
import { Icon, type IconProps, HttpStatusTagRaw, KeyValueRows } from "@yaakapp-internal/ui";
|
||||
import { KeyValueRow } from "./core/KeyValueRow";
|
||||
import type { TimelineViewMode } from "./HttpResponsePane";
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { platform, useCapability } from "@yaakapp-internal/platform";
|
||||
import { Icon } from "@yaakapp-internal/ui";
|
||||
import * as m from "motion/react-m";
|
||||
import { useEffect, useState } from "react";
|
||||
import { importCurl, looksLikeCurl } from "../commands/importCurl";
|
||||
import { useWindowFocus } from "../hooks/useWindowFocus";
|
||||
import { showToast } from "../lib/toast";
|
||||
import { Button } from "./core/Button";
|
||||
|
||||
/**
|
||||
* Offers to create a request from a Curl command on the clipboard. A host that can read
|
||||
* the clipboard on its own offers it whenever the window is focused; one that would have
|
||||
* to prompt for permission first waits for the user to paste instead.
|
||||
*/
|
||||
export function ImportCurl() {
|
||||
return useCapability("clipboardRead") ? <ImportCurlButton /> : <ImportCurlOnPaste />;
|
||||
}
|
||||
|
||||
function ImportCurlButton() {
|
||||
const focused = useWindowFocus();
|
||||
const [clipboardText, setClipboardText] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// oxlint-disable-next-line react-hooks/exhaustive-deps -- none
|
||||
useEffect(() => {
|
||||
void platform.clipboard.readText().then(setClipboardText);
|
||||
}, [focused]);
|
||||
|
||||
if (!looksLikeCurl(clipboardText)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<m.div
|
||||
initial={{ opacity: 0, scale: 0 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
>
|
||||
<Button
|
||||
size="2xs"
|
||||
variant="border"
|
||||
color="success"
|
||||
className="rounded-full"
|
||||
rightSlot={<Icon icon="import" size="sm" />}
|
||||
isLoading={isLoading}
|
||||
title="Import Curl command from clipboard"
|
||||
onClick={async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await importCurl.mutateAsync({ command: clipboardText });
|
||||
setClipboardText(""); // Hide the button until the clipboard changes
|
||||
} catch (e) {
|
||||
console.log("Failed to import curl", e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Import Curl
|
||||
</Button>
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImportCurlOnPaste() {
|
||||
useEffect(() => {
|
||||
const handlePaste = (e: ClipboardEvent) => {
|
||||
const command = e.clipboardData?.getData("text/plain") ?? "";
|
||||
if (!looksLikeCurl(command)) return;
|
||||
|
||||
// Editable targets keep the text as text. The URL editor imports it itself.
|
||||
if (isEditable(e.target)) return;
|
||||
|
||||
showToast({
|
||||
id: "curl-paste",
|
||||
color: "success",
|
||||
message: (
|
||||
<div>
|
||||
<h2 className="font-semibold">Curl command detected</h2>
|
||||
<p className="text-text-subtle text-sm">Create a request from the pasted command?</p>
|
||||
</div>
|
||||
),
|
||||
action: ({ hide }) => (
|
||||
<Button
|
||||
size="xs"
|
||||
color="success"
|
||||
className="mr-auto min-w-20"
|
||||
rightSlot={<Icon icon="import" size="sm" />}
|
||||
onClick={() => {
|
||||
hide();
|
||||
importCurl.mutate({ command });
|
||||
}}
|
||||
>
|
||||
Create Request
|
||||
</Button>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener("paste", handlePaste);
|
||||
return () => document.removeEventListener("paste", handlePaste);
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function isEditable(target: EventTarget | null) {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
if (target.isContentEditable) return true;
|
||||
if (target.closest(".cm-editor") != null) return true;
|
||||
return ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { clear, readText } from "@tauri-apps/plugin-clipboard-manager";
|
||||
import * as m from "motion/react-m";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useImportCurl } from "../hooks/useImportCurl";
|
||||
import { useWindowFocus } from "../hooks/useWindowFocus";
|
||||
import { Button } from "./core/Button";
|
||||
import { Icon } from "@yaakapp-internal/ui";
|
||||
|
||||
export function ImportCurlButton() {
|
||||
const focused = useWindowFocus();
|
||||
const [clipboardText, setClipboardText] = useState("");
|
||||
|
||||
const importCurl = useImportCurl();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// oxlint-disable-next-line react-hooks/exhaustive-deps -- none
|
||||
useEffect(() => {
|
||||
void readText().then(setClipboardText);
|
||||
}, [focused]);
|
||||
|
||||
if (!clipboardText?.trim().startsWith("curl ")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<m.div
|
||||
initial={{ opacity: 0, scale: 0 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
>
|
||||
<Button
|
||||
size="2xs"
|
||||
variant="border"
|
||||
color="success"
|
||||
className="rounded-full"
|
||||
rightSlot={<Icon icon="import" size="sm" />}
|
||||
isLoading={isLoading}
|
||||
title="Import Curl command from clipboard"
|
||||
onClick={async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await importCurl.mutateAsync({ command: clipboardText });
|
||||
await clear(); // Clear the clipboard so the button goes away
|
||||
setClipboardText("");
|
||||
} catch (e) {
|
||||
console.log("Failed to import curl", e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Import Curl
|
||||
</Button>
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +1,769 @@
|
||||
import { VStack } from "@yaakapp-internal/ui";
|
||||
import { useState } from "react";
|
||||
import { useLocalStorage } from "react-use";
|
||||
import {
|
||||
type ImportDestination,
|
||||
type ImportPlan,
|
||||
type ImportPlanItem,
|
||||
type ImportSource,
|
||||
type Workspace,
|
||||
} from "@yaakapp-internal/models";
|
||||
import {
|
||||
Banner,
|
||||
FormattedError,
|
||||
HStack,
|
||||
Icon,
|
||||
VStack,
|
||||
IconTooltip,
|
||||
SegmentedControl,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { type ComponentProps, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { errorMessage } from "../lib/errorMessage";
|
||||
import { CommercialUseBanner } from "./CommercialUseBanner";
|
||||
import { Button } from "./core/Button";
|
||||
import { SelectFile } from "./SelectFile";
|
||||
import {
|
||||
ImportSourceList,
|
||||
type ImportSourceDetection,
|
||||
type ImportSourcePath,
|
||||
InlineButton,
|
||||
toSourcePath,
|
||||
} from "./ImportSourceList";
|
||||
import type { CheckboxTreeNode } from "./core/CheckboxTree";
|
||||
import { CheckboxTree } from "./core/CheckboxTree";
|
||||
import { Chip } from "./core/Chip";
|
||||
import { DialogFooter } from "./core/Dialog";
|
||||
import { Select } from "./core/Select";
|
||||
import { pluralize } from "@yaakapp-internal/lib/pluralize";
|
||||
|
||||
interface Props {
|
||||
importData: (filePath: string) => Promise<void>;
|
||||
currentWorkspace: Workspace | null;
|
||||
workspaces: Workspace[];
|
||||
planSources: (sources: ImportSourcePath[], destination: ImportDestination) => Promise<ImportPlan>;
|
||||
detectSource: (source: ImportSourcePath) => Promise<string>;
|
||||
listSources: (workspaceId: string) => Promise<ImportSource[]>;
|
||||
findSourcesForOrigin: (args: { filePath?: string; url?: string }) => Promise<ImportSource[]>;
|
||||
commit: (plan: ImportPlan) => Promise<void>;
|
||||
cancel: () => void;
|
||||
onError: (err: unknown) => void;
|
||||
}
|
||||
|
||||
export function ImportDataDialog({ importData }: Props) {
|
||||
/**
|
||||
* 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 LoadedImportDataDialog({
|
||||
currentWorkspace,
|
||||
workspaces,
|
||||
planSources,
|
||||
detectSource,
|
||||
findSourcesForOrigin,
|
||||
commit,
|
||||
cancel,
|
||||
onError,
|
||||
initialSources,
|
||||
}: Props & { initialSources: ImportSource[] }) {
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [filePath, setFilePath] = useLocalStorage<string | null>("importFilePath", null);
|
||||
const [sourceError, setSourceError] = useState<unknown>(null);
|
||||
const [plan, setPlan] = useState<ImportPlan | null>(null);
|
||||
const [items, setItems] = useState<ImportPlanItem[]>([]);
|
||||
// null means no explicit choice yet, so the default below applies
|
||||
const [destinationChoice, setDestinationChoice] = useState<"new" | "current" | "other" | null>(
|
||||
null,
|
||||
);
|
||||
const [otherWorkspaceId, setOtherWorkspaceId] = useState<string | null>(null);
|
||||
const [originSources, setOriginSources] = useState<ImportSource[]>(initialSources);
|
||||
const initialPaths = useMemo(
|
||||
() => [...new Set(initialSources.map((source) => source.origin))].map(toSourcePath),
|
||||
[initialSources],
|
||||
);
|
||||
const [sources, setSources] = useState<ImportSourcePath[]>(initialPaths);
|
||||
const knownPaths = useRef(new Set(initialPaths.map((source) => source.path)));
|
||||
const [detections, setDetections] = useState<Record<string, ImportSourceDetection>>({});
|
||||
const detect = useCallback(
|
||||
(added: ImportSourcePath[]) => {
|
||||
setDetections((prev) => ({
|
||||
...prev,
|
||||
...Object.fromEntries(added.map((s) => [s.path, { status: "loading" as const }])),
|
||||
}));
|
||||
for (const source of added) {
|
||||
const settle = (detection: ImportSourceDetection) =>
|
||||
setDetections((prev) =>
|
||||
source.path in prev ? { ...prev, [source.path]: detection } : prev,
|
||||
);
|
||||
detectSource(source).then(
|
||||
(importer) => settle({ status: "ok", importer }),
|
||||
(err) => settle({ status: "error", message: errorMessage(err) }),
|
||||
);
|
||||
}
|
||||
},
|
||||
[detectSource],
|
||||
);
|
||||
const allDetected = sources.every((source) => detections[source.path]?.status === "ok");
|
||||
useEffect(() => {
|
||||
detect(initialPaths);
|
||||
}, [detect, initialPaths]);
|
||||
|
||||
const addSources = useCallback(
|
||||
(added: ImportSourcePath[]) => {
|
||||
setSourceError(null);
|
||||
const fresh = added.filter(({ path }) => {
|
||||
if (knownPaths.current.has(path)) return false;
|
||||
knownPaths.current.add(path);
|
||||
return true;
|
||||
});
|
||||
if (fresh.length === 0) return;
|
||||
setSources((existing) => [...existing, ...fresh]);
|
||||
detect(fresh);
|
||||
},
|
||||
[detect],
|
||||
);
|
||||
const removeSource = (path: string) => {
|
||||
setSourceError(null);
|
||||
knownPaths.current.delete(path);
|
||||
setSources((current) => current.filter((source) => source.path !== path));
|
||||
setDetections(({ [path]: _removed, ...rest }) => rest);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (sources.length === 0) {
|
||||
setOriginSources([]);
|
||||
return;
|
||||
}
|
||||
Promise.all(
|
||||
sources.map(({ path, kind }) =>
|
||||
findSourcesForOrigin(kind === "url" ? { url: path } : { filePath: path }),
|
||||
),
|
||||
)
|
||||
.then((found) => {
|
||||
if (!cancelled) setOriginSources(found.flat());
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setOriginSources([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [sources, findSourcesForOrigin]);
|
||||
|
||||
// The one workspace these sources are linked to, if there is exactly one.
|
||||
const linkedWorkspace = useMemo(() => {
|
||||
const ids = [...new Set(originSources.map((s) => s.workspaceId))];
|
||||
if (ids.length !== 1) return null;
|
||||
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;
|
||||
|
||||
const destination = (): ImportDestination => {
|
||||
if (destinationWorkspaceId == null) {
|
||||
return { type: "new_workspace" };
|
||||
}
|
||||
return {
|
||||
type: "existing_workspace",
|
||||
workspaceId: destinationWorkspaceId,
|
||||
};
|
||||
};
|
||||
|
||||
const handlePreview = async () => {
|
||||
setIsLoading(true);
|
||||
setSourceError(null);
|
||||
try {
|
||||
const nextPlan = await planSources(sources, destination());
|
||||
setPlan(nextPlan);
|
||||
setItems(nextPlan.items);
|
||||
} catch (err) {
|
||||
setSourceError(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);
|
||||
}
|
||||
};
|
||||
|
||||
// A folder row's checkbox carries everything beneath it, deletions included — the row labels
|
||||
// say which of those are destructive. Checking anything also brings back the folders it needs
|
||||
// to live in.
|
||||
const toggleNode = (node: CheckboxTreeNode<TreeRow>, checked: boolean) => {
|
||||
const targets = new Set(togglableItems(node).map((i) => i.modelId));
|
||||
if (checked && node.data.kind === "item") {
|
||||
const byId = new Map(items.map((i) => [i.modelId, i]));
|
||||
for (const ancestor of ancestorsOf(node.data.item, byId)) {
|
||||
if (isMissingFolder(ancestor)) 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)),
|
||||
);
|
||||
};
|
||||
|
||||
// Deleting a folder takes its contents with it, so those rows have nothing left to decide.
|
||||
const disabledIds = useMemo(() => {
|
||||
const disabled = new Set<string>();
|
||||
const byId = new Map(items.map((i) => [i.modelId, i]));
|
||||
for (const item of items) {
|
||||
if (item.action !== "delete") continue;
|
||||
for (const parent of ancestorsOf(item, byId)) {
|
||||
if (parent.action === "delete" && parent.selected) {
|
||||
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;
|
||||
|
||||
// The row's label carries what kind of destination it is, so the value can just be its name
|
||||
const [destinationLabel, destinationValue] = ((): [string, string] => {
|
||||
if (plan.destination.type === "new_workspace") {
|
||||
const names = plan.resources.workspaces.map((w) => w.name).filter((n) => n !== "");
|
||||
if (names.length === 0) return ["New workspace", "Untitled"];
|
||||
return [
|
||||
names.length === 1 ? "New workspace" : `${names.length} new workspaces`,
|
||||
names.join(", "),
|
||||
];
|
||||
}
|
||||
const { workspaceId } = plan.destination;
|
||||
const name = workspaces.find((w) => w.id === workspaceId)?.name ?? "Unknown workspace";
|
||||
return ["Destination", name];
|
||||
})();
|
||||
|
||||
// The destination workspace roots the tree. It is not a plan item — commit always applies
|
||||
// it — so its checkbox only aggregates the subtree.
|
||||
const workspaceRoots: CheckboxTreeNode<TreeRow>[] = (() => {
|
||||
const planDestination = plan.destination;
|
||||
const existing =
|
||||
planDestination.type === "existing_workspace"
|
||||
? workspaces.find((w) => w.id === planDestination.workspaceId)
|
||||
: null;
|
||||
const destinations = existing ? [existing] : plan.resources.workspaces;
|
||||
if (destinations.length === 1) {
|
||||
const workspace = destinations[0]!;
|
||||
return [
|
||||
{
|
||||
key: workspace.id,
|
||||
data: {
|
||||
kind: "destination",
|
||||
label: workspace.name,
|
||||
isNew: planDestination.type === "new_workspace",
|
||||
},
|
||||
children: buildItemTree(items),
|
||||
},
|
||||
];
|
||||
}
|
||||
const workspaceById = new Map(
|
||||
[
|
||||
...plan.resources.environments,
|
||||
...plan.resources.folders,
|
||||
...plan.resources.httpRequests,
|
||||
...plan.resources.grpcRequests,
|
||||
...plan.resources.websocketRequests,
|
||||
].map((resource) => [resource.id, resource.workspaceId]),
|
||||
);
|
||||
return destinations.map((workspace) => ({
|
||||
key: workspace.id,
|
||||
data: {
|
||||
kind: "destination",
|
||||
label: workspace.name,
|
||||
isNew: planDestination.type === "new_workspace",
|
||||
},
|
||||
children: buildItemTree(
|
||||
items.filter((item) => workspaceById.get(item.modelId) === workspace.id),
|
||||
),
|
||||
}));
|
||||
})();
|
||||
|
||||
return (
|
||||
<>
|
||||
<VStack space={4} className="pb-4">
|
||||
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
|
||||
<PreviewRow label="Detected format" value={plan.importer} />
|
||||
<PreviewRow label={destinationLabel} value={destinationValue} />
|
||||
</div>
|
||||
|
||||
{plan.warnings
|
||||
.filter(
|
||||
(warning, index, warnings) =>
|
||||
warnings.findIndex(
|
||||
(other) =>
|
||||
other.title === warning.title &&
|
||||
other.detail === warning.detail &&
|
||||
other.level === warning.level,
|
||||
) === index,
|
||||
)
|
||||
.map((warning) => (
|
||||
<Banner
|
||||
key={`${warning.level}:${warning.title}:${warning.detail}`}
|
||||
color={warning.level === "warning" ? "warning" : "info"}
|
||||
className="flex items-start gap-2.5"
|
||||
>
|
||||
<Icon
|
||||
icon={warning.level === "warning" ? "alert_triangle" : "info"}
|
||||
size="sm"
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">{warning.title}</div>
|
||||
<div className="text-xs text-text-subtle mt-0.5">{warning.detail}</div>
|
||||
</div>
|
||||
</Banner>
|
||||
))}
|
||||
|
||||
{workspaceRoots.map((root) => (
|
||||
<div key={root.key} className="rounded-lg border border-border-subtle px-3 py-2">
|
||||
<CheckboxTree
|
||||
node={root}
|
||||
checked={nodeCheckedStatus}
|
||||
onCheck={toggleNode}
|
||||
isCheckboxDisabled={(n) => disabledIds.has(n.key)}
|
||||
isCollapsedByDefault={(n) =>
|
||||
(n.data.kind === "destination" && workspaceRoots.length > 1) ||
|
||||
(n.data.kind === "item" && n.data.item.action === "ignored")
|
||||
}
|
||||
isRelevant={(n) =>
|
||||
n.data.kind === "destination" ||
|
||||
(n.data.kind === "item" && n.data.item.action !== "unchanged")
|
||||
}
|
||||
renderRow={(n) => (
|
||||
<ImportTreeRow row={n.data} onResolveConflict={resolveConflict} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</VStack>
|
||||
<DialogFooter
|
||||
leftSlot={
|
||||
footerNote !== "" ? (
|
||||
<div className="text-xs text-text-subtle">{footerNote}</div>
|
||||
) : undefined
|
||||
}
|
||||
actions={[
|
||||
{
|
||||
label: "Back",
|
||||
disabled: isLoading,
|
||||
onClick: () => {
|
||||
setPlan(null);
|
||||
setItems([]);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: isLoading
|
||||
? "Importing"
|
||||
: changeCount > 0
|
||||
? `Apply ${changeCount} ${changeCount === 1 ? "Change" : "Changes"}`
|
||||
: "Done",
|
||||
color: "primary",
|
||||
isLoading,
|
||||
onClick: handleCommit,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const destinationPicker = (
|
||||
<HStack
|
||||
space={2}
|
||||
alignItems="center"
|
||||
className={classNames(workspaces.length === 0 && "hidden")}
|
||||
>
|
||||
<Select
|
||||
name="import-destination-kind"
|
||||
label="Import into"
|
||||
labelPosition="left"
|
||||
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 })),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
);
|
||||
|
||||
return (
|
||||
<VStack space={5} className="pb-4">
|
||||
<CommercialUseBanner source="data-import" title="Importing work data?" />
|
||||
<>
|
||||
<VStack space={4} className="h-full pb-4">
|
||||
<CommercialUseBanner source="data-import" title="Importing work data?" />
|
||||
|
||||
<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)}
|
||||
<ImportSourceList
|
||||
sources={sources}
|
||||
detections={detections}
|
||||
disabled={isLoading}
|
||||
onAdd={addSources}
|
||||
onRemove={removeSource}
|
||||
onError={setSourceError}
|
||||
/>
|
||||
{filePath && (
|
||||
<Button
|
||||
color="primary"
|
||||
disabled={!filePath || isLoading}
|
||||
isLoading={isLoading}
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await importData(filePath);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isLoading ? "Importing" : "Import"}
|
||||
</Button>
|
||||
|
||||
{sourceError != null && (
|
||||
<Banner color="warning">
|
||||
<FormattedError>{errorMessage(sourceError)}</FormattedError>
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
{linkedWorkspace != null && linkedWorkspace.id !== destinationWorkspaceId && (
|
||||
<div className="text-xs text-text-subtle">
|
||||
{sources.length === 1 ? "This source was" : "These sources were"} last imported into{" "}
|
||||
<InlineButton
|
||||
onClick={() => {
|
||||
if (linkedWorkspace.id === currentWorkspace?.id) {
|
||||
setDestinationChoice("current");
|
||||
} else {
|
||||
setDestinationChoice("other");
|
||||
setOtherWorkspaceId(linkedWorkspace.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{linkedWorkspace.name}
|
||||
</InlineButton>
|
||||
</div>
|
||||
)}
|
||||
</VStack>
|
||||
</VStack>
|
||||
<DialogFooter
|
||||
leftSlot={destinationPicker}
|
||||
actions={[
|
||||
{ label: "Cancel", disabled: isLoading, onClick: cancel },
|
||||
{
|
||||
label: isLoading ? "Analyzing" : "Preview Import",
|
||||
color: "primary",
|
||||
disabled:
|
||||
sources.length === 0 ||
|
||||
!allDetected ||
|
||||
isLoading ||
|
||||
(destinationKind === "other" && otherWorkspaceId == null),
|
||||
isLoading,
|
||||
onClick: handlePreview,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ImportTreeRow({
|
||||
row,
|
||||
onResolveConflict,
|
||||
}: {
|
||||
row: TreeRow;
|
||||
onResolveConflict: (modelId: string, resolution: "keep_mine" | "take_source") => void;
|
||||
}) {
|
||||
if (row.kind !== "item") {
|
||||
return (
|
||||
<>
|
||||
{row.kind === "destination" && (
|
||||
<Icon icon="box" size="sm" className="shrink-0 text-text-subtle" />
|
||||
)}
|
||||
<div className="truncate flex-1">{row.label}</div>
|
||||
{row.kind === "destination" && row.isNew && (
|
||||
<ActionChip label="new" help="Created by this import" color="success" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const { item } = row;
|
||||
const label = actionLabel(item);
|
||||
return (
|
||||
<>
|
||||
<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>
|
||||
) : (
|
||||
label != null && (
|
||||
<ActionChip label={label} help={actionHelp(item)} color={actionColor(item)} />
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionChip({
|
||||
label,
|
||||
help,
|
||||
color,
|
||||
}: {
|
||||
label: string;
|
||||
help: string | null;
|
||||
color: ComponentProps<typeof Chip>["color"];
|
||||
}) {
|
||||
return (
|
||||
<Chip color={color}>
|
||||
{label}
|
||||
{help != null && <IconTooltip content={help} iconSize="xs" />}
|
||||
</Chip>
|
||||
);
|
||||
}
|
||||
|
||||
function actionColor(item: ImportPlanItem): ComponentProps<typeof Chip>["color"] {
|
||||
switch (item.action) {
|
||||
case "create":
|
||||
return "success";
|
||||
case "update":
|
||||
return "info";
|
||||
case "delete":
|
||||
return "danger";
|
||||
case "keep_local":
|
||||
return item.selected ? "warning" : "default";
|
||||
case "unchanged":
|
||||
case "conflict":
|
||||
case "ignored":
|
||||
return "default";
|
||||
}
|
||||
}
|
||||
|
||||
function actionLabel(item: ImportPlanItem): string | null {
|
||||
switch (item.action) {
|
||||
case "create":
|
||||
return "new";
|
||||
case "update":
|
||||
return "updated";
|
||||
case "delete":
|
||||
return "removed";
|
||||
case "keep_local":
|
||||
return "edited";
|
||||
case "ignored":
|
||||
return "ignored";
|
||||
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 "Not in this workspace yet";
|
||||
case "update":
|
||||
return item.reason === "moved_into_ignored_folder"
|
||||
? "Moved into a folder that isn't imported. Importing that folder brings it along"
|
||||
: help("Changed in the source since the last import");
|
||||
case "delete":
|
||||
return "Gone from the source since the last import. Checking it deletes it here";
|
||||
case "keep_local":
|
||||
return help("Changed here since the last import. Checking it reverts to the source");
|
||||
case "conflict":
|
||||
return help("Changed here and in the source since the last import");
|
||||
case "ignored":
|
||||
return "Not in this workspace. Imports leave it alone until you check 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* A row of the preview tree. Most are plan items, but the destination workspace and the group the
|
||||
* workspace's environments sit in are headings: they aggregate their children and decide nothing
|
||||
* themselves.
|
||||
*/
|
||||
type TreeRow =
|
||||
| { kind: "destination"; label: string; isNew: boolean }
|
||||
| { kind: "group"; label: string }
|
||||
| { kind: "item"; item: ImportPlanItem };
|
||||
|
||||
function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<TreeRow>[] {
|
||||
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 byKind = (list: ImportPlanItem[]) => [
|
||||
...list.filter((i) => i.model === "environment"),
|
||||
...list.filter((i) => i.model === "folder"),
|
||||
...list.filter((i) => i.model !== "environment" && i.model !== "folder"),
|
||||
];
|
||||
|
||||
const toNode = (item: ImportPlanItem, seen: Set<string>): CheckboxTreeNode<TreeRow> => ({
|
||||
key: item.modelId,
|
||||
data: { kind: "item", item },
|
||||
children: seen.has(item.modelId)
|
||||
? []
|
||||
: byKind(childrenOf.get(item.modelId) ?? []).map((c) =>
|
||||
toNode(c, new Set([...seen, item.modelId])),
|
||||
),
|
||||
});
|
||||
|
||||
// The workspace's environments have nothing to sit under — a sub-environment is a sibling of
|
||||
// the base one, not its child — so a heading groups them into one thing to turn on and off.
|
||||
const environments = roots.filter((i) => i.model === "environment");
|
||||
const others = byKind(roots.filter((i) => i.model !== "environment"));
|
||||
const nodes = others.map((r) => toNode(r, new Set()));
|
||||
if (environments.length === 0) return nodes;
|
||||
return [
|
||||
{
|
||||
key: "group:environments",
|
||||
data: { kind: "group", label: "Variables" },
|
||||
children: environments.map((e) => toNode(e, new Set())),
|
||||
},
|
||||
...nodes,
|
||||
];
|
||||
}
|
||||
|
||||
function collectRows(node: CheckboxTreeNode<TreeRow>): TreeRow[] {
|
||||
return [node.data, ...node.children.flatMap(collectRows)];
|
||||
}
|
||||
|
||||
/** A folder that isn't there yet, so anything inside it needs it brought in first. */
|
||||
function isMissingFolder(item: ImportPlanItem): boolean {
|
||||
return item.model === "folder" && (item.action === "create" || item.action === "ignored");
|
||||
}
|
||||
|
||||
/**
|
||||
* The plan item a row's checkbox decides, if it decides one. An unchanged resource has nothing to
|
||||
* decide and a conflict is decided by its own control, so neither takes a checkbox — nor rides
|
||||
* along with a parent's.
|
||||
*/
|
||||
function togglableItem(row: TreeRow): ImportPlanItem | null {
|
||||
if (row.kind !== "item") return null;
|
||||
const { item } = row;
|
||||
return item.action === "unchanged" || item.action === "conflict" ? null : item;
|
||||
}
|
||||
|
||||
function togglableItems(node: CheckboxTreeNode<TreeRow>): ImportPlanItem[] {
|
||||
return collectRows(node)
|
||||
.map(togglableItem)
|
||||
.filter((i) => i != null);
|
||||
}
|
||||
|
||||
function nodeCheckedStatus(node: CheckboxTreeNode<TreeRow>): boolean | "indeterminate" | "hidden" {
|
||||
const covered = togglableItems(node);
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { Icon, LoadingIcon } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { type KeyboardEvent, type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { Button } from "./core/Button";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { PathList, splitPath } from "./core/PathList";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
|
||||
export interface ImportSourcePath {
|
||||
path: string;
|
||||
kind?: "file" | "folder" | "url";
|
||||
}
|
||||
|
||||
export type ImportSourceDetection =
|
||||
| { status: "loading" }
|
||||
| { status: "ok"; importer: string }
|
||||
| { status: "error"; message: string };
|
||||
|
||||
interface Props {
|
||||
sources: ImportSourcePath[];
|
||||
detections: Record<string, ImportSourceDetection>;
|
||||
disabled?: boolean;
|
||||
onAdd: (sources: ImportSourcePath[]) => void;
|
||||
onRemove: (path: string) => void;
|
||||
onError: (err: unknown) => void;
|
||||
}
|
||||
|
||||
/** A bare hostname is a URL; local paths need an absolute or explicit relative prefix. */
|
||||
export function isImportFilePath(value: string): boolean {
|
||||
return (
|
||||
value.startsWith("/") ||
|
||||
value.startsWith("./") ||
|
||||
value.startsWith("../") ||
|
||||
value.startsWith("~/") ||
|
||||
value.startsWith("\\\\") ||
|
||||
/^[a-zA-Z]:[\\/]/.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
/** A typed or prefilled path is a URL unless it looks like a local path; kind is left for the host */
|
||||
export function toSourcePath(path: string): ImportSourcePath {
|
||||
return { path, kind: isImportFilePath(path) ? undefined : "url" };
|
||||
}
|
||||
|
||||
export function ImportSourceList({
|
||||
sources,
|
||||
detections,
|
||||
disabled,
|
||||
onAdd,
|
||||
onRemove,
|
||||
onError,
|
||||
}: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const [pathInput, setPathInput] = useState("");
|
||||
const [showPathInput, setShowPathInput] = useState(false);
|
||||
const pathInputRef = useRef<{ focus: () => void }>(null);
|
||||
const trimmedPath = pathInput.trim();
|
||||
|
||||
const openPathInput = () => {
|
||||
setShowPathInput(true);
|
||||
// The add-row menu hands focus back to its trigger as it closes, on a timer, so focus after it
|
||||
setTimeout(() => pathInputRef.current?.focus(), 50);
|
||||
};
|
||||
|
||||
const closePathInput = () => {
|
||||
setPathInput("");
|
||||
setShowPathInput(false);
|
||||
};
|
||||
|
||||
const addPath = () => {
|
||||
if (!trimmedPath || disabled) return;
|
||||
onAdd([toSourcePath(trimmedPath)]);
|
||||
closePathInput();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled) return;
|
||||
return platform.window.onDragDrop((event) => {
|
||||
if (event.type === "leave") {
|
||||
setIsHovering(false);
|
||||
return;
|
||||
}
|
||||
const rect = ref.current?.getBoundingClientRect();
|
||||
const { x, y } = event.position;
|
||||
const isOver =
|
||||
rect != null && x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
|
||||
if (event.type === "drop") {
|
||||
if (isOver) onAdd(event.paths.map((path) => ({ path })));
|
||||
setIsHovering(false);
|
||||
} else {
|
||||
setIsHovering(isOver);
|
||||
}
|
||||
});
|
||||
}, [disabled, onAdd]);
|
||||
|
||||
const pick = async (directory: boolean) => {
|
||||
try {
|
||||
const selected = await platform.dialog.open({
|
||||
title: directory ? "Select Import Folder" : "Select Import Files",
|
||||
multiple: true,
|
||||
directory,
|
||||
});
|
||||
if (selected == null) return;
|
||||
onAdd(selected.map((path) => ({ path, kind: directory ? "folder" : "file" })));
|
||||
} catch (err) {
|
||||
onError(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePathKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
addPath();
|
||||
} else if (event.key === "Escape") {
|
||||
event.stopPropagation();
|
||||
closePathInput();
|
||||
}
|
||||
};
|
||||
|
||||
const pathInputEl = (
|
||||
<PlainInput
|
||||
ref={pathInputRef}
|
||||
label="URL or file path"
|
||||
hideLabel
|
||||
size="sm"
|
||||
autoFocus
|
||||
disabled={disabled}
|
||||
placeholder="Paste a URL or a file path"
|
||||
onChange={setPathInput}
|
||||
onKeyDownCapture={handlePathKeyDown}
|
||||
rightSlot={
|
||||
<div className="flex items-center gap-1 my-1 mr-1">
|
||||
{trimmedPath !== "" && (
|
||||
<Button size="xs" variant="border" onClick={addPath}>
|
||||
Add
|
||||
</Button>
|
||||
)}
|
||||
<IconButton size="xs" icon="x" title="Cancel" onClick={closePathInput} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
if (sources.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
<div
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
"flex-1 rounded-lg border min-h-28 flex flex-col items-center justify-center gap-2 px-4 py-5 text-center text-text-subtle",
|
||||
isHovering ? "border-notice bg-surface-highlight" : "border-dashed border-border",
|
||||
)}
|
||||
>
|
||||
<Icon icon="folder_input" className="text-text-subtlest w-10! h-10! mb-1" />
|
||||
{isHovering ? (
|
||||
<span>Drop to add</span>
|
||||
) : (
|
||||
<span>
|
||||
Choose{" "}
|
||||
<InlineButton disabled={disabled} onClick={() => pick(false)}>
|
||||
files
|
||||
</InlineButton>
|
||||
, a{" "}
|
||||
<InlineButton disabled={disabled} onClick={() => pick(true)}>
|
||||
folder
|
||||
</InlineButton>
|
||||
, or a{" "}
|
||||
<InlineButton disabled={disabled} onClick={openPathInput}>
|
||||
URL
|
||||
</InlineButton>
|
||||
, or drag them here
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-text-subtlest">
|
||||
Supports OpenAPI, Swagger, Postman, Insomnia, Bruno, curl, and Yaak exports
|
||||
</span>
|
||||
</div>
|
||||
{showPathInput && pathInputEl}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PathList
|
||||
ref={ref}
|
||||
hovering={isHovering}
|
||||
disabled={disabled}
|
||||
onRemove={onRemove}
|
||||
addLabel="Add files, a folder, or a URL"
|
||||
addHint="or drop them here"
|
||||
addItems={[
|
||||
{ label: "Files", icon: "file", onSelect: () => pick(false) },
|
||||
{ label: "Folder", icon: "folder", onSelect: () => pick(true) },
|
||||
{ label: "URL or file path", icon: "globe", onSelect: openPathInput },
|
||||
]}
|
||||
items={sources.map((source) => {
|
||||
const detection = detections[source.path];
|
||||
return {
|
||||
path: source.path,
|
||||
icon: sourceIcon(source),
|
||||
failed: detection?.status === "error",
|
||||
subtitle: <DetectionSubtitle path={source.path} detection={detection} />,
|
||||
};
|
||||
})}
|
||||
>
|
||||
{showPathInput && pathInputEl}
|
||||
</PathList>
|
||||
);
|
||||
}
|
||||
|
||||
function DetectionSubtitle({
|
||||
path,
|
||||
detection,
|
||||
}: {
|
||||
path: string;
|
||||
detection: ImportSourceDetection | undefined;
|
||||
}) {
|
||||
const { parent } = splitPath(path);
|
||||
if (detection?.status === "loading") {
|
||||
return (
|
||||
<>
|
||||
<LoadingIcon size="xs" />
|
||||
Detecting format
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (detection?.status === "error") return detection.message;
|
||||
return (
|
||||
<>
|
||||
{detection?.status === "ok" && <span>{detection.importer}</span>}
|
||||
{detection?.status === "ok" && parent !== "" && <span>·</span>}
|
||||
{parent !== "" && <span className="truncate font-mono">{parent}</span>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function sourceIcon({ kind }: ImportSourcePath) {
|
||||
switch (kind) {
|
||||
case "url":
|
||||
return "globe";
|
||||
case "folder":
|
||||
return "folder";
|
||||
case "file":
|
||||
return "file";
|
||||
case undefined:
|
||||
return "import";
|
||||
}
|
||||
}
|
||||
|
||||
export function InlineButton({
|
||||
children,
|
||||
disabled,
|
||||
onClick,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className="underline decoration-current/40 underline-offset-3 hocus:decoration-current disabled:opacity-disabled"
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import { linter } from "@codemirror/lint";
|
||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||
import { patchModel } from "@yaakapp-internal/models";
|
||||
import { Banner, Icon } from "@yaakapp-internal/ui";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useKeyValue } from "../hooks/useKeyValue";
|
||||
import { fireAndForget } from "../lib/fireAndForget";
|
||||
import { textLikelyContainsJsonComments } from "../lib/jsonComments";
|
||||
import type { DropdownItem } from "./core/Dropdown";
|
||||
import { Dropdown } from "./core/Dropdown";
|
||||
import type { EditorProps } from "./core/Editor/Editor";
|
||||
import { jsonParseLinter } from "./core/Editor/json-lint";
|
||||
import { Editor } from "./core/Editor/LazyEditor";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { IconTooltip } from "./core/IconTooltip";
|
||||
|
||||
interface Props {
|
||||
forceUpdateKey: string;
|
||||
heightMode: EditorProps["heightMode"];
|
||||
request: HttpRequest;
|
||||
}
|
||||
|
||||
export function JsonBodyEditor({ forceUpdateKey, heightMode, request }: Props) {
|
||||
const handleChange = useCallback(
|
||||
(text: string) => patchModel(request, { body: { ...request.body, text } }),
|
||||
[request],
|
||||
);
|
||||
|
||||
const autoFix = request.body?.sendJsonComments !== true;
|
||||
|
||||
const lintExtension = useMemo(
|
||||
() =>
|
||||
linter(
|
||||
jsonParseLinter(
|
||||
autoFix
|
||||
? { allowComments: true, allowTrailingCommas: true }
|
||||
: { allowComments: false, allowTrailingCommas: false },
|
||||
),
|
||||
),
|
||||
[autoFix],
|
||||
);
|
||||
|
||||
const hasComments = useMemo(
|
||||
() => textLikelyContainsJsonComments(request.body?.text ?? ""),
|
||||
[request.body?.text],
|
||||
);
|
||||
|
||||
const { value: bannerDismissed, set: setBannerDismissed } = useKeyValue<boolean>({
|
||||
namespace: "no_sync",
|
||||
key: ["json-fix-3", request.workspaceId],
|
||||
fallback: false,
|
||||
});
|
||||
|
||||
const handleToggleAutoFix = useCallback(() => {
|
||||
const newBody = { ...request.body };
|
||||
if (autoFix) {
|
||||
newBody.sendJsonComments = true;
|
||||
} else {
|
||||
delete newBody.sendJsonComments;
|
||||
}
|
||||
fireAndForget(patchModel(request, { body: newBody }));
|
||||
}, [request, autoFix]);
|
||||
|
||||
const handleDropdownOpen = useCallback(() => {
|
||||
if (!bannerDismissed) {
|
||||
fireAndForget(setBannerDismissed(true));
|
||||
}
|
||||
}, [bannerDismissed, setBannerDismissed]);
|
||||
|
||||
const showBanner = hasComments && autoFix && !bannerDismissed;
|
||||
|
||||
const stripMessage = "Automatically strip comments and trailing commas before sending";
|
||||
const actions = useMemo<EditorProps["actions"]>(
|
||||
() => [
|
||||
showBanner && (
|
||||
<Banner color="notice" className="opacity-100! h-sm py-0! px-2! flex items-center text-xs">
|
||||
<p className="inline-flex items-center gap-1 min-w-0">
|
||||
<span className="truncate">Auto-fix enabled</span>
|
||||
<Icon icon="arrow_right" size="sm" className="opacity-disabled" />
|
||||
</p>
|
||||
</Banner>
|
||||
),
|
||||
<div key="settings" className="opacity-100! shadow!">
|
||||
<Dropdown
|
||||
onOpen={handleDropdownOpen}
|
||||
items={
|
||||
[
|
||||
{
|
||||
label: "Automatically Fix JSON",
|
||||
keepOpenOnSelect: true,
|
||||
onSelect: handleToggleAutoFix,
|
||||
rightSlot: <IconTooltip content={stripMessage} />,
|
||||
leftSlot: (
|
||||
<Icon icon={autoFix ? "check_square_checked" : "check_square_unchecked"} />
|
||||
),
|
||||
},
|
||||
] satisfies DropdownItem[]
|
||||
}
|
||||
>
|
||||
<IconButton size="sm" variant="border" icon="settings" title="JSON Settings" />
|
||||
</Dropdown>
|
||||
</div>,
|
||||
],
|
||||
[handleDropdownOpen, handleToggleAutoFix, autoFix, showBanner],
|
||||
);
|
||||
|
||||
return (
|
||||
<Editor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
autocompleteFunctions
|
||||
autocompleteVariables
|
||||
placeholder="..."
|
||||
heightMode={heightMode}
|
||||
defaultValue={`${request.body?.text ?? ""}`}
|
||||
language="json"
|
||||
onChange={handleChange}
|
||||
stateKey={`json.${request.id}`}
|
||||
actions={actions}
|
||||
lintExtension={lintExtension}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import { formatSize } from "@yaakapp-internal/lib/formatSize";
|
||||
import {
|
||||
Banner,
|
||||
Button,
|
||||
HStack,
|
||||
LoadingIcon,
|
||||
AudioViewer,
|
||||
ImageViewer,
|
||||
SvgViewer,
|
||||
VideoViewer,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import type { ReactNode } from "react";
|
||||
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
||||
import type { SniffedValue } from "./core/Editor/sniffValue";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import { decodeValue, largeValueActions } from "../lib/largeValue";
|
||||
import { Dropdown } from "./core/Dropdown";
|
||||
|
||||
const PdfViewer = lazy(() =>
|
||||
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
|
||||
);
|
||||
|
||||
interface Props {
|
||||
/** The whole value, exactly as it reads in the document */
|
||||
text: string;
|
||||
sniffed: SniffedValue | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a value the editor collapsed, in whatever viewer its type calls for.
|
||||
*
|
||||
* The editor only ever read the value's head, so this is where it is decoded in full — on an
|
||||
* explicit click, behind a spinner, rather than during layout.
|
||||
*/
|
||||
export function LargeValueDialog({ text, sniffed }: Props) {
|
||||
const viewer = sniffed == null ? null : viewerFor(sniffed.mime);
|
||||
const decoded = useDecoded(text, viewer == null ? null : sniffed);
|
||||
|
||||
// Nothing recognised it, so there is nothing to decode it into
|
||||
if (sniffed == null || viewer == null) {
|
||||
return (
|
||||
<PagedText text={text}>
|
||||
{sniffed != null && (
|
||||
<Banner color="info" className="mb-3">
|
||||
{sniffed.label} content cannot be previewed, so it is shown as it appears in the
|
||||
response.
|
||||
</Banner>
|
||||
)}
|
||||
</PagedText>
|
||||
);
|
||||
}
|
||||
|
||||
if (decoded == null) {
|
||||
return (
|
||||
<HStack className="h-full" alignItems="center" justifyContent="center">
|
||||
<LoadingIcon />
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
|
||||
if ("error" in decoded) {
|
||||
return (
|
||||
<PagedText text={text}>
|
||||
<Banner color="danger" className="mb-3">
|
||||
Failed to decode this {sniffed.label} value: {decoded.error}
|
||||
</Banner>
|
||||
</PagedText>
|
||||
);
|
||||
}
|
||||
|
||||
const { bytes } = decoded;
|
||||
|
||||
switch (viewer) {
|
||||
case "svg":
|
||||
return <DecodedSvg bytes={bytes} />;
|
||||
case "image":
|
||||
return (
|
||||
<div className="h-full overflow-auto flex items-center justify-center">
|
||||
<ImageViewer data={toArrayBuffer(bytes)} mimeType={sniffed.mime} />
|
||||
</div>
|
||||
);
|
||||
case "audio":
|
||||
return <AudioViewer data={bytes} mimeType={sniffed.mime} />;
|
||||
case "video":
|
||||
return <VideoViewer data={bytes} mimeType={sniffed.mime} />;
|
||||
case "pdf":
|
||||
return (
|
||||
<Suspense fallback={<LoadingIcon />}>
|
||||
<PdfViewer data={bytes} />
|
||||
</Suspense>
|
||||
);
|
||||
case "text":
|
||||
return <DecodedText bytes={bytes} />;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decoding megabytes is worth doing once, not on every render — and the viewers below key their
|
||||
* blob URLs off the string, so a fresh one each time would rebuild them for nothing.
|
||||
*/
|
||||
function DecodedSvg({ bytes }: { bytes: Uint8Array }) {
|
||||
const text = useMemo(() => new TextDecoder().decode(bytes), [bytes]);
|
||||
return <SvgViewer text={text} />;
|
||||
}
|
||||
|
||||
function DecodedText({ bytes }: { bytes: Uint8Array }) {
|
||||
const text = useMemo(() => new TextDecoder().decode(bytes), [bytes]);
|
||||
return <PagedText text={text} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* The same menu the tag in the editor carries, minus the one entry that would only reopen this.
|
||||
*
|
||||
* It lives in the dialog's title rather than above the content, which would cost a band of
|
||||
* whitespace the width of the dialog to hold one small button.
|
||||
*/
|
||||
function LargeValueActions({ text, sniffed }: Props) {
|
||||
const items = useMemo(
|
||||
() =>
|
||||
largeValueActions({
|
||||
value: () => text,
|
||||
sniffed,
|
||||
// The tag copies only what it hides; in here, what you see is the whole value
|
||||
copyText: () => text,
|
||||
}),
|
||||
[text, sniffed],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown items={items}>
|
||||
<Button size="xs" variant="border" forDropdown>
|
||||
Actions
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
type Viewer = "image" | "svg" | "audio" | "video" | "pdf" | "text";
|
||||
|
||||
/** Which viewer a media type calls for, or null if none of them can show it. */
|
||||
function viewerFor(mime: string): Viewer | null {
|
||||
if (mime.startsWith("image/svg")) return "svg";
|
||||
if (mime.startsWith("image/")) return "image";
|
||||
if (mime.startsWith("audio/")) return "audio";
|
||||
if (mime.startsWith("video/")) return "video";
|
||||
if (mime === "application/pdf") return "pdf";
|
||||
if (mime.startsWith("text/") || /\b(json|xml|javascript|csv)\b/.test(mime)) return "text";
|
||||
return null;
|
||||
}
|
||||
|
||||
type Decoded = { bytes: Uint8Array } | { error: string };
|
||||
|
||||
/**
|
||||
* The value's bytes, once they exist.
|
||||
*
|
||||
* Decoding a few megabytes takes long enough to be seen, so it happens in an effect rather than
|
||||
* during render — the dialog paints with a spinner first, instead of opening late.
|
||||
*/
|
||||
function useDecoded(text: string, sniffed: SniffedValue | null): Decoded | null {
|
||||
const [decoded, setDecoded] = useState<Decoded | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (sniffed == null) return;
|
||||
|
||||
setDecoded(null);
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(() => {
|
||||
let result: Decoded;
|
||||
try {
|
||||
result = { bytes: decodeValue(text, sniffed) };
|
||||
} catch (err) {
|
||||
result = { error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
if (!cancelled) setDecoded(result);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [text, sniffed]);
|
||||
|
||||
return decoded;
|
||||
}
|
||||
|
||||
/** A copy, since a `Uint8Array` may be a view onto a larger buffer */
|
||||
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||
return bytes.slice().buffer;
|
||||
}
|
||||
|
||||
/** Characters shown at once. A page this size lays out instantly once its lines are short. */
|
||||
const PAGE_CHARS = 50_000;
|
||||
|
||||
/** Where a line is broken. The long-line cost this whole feature avoids is per line. */
|
||||
const WRAP_CHARS = 120;
|
||||
|
||||
/**
|
||||
* The raw text, in pages of short lines.
|
||||
*
|
||||
* What is left when nothing can render the value. Handing it to an editor whole would walk
|
||||
* straight back into the layout stall that collapsed it in the first place, so it is paged and
|
||||
* hard-wrapped instead: no line is ever long, and no page is ever big.
|
||||
*/
|
||||
function PagedText({ text, children }: { text: string; children?: ReactNode }) {
|
||||
const [page, setPage] = useState(0);
|
||||
const pages = Math.max(1, Math.ceil(text.length / PAGE_CHARS));
|
||||
const from = page * PAGE_CHARS;
|
||||
const to = Math.min(from + PAGE_CHARS, text.length);
|
||||
const body = useMemo(() => wrap(text.slice(from, to)), [text, from, to]);
|
||||
|
||||
return (
|
||||
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)_auto] gap-3">
|
||||
<div>{children}</div>
|
||||
<pre className="overflow-auto font-mono text-sm text-text-subtle select-text">{body}</pre>
|
||||
<HStack space={2} alignItems="center" className="text-sm text-text-subtle">
|
||||
<span>
|
||||
{(to - from).toLocaleString()} of {text.length.toLocaleString()} characters
|
||||
</span>
|
||||
{pages > 1 && (
|
||||
<HStack space={2} alignItems="center" className="ml-auto">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="border"
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<span>
|
||||
Page {page + 1} of {pages.toLocaleString()}
|
||||
</span>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="border"
|
||||
disabled={page >= pages - 1}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</HStack>
|
||||
)}
|
||||
</HStack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Breaks every line at {@link WRAP_CHARS}, leaving the ones already shorter alone. */
|
||||
function wrap(text: string): string {
|
||||
const lines: string[] = [];
|
||||
for (const line of text.split("\n")) {
|
||||
if (line.length <= WRAP_CHARS) {
|
||||
lines.push(line);
|
||||
continue;
|
||||
}
|
||||
for (let i = 0; i < line.length; i += WRAP_CHARS) {
|
||||
lines.push(line.slice(i, i + WRAP_CHARS));
|
||||
}
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function showLargeValueDialog({ text, sniffed }: Props) {
|
||||
showDialog({
|
||||
id: "large-value",
|
||||
size: "lg",
|
||||
className: "h-[calc(100vh-10rem)]",
|
||||
// Everything in one line: the same words the tag uses, and the same menu it carries. A
|
||||
// separate description and a row of its own for the button cost three bands to say this.
|
||||
title: (
|
||||
<HStack space={3} alignItems="center">
|
||||
<span>
|
||||
{sniffed == null ? "Hidden Value" : sniffed.label} · {formatSize(text.length)}
|
||||
</span>
|
||||
<LargeValueActions text={text} sniffed={sniffed} />
|
||||
</HStack>
|
||||
),
|
||||
render: () => <LargeValueDialog text={text} sniffed={sniffed} />,
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import type { LicenseCheckStatus } from "@yaakapp-internal/license";
|
||||
import { 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",
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { SegmentedControl } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useRef, useState } from "react";
|
||||
import type { EditorProps } from "./core/Editor/Editor";
|
||||
import { Editor } from "./core/Editor/LazyEditor";
|
||||
import { SegmentedControl } from "./core/SegmentedControl";
|
||||
import { Markdown } from "./Markdown";
|
||||
|
||||
type ViewMode = "edit" | "preview";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -3,12 +3,12 @@ import { patchModel, workspacesAtom } from "@yaakapp-internal/models";
|
||||
import { InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useState } from "react";
|
||||
import { pluralizeCount } from "../lib/pluralize";
|
||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
||||
import { router } from "../lib/router";
|
||||
import { showToast } from "../lib/toast";
|
||||
import { Button } from "./core/Button";
|
||||
import { Select } from "./core/Select";
|
||||
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
|
||||
|
||||
interface Props {
|
||||
activeWorkspaceId: string;
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import type { Color } from "@yaakapp-internal/plugins";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { settingsAtom } from "@yaakapp-internal/models";
|
||||
import { HeaderSize, Heading, Icon, type IconProps, LoadingIcon } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useState } from "react";
|
||||
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import { importData } from "../lib/importData";
|
||||
import {
|
||||
createExampleWorkspace,
|
||||
createFreshWorkspace,
|
||||
type OnboardingChoice,
|
||||
recordOnboardingChoice,
|
||||
} from "../lib/onboarding";
|
||||
import { showErrorToast } from "../lib/toast";
|
||||
import { CloneGitRepositoryDialog } from "./CloneGitRepositoryDialog";
|
||||
import { Button } from "./core/Button";
|
||||
|
||||
/** Shown instead of a workspace when there are none. */
|
||||
export function Onboarding() {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const { capabilities } = platform;
|
||||
const canImport = capabilities.plugins;
|
||||
const showAlreadyUsing = capabilities.localFiles || capabilities.git || canImport;
|
||||
const [busy, setBusy] = useState<OnboardingChoice | null>(null);
|
||||
|
||||
const choose = (choice: OnboardingChoice, run: () => Promise<void> | void) => async () => {
|
||||
recordOnboardingChoice(choice);
|
||||
setBusy(choice);
|
||||
try {
|
||||
await run();
|
||||
} catch (err) {
|
||||
showErrorToast({
|
||||
id: "onboarding-failed",
|
||||
title: "Something went wrong",
|
||||
message: String(err),
|
||||
});
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full w-full">
|
||||
<HeaderSize
|
||||
data-tauri-drag-region
|
||||
size="lg"
|
||||
className="x-theme-appHeader bg-surface"
|
||||
osType={platform.osType()}
|
||||
hideWindowControls={settings.hideWindowControls}
|
||||
useNativeTitlebar={settings.useNativeTitlebar}
|
||||
interfaceScale={settings.interfaceScale}
|
||||
/>
|
||||
<div className="overflow-auto px-6 py-10 grid">
|
||||
<div className="m-auto w-full max-w-lg flex flex-col gap-7">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Heading>How would you like to get started?</Heading>
|
||||
<p className="text-text-subtle">
|
||||
{canImport
|
||||
? "Bring over existing work, try a real API, or start fresh."
|
||||
: "Try a real API, or start fresh."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{canImport && (
|
||||
<StartOption
|
||||
color="primary"
|
||||
icon="folder_input"
|
||||
title="Migrate from another tool"
|
||||
description="Postman, Insomnia, Bruno, OpenAPI, or curl"
|
||||
busy={busy === "import"}
|
||||
disabled={busy != null}
|
||||
onClick={choose("import", () => importData.mutateAsync())}
|
||||
/>
|
||||
)}
|
||||
<StartOption
|
||||
color="info"
|
||||
icon="flask"
|
||||
title="Try Yaak with a real API"
|
||||
description="Ready-made requests you can send right away"
|
||||
busy={busy === "example"}
|
||||
disabled={busy != null}
|
||||
onClick={choose("example", createExampleWorkspace)}
|
||||
/>
|
||||
<StartOption
|
||||
color="success"
|
||||
icon="plus"
|
||||
title="Start fresh"
|
||||
description="An empty workspace for your first request"
|
||||
busy={busy === "fresh"}
|
||||
disabled={busy != null}
|
||||
onClick={choose("fresh", createFreshWorkspace)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showAlreadyUsing && (
|
||||
<div className="pt-5 border-t border-dashed border-border-subtle flex flex-col items-start gap-2.5">
|
||||
<span className="text-sm text-text-subtle">Already using Yaak?</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{capabilities.localFiles && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="border"
|
||||
color="secondary"
|
||||
leftSlot={<Icon icon="folder_open" size="sm" />}
|
||||
disabled={busy != null}
|
||||
onClick={choose("open_folder", async () => {
|
||||
const dir = await platform.dialog.open({
|
||||
title: "Select Workspace Directory",
|
||||
directory: true,
|
||||
multiple: false,
|
||||
});
|
||||
if (dir == null) return;
|
||||
await openWorkspaceFromSyncDir.mutateAsync(dir);
|
||||
})}
|
||||
>
|
||||
Open folder
|
||||
</Button>
|
||||
)}
|
||||
{capabilities.git && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="border"
|
||||
color="secondary"
|
||||
leftSlot={<Icon icon="git_branch" size="sm" />}
|
||||
disabled={busy != null}
|
||||
onClick={choose("clone_git", () => {
|
||||
showDialog({
|
||||
id: "clone-git-repository",
|
||||
size: "md",
|
||||
title: "Clone Git Repository",
|
||||
render: ({ hide }) => <CloneGitRepositoryDialog hide={hide} />,
|
||||
});
|
||||
})}
|
||||
>
|
||||
Clone repository
|
||||
</Button>
|
||||
)}
|
||||
{canImport && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="border"
|
||||
color="secondary"
|
||||
leftSlot={<Icon icon="import" size="sm" />}
|
||||
disabled={busy != null}
|
||||
onClick={choose("import_yaak", () => importData.mutateAsync())}
|
||||
>
|
||||
Import
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StartOption({
|
||||
color,
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
onClick,
|
||||
busy,
|
||||
disabled,
|
||||
}: {
|
||||
color: Color;
|
||||
icon: IconProps["icon"];
|
||||
title: string;
|
||||
description: string;
|
||||
onClick: () => void;
|
||||
busy: boolean;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={classNames(
|
||||
"group w-full text-left rounded-lg px-3 py-2.5 border border-border-subtle",
|
||||
"grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3.5",
|
||||
"enabled:hocus:bg-surface-highlight/50 enabled:hocus:border-border",
|
||||
"outline-border-focus focus-visible:outline-2",
|
||||
disabled && !busy && "opacity-disabled",
|
||||
)}
|
||||
>
|
||||
{/* The banner theme tints this tile from the theme's own color for each option */}
|
||||
<div
|
||||
className={classNames(
|
||||
`x-theme-banner--${color}`,
|
||||
"size-10 rounded-md bg-surface-highlight grid place-items-center",
|
||||
)}
|
||||
>
|
||||
{busy ? <LoadingIcon size="sm" /> : <Icon icon={icon} color={color} size="md" />}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-text">{title}</div>
|
||||
<div className="text-sm text-text-subtle">{description}</div>
|
||||
</div>
|
||||
<Icon
|
||||
icon="chevron_right"
|
||||
className="text-text-subtlest group-enabled:group-hover:text-text-subtle group-enabled:group-focus-visible:text-text-subtle"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { GrpcConnection } from "@yaakapp-internal/models";
|
||||
import { deleteModel } from "@yaakapp-internal/models";
|
||||
import { HStack, Icon } from "@yaakapp-internal/ui";
|
||||
import { HStack, Icon, formatMillis } from "@yaakapp-internal/ui";
|
||||
import {
|
||||
differenceInHours,
|
||||
differenceInMinutes,
|
||||
@@ -9,10 +9,9 @@ import {
|
||||
isYesterday,
|
||||
} from "date-fns";
|
||||
import { useDeleteGrpcConnections } from "../hooks/useDeleteGrpcConnections";
|
||||
import { pluralizeCount } from "../lib/pluralize";
|
||||
import { Dropdown, type DropdownItem } from "./core/Dropdown";
|
||||
import { formatMillis } from "./core/HttpResponseDurationTag";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
|
||||
|
||||
interface Props {
|
||||
connections: GrpcConnection[];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||
import { deleteModel } from "@yaakapp-internal/models";
|
||||
import { HStack, Icon } from "@yaakapp-internal/ui";
|
||||
import { HStack, Icon, formatMillis, HttpStatusTag, SizeTag } from "@yaakapp-internal/ui";
|
||||
import {
|
||||
differenceInHours,
|
||||
differenceInMinutes,
|
||||
@@ -12,10 +12,7 @@ import { useDeleteHttpResponses } from "../hooks/useDeleteHttpResponses";
|
||||
import { useKeyValue } from "../hooks/useKeyValue";
|
||||
import { DismissibleBanner } from "./core/DismissibleBanner";
|
||||
import { Dropdown, type DropdownItem } from "./core/Dropdown";
|
||||
import { formatMillis } from "./core/HttpResponseDurationTag";
|
||||
import { HttpStatusTag } from "./core/HttpStatusTag";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { SizeTag } from "./core/SizeTag";
|
||||
|
||||
interface Props {
|
||||
responses: HttpResponse[];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { WebsocketConnection } from "@yaakapp-internal/models";
|
||||
import { deleteModel, getModel } from "@yaakapp-internal/models";
|
||||
import { HStack, Icon } from "@yaakapp-internal/ui";
|
||||
import { HStack, Icon, formatMillis } from "@yaakapp-internal/ui";
|
||||
import {
|
||||
differenceInHours,
|
||||
differenceInMinutes,
|
||||
@@ -9,10 +9,9 @@ import {
|
||||
isYesterday,
|
||||
} from "date-fns";
|
||||
import { deleteWebsocketConnections } from "../commands/deleteWebsocketConnections";
|
||||
import { pluralizeCount } from "../lib/pluralize";
|
||||
import { Dropdown, type DropdownItem } from "./core/Dropdown";
|
||||
import { formatMillis } from "./core/HttpResponseDurationTag";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
|
||||
|
||||
interface Props {
|
||||
connections: WebsocketConnection[];
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getRecentRequests } from "../hooks/useRecentRequests";
|
||||
import { useRecentWorkspaces } from "../hooks/useRecentWorkspaces";
|
||||
import { fireAndForget } from "../lib/fireAndForget";
|
||||
import { router } from "../lib/router";
|
||||
import { Onboarding } from "./Onboarding";
|
||||
|
||||
export function RedirectToLatestWorkspace() {
|
||||
const workspaces = useAtomValue(workspacesAtom);
|
||||
@@ -14,10 +15,6 @@ export function RedirectToLatestWorkspace() {
|
||||
|
||||
useEffect(() => {
|
||||
if (workspaces.length === 0 || recentWorkspaces == null) {
|
||||
console.log("No workspaces found to redirect to. Skipping.", {
|
||||
workspaces,
|
||||
recentWorkspaces,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -34,11 +31,21 @@ export function RedirectToLatestWorkspace() {
|
||||
request_id: requestId,
|
||||
};
|
||||
|
||||
// Whatever created the first workspace may have already navigated into it
|
||||
const currentId = router.history.location.pathname.match(/^\/workspaces\/([^/]+)/)?.[1];
|
||||
if (workspaces.some((w) => w.id === currentId)) return;
|
||||
|
||||
console.log("Redirecting to workspace", params, search);
|
||||
await router.navigate({ to: "/workspaces/$workspaceId", params, search });
|
||||
})(),
|
||||
);
|
||||
}, [recentWorkspaces, workspaces, workspaces.length]);
|
||||
|
||||
// The global models are loaded before the router mounts, so an empty list is a real
|
||||
// first launch (or the last workspace was just deleted), not a store still loading
|
||||
if (workspaces.length === 0) {
|
||||
return <Onboarding />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||
import { lazy, Suspense } from "react";
|
||||
import { useHttpRequestBody } from "../hooks/useHttpRequestBody";
|
||||
import { getMimeTypeFromContentType, languageFromContentType } from "../lib/contentType";
|
||||
import { LoadingIcon } from "@yaakapp-internal/ui";
|
||||
import { EmptyStateText } from "./EmptyStateText";
|
||||
import { AudioViewer } from "./responseViewers/AudioViewer";
|
||||
import { CsvViewer } from "./responseViewers/CsvViewer";
|
||||
import { ImageViewer } from "./responseViewers/ImageViewer";
|
||||
import { MultipartViewer } from "./responseViewers/MultipartViewer";
|
||||
import { SvgViewer } from "./responseViewers/SvgViewer";
|
||||
import { TextViewer } from "./responseViewers/TextViewer";
|
||||
import { VideoViewer } from "./responseViewers/VideoViewer";
|
||||
import { WebPageViewer } from "./responseViewers/WebPageViewer";
|
||||
|
||||
const PdfViewer = lazy(() =>
|
||||
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
|
||||
);
|
||||
|
||||
interface Props {
|
||||
response: HttpResponse;
|
||||
}
|
||||
|
||||
export function RequestBodyViewer({ response }: Props) {
|
||||
return <RequestBodyViewerInner key={response.id} response={response} />;
|
||||
}
|
||||
|
||||
function RequestBodyViewerInner({ response }: Props) {
|
||||
const { data, isLoading, error } = useHttpRequestBody(response);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<EmptyStateText>
|
||||
<LoadingIcon />
|
||||
</EmptyStateText>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <EmptyStateText>Error loading request body: {error.message}</EmptyStateText>;
|
||||
}
|
||||
|
||||
if (data?.bodyText == null || data.bodyText.length === 0) {
|
||||
return <EmptyStateText>No request body</EmptyStateText>;
|
||||
}
|
||||
|
||||
const { bodyText, body } = data;
|
||||
|
||||
// Try to detect language from content-type header that was sent
|
||||
const contentTypeHeader = response.requestHeaders.find(
|
||||
(h) => h.name.toLowerCase() === "content-type",
|
||||
);
|
||||
const contentType = contentTypeHeader?.value ?? null;
|
||||
const mimeType = contentType ? getMimeTypeFromContentType(contentType).essence : null;
|
||||
const language = languageFromContentType(contentType, bodyText);
|
||||
|
||||
// Route to appropriate viewer based on content type
|
||||
if (mimeType?.match(/^multipart/i)) {
|
||||
const boundary = contentType?.split("boundary=")[1] ?? "unknown";
|
||||
// Create a copy because parseMultipart may detach the buffer
|
||||
const bodyCopy = new Uint8Array(body);
|
||||
return (
|
||||
<MultipartViewer data={bodyCopy} boundary={boundary} idPrefix={`request.${response.id}`} />
|
||||
);
|
||||
}
|
||||
|
||||
if (mimeType?.match(/^image\/svg/i)) {
|
||||
return <SvgViewer text={bodyText} />;
|
||||
}
|
||||
|
||||
if (mimeType?.match(/^image/i)) {
|
||||
return <ImageViewer data={body.buffer} />;
|
||||
}
|
||||
|
||||
if (mimeType?.match(/^audio/i)) {
|
||||
return <AudioViewer data={body} />;
|
||||
}
|
||||
|
||||
if (mimeType?.match(/^video/i)) {
|
||||
return <VideoViewer data={body} />;
|
||||
}
|
||||
|
||||
if (mimeType?.match(/csv|tab-separated/i)) {
|
||||
return <CsvViewer text={bodyText} />;
|
||||
}
|
||||
|
||||
if (mimeType?.match(/^text\/html/i)) {
|
||||
return <WebPageViewer html={bodyText} />;
|
||||
}
|
||||
|
||||
if (mimeType?.match(/pdf/i)) {
|
||||
return (
|
||||
<Suspense fallback={<LoadingIcon />}>
|
||||
<PdfViewer data={body} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TextViewer text={bodyText} language={language} stateKey={`request.body.${response.id}`} />
|
||||
);
|
||||
}
|
||||
@@ -1,38 +1,20 @@
|
||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||
import { patchModel } from "@yaakapp-internal/models";
|
||||
import classNames from "classnames";
|
||||
import { memo, useCallback, useMemo } from "react";
|
||||
import { showPrompt } from "../lib/prompt";
|
||||
import { Button } from "./core/Button";
|
||||
import type { DropdownItem } from "./core/Dropdown";
|
||||
import { HttpMethodTag, HttpMethodTagRaw } from "./core/HttpMethodTag";
|
||||
import { Icon } from "@yaakapp-internal/ui";
|
||||
import type { RadioDropdownItem } from "./core/RadioDropdown";
|
||||
import { RadioDropdown } from "./core/RadioDropdown";
|
||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
||||
import { RequestMethodDropdown as BaseRequestMethodDropdown } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { memo, useCallback } from "react";
|
||||
|
||||
type Props = {
|
||||
request: HttpRequest;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const radioItems: RadioDropdownItem<string>[] = [
|
||||
"GET",
|
||||
"PUT",
|
||||
"POST",
|
||||
"PATCH",
|
||||
"DELETE",
|
||||
"OPTIONS",
|
||||
"QUERY",
|
||||
"HEAD",
|
||||
].map((m) => ({
|
||||
value: m,
|
||||
label: <HttpMethodTagRaw method={m} />,
|
||||
}));
|
||||
|
||||
/** The shared method menu, writing to the request and coloured by the user's setting */
|
||||
export const RequestMethodDropdown = memo(function RequestMethodDropdown({
|
||||
request,
|
||||
className,
|
||||
}: Props) {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const handleChange = useCallback(
|
||||
async (method: string) => {
|
||||
await patchModel(request, { method });
|
||||
@@ -40,39 +22,12 @@ export const RequestMethodDropdown = memo(function RequestMethodDropdown({
|
||||
[request],
|
||||
);
|
||||
|
||||
const itemsAfter = useMemo<DropdownItem[]>(
|
||||
() => [
|
||||
{
|
||||
key: "custom",
|
||||
label: "CUSTOM",
|
||||
leftSlot: <Icon icon="sparkles" />,
|
||||
onSelect: async () => {
|
||||
const newMethod = await showPrompt({
|
||||
id: "custom-method",
|
||||
label: "Http Method",
|
||||
title: "Custom Method",
|
||||
confirmText: "Save",
|
||||
description: "Enter a custom method name",
|
||||
placeholder: "CUSTOM",
|
||||
});
|
||||
if (newMethod == null) return;
|
||||
await handleChange(newMethod);
|
||||
},
|
||||
},
|
||||
],
|
||||
[handleChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<RadioDropdown
|
||||
value={request.method}
|
||||
items={radioItems}
|
||||
itemsAfter={itemsAfter}
|
||||
<BaseRequestMethodDropdown
|
||||
method={request.method}
|
||||
colored={settings.coloredMethods}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<Button size="xs" className={classNames(className, "text-text-subtle hover:text")}>
|
||||
<HttpMethodTag request={request} noAlias />
|
||||
</Button>
|
||||
</RadioDropdown>
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -1,110 +0,0 @@
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||
import { format, formatDistanceToNowStrict } from "date-fns";
|
||||
import { useMemo } from "react";
|
||||
import { CountBadge } from "./core/CountBadge";
|
||||
import { DetailsBanner } from "./core/DetailsBanner";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
|
||||
|
||||
interface Props {
|
||||
response: HttpResponse;
|
||||
}
|
||||
|
||||
export function ResponseHeaders({ response }: Props) {
|
||||
const responseHeaders = useMemo(
|
||||
() =>
|
||||
[...response.headers].sort((a, b) =>
|
||||
a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()),
|
||||
),
|
||||
[response.headers],
|
||||
);
|
||||
const requestHeaders = useMemo(
|
||||
() =>
|
||||
[...response.requestHeaders].sort((a, b) =>
|
||||
a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()),
|
||||
),
|
||||
[response.requestHeaders],
|
||||
);
|
||||
return (
|
||||
<div className="overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5">
|
||||
<DetailsBanner storageKey={`${response.requestId}.general`} summary={<h2>Info</h2>}>
|
||||
<KeyValueRows>
|
||||
<KeyValueRow labelColor="secondary" label="Sent">
|
||||
<time
|
||||
dateTime={new Date(`${response.createdAt}Z`).toISOString()}
|
||||
title={formatDistanceToNowStrict(`${response.createdAt}Z`, { addSuffix: true })}
|
||||
>
|
||||
{format(`${response.createdAt}Z`, "MMM d, yyyy, h:mm:ss a O")}
|
||||
</time>
|
||||
</KeyValueRow>
|
||||
<KeyValueRow labelColor="secondary" label="Request URL">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="select-text cursor-text">{response.url}</span>
|
||||
<IconButton
|
||||
iconSize="sm"
|
||||
className="inline-block w-auto h-auto! opacity-50 hover:opacity-100"
|
||||
icon="external_link"
|
||||
onClick={() => openUrl(response.url)}
|
||||
title="Open in browser"
|
||||
/>
|
||||
</div>
|
||||
</KeyValueRow>
|
||||
<KeyValueRow labelColor="secondary" label="Remote Address">
|
||||
{response.remoteAddr ?? <span className="text-text-subtlest">--</span>}
|
||||
</KeyValueRow>
|
||||
<KeyValueRow labelColor="secondary" label="Version">
|
||||
{response.version ?? <span className="text-text-subtlest">--</span>}
|
||||
</KeyValueRow>
|
||||
</KeyValueRows>
|
||||
</DetailsBanner>
|
||||
<DetailsBanner
|
||||
storageKey={`${response.requestId}.request_headers`}
|
||||
summary={
|
||||
<h2 className="flex items-center">
|
||||
Request Headers <CountBadge showZero count={requestHeaders.length} />
|
||||
</h2>
|
||||
}
|
||||
>
|
||||
{requestHeaders.length === 0 ? (
|
||||
<NoHeaders />
|
||||
) : (
|
||||
<KeyValueRows>
|
||||
{requestHeaders.map((h, i) => (
|
||||
// oxlint-disable-next-line react/no-array-index-key
|
||||
<KeyValueRow labelColor="primary" key={i} label={h.name}>
|
||||
{h.value}
|
||||
</KeyValueRow>
|
||||
))}
|
||||
</KeyValueRows>
|
||||
)}
|
||||
</DetailsBanner>
|
||||
<DetailsBanner
|
||||
defaultOpen
|
||||
storageKey={`${response.requestId}.response_headers`}
|
||||
summary={
|
||||
<h2 className="flex items-center">
|
||||
Response Headers <CountBadge showZero count={responseHeaders.length} />
|
||||
</h2>
|
||||
}
|
||||
>
|
||||
{responseHeaders.length === 0 ? (
|
||||
<NoHeaders />
|
||||
) : (
|
||||
<KeyValueRows>
|
||||
{responseHeaders.map((h, i) => (
|
||||
// oxlint-disable-next-line react/no-array-index-key
|
||||
<KeyValueRow labelColor="info" key={i} label={h.name}>
|
||||
{h.value}
|
||||
</KeyValueRow>
|
||||
))}
|
||||
</KeyValueRows>
|
||||
)}
|
||||
</DetailsBanner>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NoHeaders() {
|
||||
return <span className="text-text-subtlest text-sm italic">No Headers</span>;
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
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 { KeyValueRow } from "./core/KeyValueRow";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { KeyValueRows } from "@yaakapp-internal/ui";
|
||||
|
||||
interface Props {
|
||||
response: HttpResponse;
|
||||
@@ -26,7 +27,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>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { HStack } from "@yaakapp-internal/ui";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { HStack, IconTooltip, Label } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import mime from "mime";
|
||||
import type { ReactNode } from "react";
|
||||
@@ -8,8 +7,6 @@ import { useEffect, useRef, useState } from "react";
|
||||
import type { ButtonProps } from "./core/Button";
|
||||
import { Button } from "./core/Button";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { IconTooltip } from "./core/IconTooltip";
|
||||
import { Label } from "./core/Label";
|
||||
|
||||
type Props = Omit<ButtonProps, "type"> & {
|
||||
onChange: (value: { filePath: string | null; contentType: string | null }) => void;
|
||||
@@ -41,7 +38,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 +61,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,16 +1,20 @@
|
||||
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";
|
||||
import classNames from "classnames";
|
||||
import {
|
||||
CountBadge,
|
||||
SETTINGS_TAB_GENERAL,
|
||||
SETTINGS_TAB_INTERFACE,
|
||||
SETTINGS_TAB_LICENSE,
|
||||
SETTINGS_TAB_THEME,
|
||||
SettingsDialog,
|
||||
type SettingsTabDef,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useKeyPressEvent } from "react-use";
|
||||
import { appInfo } from "../../lib/appInfo";
|
||||
import { capitalize } from "../../lib/capitalize";
|
||||
import { CountBadge } from "../core/CountBadge";
|
||||
import { TabContent, type TabItem, Tabs } from "../core/Tabs/Tabs";
|
||||
import { SettingsAgents } from "./SettingsAgents";
|
||||
import { SettingsCertificates } from "./SettingsCertificates";
|
||||
import { SettingsGeneral } from "./SettingsGeneral";
|
||||
import { SettingsHotkeys } from "./SettingsHotkeys";
|
||||
@@ -21,141 +25,98 @@ 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;
|
||||
}
|
||||
|
||||
const TAB_GENERAL = "general";
|
||||
const TAB_INTERFACE = "interface";
|
||||
const TAB_THEME = "theme";
|
||||
const TAB_GENERAL = SETTINGS_TAB_GENERAL;
|
||||
const TAB_INTERFACE = SETTINGS_TAB_INTERFACE;
|
||||
const TAB_THEME = SETTINGS_TAB_THEME;
|
||||
const TAB_SHORTCUTS = "shortcuts";
|
||||
const TAB_PROXY = "proxy";
|
||||
const TAB_CERTIFICATES = "certificates";
|
||||
const TAB_PLUGINS = "plugins";
|
||||
const TAB_LICENSE = "license";
|
||||
const TAB_AGENTS = "agents";
|
||||
const TAB_LICENSE = SETTINGS_TAB_LICENSE;
|
||||
const tabs = [
|
||||
TAB_GENERAL,
|
||||
TAB_THEME,
|
||||
TAB_INTERFACE,
|
||||
TAB_SHORTCUTS,
|
||||
TAB_PLUGINS,
|
||||
TAB_AGENTS,
|
||||
TAB_CERTIFICATES,
|
||||
TAB_PROXY,
|
||||
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();
|
||||
});
|
||||
|
||||
const tabDefs: Record<SettingsTab, Omit<SettingsTabDef, "value" | "label">> = {
|
||||
[TAB_GENERAL]: { icon: "settings", content: <SettingsGeneral /> },
|
||||
[TAB_THEME]: { icon: "palette", content: <SettingsTheme /> },
|
||||
[TAB_INTERFACE]: { icon: "columns_2", content: <SettingsInterface /> },
|
||||
[TAB_SHORTCUTS]: { icon: "keyboard", content: <SettingsHotkeys /> },
|
||||
[TAB_PLUGINS]: {
|
||||
icon: "puzzle",
|
||||
rightSlot: <CountBadge count={plugins.filter((p) => p.source !== "bundled").length} />,
|
||||
content: <SettingsPlugins defaultSubtab={mainTab === TAB_PLUGINS ? subtab : undefined} />,
|
||||
contentClassName: "h-full grid grid-rows-1",
|
||||
},
|
||||
[TAB_AGENTS]: {
|
||||
icon: "sparkles",
|
||||
hidden: !platform.capabilities.agents,
|
||||
content: <SettingsAgents />,
|
||||
},
|
||||
[TAB_CERTIFICATES]: {
|
||||
icon: "shield_check",
|
||||
rightSlot: <CountBadge count={settings.clientCertificates.length} />,
|
||||
content: <SettingsCertificates />,
|
||||
},
|
||||
[TAB_PROXY]: {
|
||||
icon: "wifi",
|
||||
rightSlot: settings.proxy?.type === "enabled" ? <CountBadge count /> : null,
|
||||
content: <SettingsProxy />,
|
||||
},
|
||||
[TAB_LICENSE]: {
|
||||
icon: "key_round",
|
||||
hidden: !appInfo.featureLicense,
|
||||
rightSlot:
|
||||
licenseCheck.check.data?.status === "personal_use" ? (
|
||||
<CountBadge count color="notice" />
|
||||
) : null,
|
||||
content: <SettingsLicense />,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classNames("grid grid-rows-[auto_minmax(0,1fr)] h-full")}>
|
||||
{hide ? (
|
||||
<span />
|
||||
) : (
|
||||
<HeaderSize
|
||||
data-tauri-drag-region
|
||||
ignoreControlsSpacing
|
||||
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()}
|
||||
hideWindowControls={settings.hideWindowControls}
|
||||
useNativeTitlebar={settings.useNativeTitlebar}
|
||||
interfaceScale={settings.interfaceScale}
|
||||
>
|
||||
<HStack
|
||||
space={2}
|
||||
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>
|
||||
</HStack>
|
||||
</HeaderSize>
|
||||
)}
|
||||
<Tabs
|
||||
layout="horizontal"
|
||||
defaultValue={mainTab || tabFromQuery}
|
||||
addBorders
|
||||
tabListClassName="min-w-40 bg-surface x-theme-sidebar border-r border-border pl-3"
|
||||
label="Settings"
|
||||
tabs={tabs.map(
|
||||
(value): TabItem => ({
|
||||
value,
|
||||
label: capitalize(value),
|
||||
hidden: !appInfo.featureLicense && value === TAB_LICENSE,
|
||||
leftSlot:
|
||||
value === TAB_GENERAL ? (
|
||||
<Icon icon="settings" className="text-secondary" />
|
||||
) : value === TAB_THEME ? (
|
||||
<Icon icon="palette" className="text-secondary" />
|
||||
) : value === TAB_INTERFACE ? (
|
||||
<Icon icon="columns_2" className="text-secondary" />
|
||||
) : value === TAB_SHORTCUTS ? (
|
||||
<Icon icon="keyboard" className="text-secondary" />
|
||||
) : value === TAB_CERTIFICATES ? (
|
||||
<Icon icon="shield_check" className="text-secondary" />
|
||||
) : value === TAB_PROXY ? (
|
||||
<Icon icon="wifi" className="text-secondary" />
|
||||
) : value === TAB_PLUGINS ? (
|
||||
<Icon icon="puzzle" className="text-secondary" />
|
||||
) : value === TAB_LICENSE ? (
|
||||
<Icon icon="key_round" className="text-secondary" />
|
||||
) : null,
|
||||
rightSlot:
|
||||
value === TAB_CERTIFICATES ? (
|
||||
<CountBadge count={settings.clientCertificates.length} />
|
||||
) : value === TAB_PLUGINS ? (
|
||||
<CountBadge count={plugins.filter((p) => p.source !== "bundled").length} />
|
||||
) : value === TAB_PROXY && settings.proxy?.type === "enabled" ? (
|
||||
<CountBadge count />
|
||||
) : value === TAB_LICENSE && licenseCheck.check.data?.status === "personal_use" ? (
|
||||
<CountBadge count color="notice" />
|
||||
) : null,
|
||||
}),
|
||||
)}
|
||||
>
|
||||
<TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-6 py-4!">
|
||||
<SettingsGeneral />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_INTERFACE} className="overflow-y-auto h-full px-6 py-4!">
|
||||
<SettingsInterface />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_THEME} className="overflow-y-auto h-full px-6 py-4!">
|
||||
<SettingsTheme />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_SHORTCUTS} className="overflow-y-auto h-full px-6 py-4!">
|
||||
<SettingsHotkeys />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_PLUGINS} className="h-full grid grid-rows-1">
|
||||
<SettingsPlugins defaultSubtab={mainTab === TAB_PLUGINS ? subtab : undefined} />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_PROXY} className="overflow-y-auto h-full px-6 py-4!">
|
||||
<SettingsProxy />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_CERTIFICATES} className="overflow-y-auto h-full px-6 py-4!">
|
||||
<SettingsCertificates />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_LICENSE} className="overflow-y-auto h-full px-6 py-4!">
|
||||
<SettingsLicense />
|
||||
</TabContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
<SettingsDialog
|
||||
defaultTab={mainTab}
|
||||
titlebar={
|
||||
hide
|
||||
? null
|
||||
: {
|
||||
osType: platform.osType(),
|
||||
hideWindowControls: settings.hideWindowControls,
|
||||
useNativeTitlebar: settings.useNativeTitlebar,
|
||||
interfaceScale: settings.interfaceScale,
|
||||
}
|
||||
}
|
||||
tabs={tabs.map((value) => ({ value, label: capitalize(value), ...tabDefs[value] }))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import type { AgentSetupStatus, AppMetaData } from "@yaakapp-internal/rpc-schema";
|
||||
import { Banner, Heading, Icon, InlineCode, SegmentedControl, VStack } from "@yaakapp-internal/ui";
|
||||
import { useEffect } from "react";
|
||||
import { useKeyValue } from "../../hooks/useKeyValue";
|
||||
import { usePluginInfos } from "../../hooks/usePluginInfo";
|
||||
import {
|
||||
AGENT_CLIENTS,
|
||||
type AgentClient,
|
||||
cliSetup,
|
||||
skillInstallCommand,
|
||||
} from "../../lib/agentSetup";
|
||||
import { appInfo } from "../../lib/appInfo";
|
||||
import { rpc } from "../../lib/rpc";
|
||||
import { Link } from "../core/Link";
|
||||
import {
|
||||
SettingCommand,
|
||||
SettingSnippet,
|
||||
SettingsList,
|
||||
SettingsSection,
|
||||
} from "../core/SettingRow";
|
||||
import { DeprecatedPluginBanner } from "./SettingsPlugins";
|
||||
|
||||
const CLI_INSTALL_COMMAND = "npm install -g @yaakapp/cli";
|
||||
const MCP_SERVER_PLUGIN = "@yaak/mcp-server";
|
||||
|
||||
export function SettingsAgents() {
|
||||
const queryClient = useQueryClient();
|
||||
const { value: storedClient, set: setClient } = useKeyValue<AgentClient>({
|
||||
key: "agents_setup_client",
|
||||
fallback: "claude_code",
|
||||
});
|
||||
const client = AGENT_CLIENTS.find((c) => c.value === storedClient)?.value ?? "claude_code";
|
||||
const clientLabel = AGENT_CLIENTS.find((c) => c.value === client)?.label ?? "your agent";
|
||||
const cliVersion = useQuery({
|
||||
queryKey: ["cli_version"],
|
||||
queryFn: async () => (await rpc<AppMetaData>("cmd_metadata")).cliVersion,
|
||||
initialData: appInfo.cliVersion,
|
||||
});
|
||||
const setupStatus = useQuery({
|
||||
queryKey: ["agent_setup_status", client],
|
||||
queryFn: () => rpc<AgentSetupStatus>("cmd_agent_setup_status", { agent: client }),
|
||||
});
|
||||
const mcpServerPlugin = usePluginInfos().find(({ info }) => info.name === MCP_SERVER_PLUGIN);
|
||||
|
||||
useEffect(() => {
|
||||
return platform.window.onFocusChanged((focused) => {
|
||||
if (!focused) return;
|
||||
void queryClient.invalidateQueries({ queryKey: ["cli_version"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["agent_setup_status"] });
|
||||
});
|
||||
}, [queryClient]);
|
||||
|
||||
const cliInstalled = cliVersion.data != null;
|
||||
const cli = cliSetup(client);
|
||||
|
||||
return (
|
||||
<VStack space={1.5} className="mb-4">
|
||||
<div className="mb-3">
|
||||
<Heading>Agents</Heading>
|
||||
<p className="text-text-subtle">
|
||||
Connect AI coding agents like Claude Code and Cursor to Yaak.
|
||||
</p>
|
||||
</div>
|
||||
<SettingsList>
|
||||
<SettingsSection title={null}>
|
||||
<div className="pb-4 border-b border-border-subtle">
|
||||
<SegmentedControl
|
||||
name="agents_setup_client"
|
||||
label="Agent"
|
||||
hideLabel
|
||||
value={client}
|
||||
onChange={setClient}
|
||||
options={AGENT_CLIENTS}
|
||||
/>
|
||||
</div>
|
||||
{cliInstalled ? (
|
||||
<div className="flex items-center gap-2 py-3 text-sm text-text-subtle border-b border-border-subtle">
|
||||
<Icon icon="check" size="sm" color="success" />
|
||||
<span>
|
||||
Yaak CLI <span className="text-text">{cliVersion.data}</span> is installed.
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-4 border-b border-border-subtle">
|
||||
<Banner color="info">
|
||||
<div className="flex items-center gap-2 text text-text">
|
||||
<Icon icon="square_terminal" color="info" />
|
||||
Install the Yaak CLI first
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-text-subtle">
|
||||
Agents launch it to reach Yaak, even with the app closed.
|
||||
</p>
|
||||
<SettingSnippet className="mt-3" language="shell" snippet={CLI_INSTALL_COMMAND} />
|
||||
<div className="mt-3 mb-1 flex items-center gap-4 text-sm">
|
||||
<Link href="https://yaak.app/docs/cli">Docs</Link>
|
||||
<span className="text-text-subtlest">
|
||||
Checks again when you come back to Yaak.
|
||||
</span>
|
||||
</div>
|
||||
</Banner>
|
||||
</div>
|
||||
)}
|
||||
{mcpServerPlugin != null && (
|
||||
<div className="py-4 border-b border-border-subtle">
|
||||
<DeprecatedPluginBanner
|
||||
plugin={mcpServerPlugin.plugin}
|
||||
name={mcpServerPlugin.info.displayName}
|
||||
>
|
||||
The two steps below replace it. Uninstall it once your agent is connected.
|
||||
</DeprecatedPluginBanner>
|
||||
</div>
|
||||
)}
|
||||
<SettingCommand
|
||||
title="Add the MCP server"
|
||||
description={cli.where}
|
||||
snippet={cli.snippet}
|
||||
language={cli.language}
|
||||
disabled={!cliInstalled}
|
||||
done={setupStatus.data?.mcpServer ? "Added" : null}
|
||||
/>
|
||||
<SettingCommand
|
||||
title="Install the skill"
|
||||
description={
|
||||
<>
|
||||
Lets {clientLabel} run the <InlineCode>yaak</InlineCode> command directly.
|
||||
</>
|
||||
}
|
||||
snippet={skillInstallCommand(client)}
|
||||
language="shell"
|
||||
disabled={!cliInstalled}
|
||||
done={setupStatus.data?.skill ? "Installed" : null}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</SettingsList>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +1,17 @@
|
||||
import { revealItemInDir } from "@tauri-apps/plugin-opener";
|
||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
||||
import { Heading, VStack } from "@yaakapp-internal/ui";
|
||||
import { settingsAtom } from "@yaakapp-internal/models";
|
||||
import { SettingsGeneral as SharedSettingsGeneral } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useCheckForUpdates } from "../../hooks/useCheckForUpdates";
|
||||
import { appInfo } from "../../lib/appInfo";
|
||||
import { revealInFinderText } from "../../lib/reveal";
|
||||
import { CargoFeature } from "../CargoFeature";
|
||||
import { useSettingsView } from "../../hooks/useSettingsView";
|
||||
import { CommercialUseBanner } from "../CommercialUseBanner";
|
||||
import { DismissibleBanner } from "../core/DismissibleBanner";
|
||||
import { IconButton } from "../core/IconButton";
|
||||
import {
|
||||
ModelSettingRowBoolean,
|
||||
ModelSettingSelectControl,
|
||||
SettingValue,
|
||||
SettingRow,
|
||||
SettingRowBoolean,
|
||||
SettingRowSelect,
|
||||
SettingsList,
|
||||
SettingsSection,
|
||||
} from "../core/SettingRow";
|
||||
|
||||
const WORKSPACE_SETTINGS_MOVED_AT = "2026-06-30";
|
||||
|
||||
export function SettingsGeneral() {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const checkForUpdates = useCheckForUpdates();
|
||||
const bound = useSettingsView();
|
||||
|
||||
if (settings == null) {
|
||||
if (settings == null || bound == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -34,147 +19,24 @@ export function SettingsGeneral() {
|
||||
settings.createdAt.slice(0, 10) < WORKSPACE_SETTINGS_MOVED_AT;
|
||||
|
||||
return (
|
||||
<VStack space={1.5} className="mb-4">
|
||||
<div>
|
||||
<Heading>General</Heading>
|
||||
<p className="text-text-subtle">
|
||||
Configure general settings for update behavior and more.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-3 mb-5">
|
||||
<CommercialUseBanner source="settings-general" title="Using Yaak for work?" />
|
||||
</div>
|
||||
<SettingsList className="space-y-8">
|
||||
<CargoFeature feature="updater">
|
||||
<SettingsSection title="Updates">
|
||||
<SettingRow
|
||||
title="Update Channel"
|
||||
description="Choose whether Yaak should use stable releases or beta releases."
|
||||
>
|
||||
<div className="grid grid-cols-[12rem_auto] gap-1">
|
||||
<ModelSettingSelectControl
|
||||
model={settings}
|
||||
modelKey="updateChannel"
|
||||
label="Update Channel"
|
||||
selectClassName="w-full!"
|
||||
options={[
|
||||
{ label: "Stable", value: "stable" },
|
||||
{ label: "Beta", value: "beta" },
|
||||
]}
|
||||
/>
|
||||
<IconButton
|
||||
variant="border"
|
||||
size="sm"
|
||||
title="Check for updates"
|
||||
icon="refresh"
|
||||
spin={checkForUpdates.isPending}
|
||||
onClick={() => checkForUpdates.mutateAsync()}
|
||||
/>
|
||||
</div>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRowSelect
|
||||
title="Update Behavior"
|
||||
description="Choose whether updates are installed automatically or manually."
|
||||
name="autoupdate"
|
||||
value={settings.autoupdate ? "auto" : "manual"}
|
||||
onChange={(v) =>
|
||||
patchModel(settings, { autoupdate: v === "auto" })
|
||||
}
|
||||
options={[
|
||||
{ label: "Automatic", value: "auto" },
|
||||
{ label: "Manual", value: "manual" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<ModelSettingRowBoolean
|
||||
model={settings}
|
||||
modelKey="autoDownloadUpdates"
|
||||
title="Automatically download updates"
|
||||
description="Download Yaak updates in the background so they are ready to install."
|
||||
disabled={!settings.autoupdate}
|
||||
/>
|
||||
|
||||
<ModelSettingRowBoolean
|
||||
model={settings}
|
||||
modelKey="checkNotifications"
|
||||
title="Check for notifications"
|
||||
description="Periodically ping Yaak servers to check for relevant notifications."
|
||||
/>
|
||||
|
||||
<SettingRowBoolean
|
||||
title="Send anonymous usage statistics"
|
||||
description="Yaak is local-first and does not collect analytics or usage data."
|
||||
disabled
|
||||
checked={false}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</CargoFeature>
|
||||
|
||||
<CargoFeature feature="license">
|
||||
<SettingsSection title="Feedback">
|
||||
<SettingRowBoolean
|
||||
title="Prompt for feedback"
|
||||
description="Show rare one-time prompts asking how new features are working."
|
||||
checked={settings.promptFeedback}
|
||||
onChange={(promptFeedback) => patchModel(settings, { promptFeedback })}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</CargoFeature>
|
||||
|
||||
{showWorkspaceSettingsMovedBanner && (
|
||||
<SharedSettingsGeneral
|
||||
view={bound.view}
|
||||
callbacks={bound.callbacks}
|
||||
banner={<CommercialUseBanner source="settings-general" title="Using Yaak for work?" />}
|
||||
extraSections={
|
||||
showWorkspaceSettingsMovedBanner && (
|
||||
<DismissibleBanner
|
||||
id="workspace-settings-moved-2026-06-30"
|
||||
color="info"
|
||||
className="w-full p-4 max-w-xl mr-auto"
|
||||
>
|
||||
<p>
|
||||
Workspace specific settings have moved to{" "}
|
||||
<b>Workspace Settings</b>, accessible from the workspace switcher
|
||||
menu.
|
||||
Workspace specific settings have moved to <b>Workspace Settings</b>, accessible from
|
||||
the workspace switcher menu.
|
||||
</p>
|
||||
</DismissibleBanner>
|
||||
)}
|
||||
|
||||
<SettingsSection title="App Info">
|
||||
<SettingRow title="Version" description="Current Yaak version.">
|
||||
<SettingValue value={appInfo.version} />
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
title="Data Directory"
|
||||
description="Where Yaak stores application data."
|
||||
controlClassName="min-w-0 max-w-[min(42rem,55vw)] gap-2"
|
||||
>
|
||||
<SettingValue
|
||||
value={appInfo.appDataDir}
|
||||
actions={[
|
||||
{
|
||||
title: revealInFinderText,
|
||||
icon: "folder_open",
|
||||
onClick: () => revealItemInDir(appInfo.appDataDir),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
title="Logs Directory"
|
||||
description="Where Yaak writes application logs."
|
||||
controlClassName="min-w-0 max-w-[min(42rem,55vw)] gap-2"
|
||||
>
|
||||
<SettingValue
|
||||
value={appInfo.appLogDir}
|
||||
actions={[
|
||||
{
|
||||
title: revealInFinderText,
|
||||
icon: "folder_open",
|
||||
onClick: () => revealItemInDir(appInfo.appLogDir),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingsSection>
|
||||
</SettingsList>
|
||||
</VStack>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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,59 +1,30 @@
|
||||
import { type } from "@tauri-apps/plugin-os";
|
||||
import { useFonts } from "@yaakapp-internal/fonts";
|
||||
import { useLicense } from "@yaakapp-internal/license";
|
||||
import type { EditorKeymap, Settings } from "@yaakapp-internal/models";
|
||||
import type { Settings } from "@yaakapp-internal/models";
|
||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
||||
import { clamp, Heading, VStack } from "@yaakapp-internal/ui";
|
||||
import { SettingsInterface as SharedSettingsInterface, VStack } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useState } from "react";
|
||||
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
|
||||
import { useSettingsView } from "../../hooks/useSettingsView";
|
||||
import { showConfirm } from "../../lib/confirm";
|
||||
import { pricingUrl } from "../../lib/pricingUrl";
|
||||
import { invokeCmd } from "../../lib/tauri";
|
||||
import { CargoFeature } from "../CargoFeature";
|
||||
import { Button } from "../core/Button";
|
||||
import { Checkbox } from "../core/Checkbox";
|
||||
import { Link } from "../core/Link";
|
||||
import {
|
||||
ModelSettingRowBoolean,
|
||||
ModelSettingRowSelect,
|
||||
SettingRow,
|
||||
SettingRowBoolean,
|
||||
SettingRowSelect,
|
||||
SettingSelectControl,
|
||||
SettingsList,
|
||||
SettingsSection,
|
||||
} from "../core/SettingRow";
|
||||
|
||||
const NULL_FONT_VALUE = "__NULL_FONT__";
|
||||
|
||||
const fontSizeOptions = [
|
||||
8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30,
|
||||
].map((n) => ({ label: `${n}`, value: `${n}` }));
|
||||
|
||||
const keymaps: { value: EditorKeymap; label: string }[] = [
|
||||
{ value: "default", label: "Default" },
|
||||
{ value: "vim", label: "Vim" },
|
||||
{ value: "vscode", label: "VSCode" },
|
||||
{ value: "emacs", label: "Emacs" },
|
||||
];
|
||||
import { SettingRowBoolean, SettingRowSelect, SettingsSection } from "../core/SettingRow";
|
||||
|
||||
export function SettingsInterface() {
|
||||
const workspace = useAtomValue(activeWorkspaceAtom);
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const fonts = useFonts();
|
||||
const bound = useSettingsView();
|
||||
|
||||
if (settings == null || workspace == null) {
|
||||
if (settings == null || workspace == null || bound == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack space={1.5} className="mb-4">
|
||||
<div className="mb-3">
|
||||
<Heading>Interface</Heading>
|
||||
<p className="text-text-subtle">Tweak settings related to the user interface.</p>
|
||||
</div>
|
||||
<SettingsList className="space-y-8">
|
||||
<SharedSettingsInterface
|
||||
view={bound.view}
|
||||
callbacks={bound.callbacks}
|
||||
leadingSections={
|
||||
<SettingsSection title="Workspaces">
|
||||
<SettingRowSelect
|
||||
title="Open workspace behavior"
|
||||
@@ -78,151 +49,13 @@ export function SettingsInterface() {
|
||||
]}
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Fonts">
|
||||
<SettingRow
|
||||
title="Interface font"
|
||||
description="Font used for Yaak interface controls."
|
||||
controlClassName="gap-1"
|
||||
>
|
||||
{fonts.data && (
|
||||
<SettingSelectControl
|
||||
name="uiFont"
|
||||
label="Interface font"
|
||||
selectClassName="w-72!"
|
||||
value={settings.interfaceFont ?? NULL_FONT_VALUE}
|
||||
defaultValue={NULL_FONT_VALUE}
|
||||
options={[
|
||||
{ label: "System default", value: NULL_FONT_VALUE },
|
||||
...fonts.data.uiFonts.map((f) => ({ label: f, value: f })),
|
||||
...fonts.data.editorFonts.map((f) => ({ label: f, value: f })),
|
||||
]}
|
||||
onChange={async (v) => {
|
||||
const interfaceFont = v === NULL_FONT_VALUE ? null : v;
|
||||
await patchModel(settings, { interfaceFont });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<SettingSelectControl
|
||||
name="interfaceFontSize"
|
||||
label="Interface Font Size"
|
||||
selectClassName="w-20!"
|
||||
value={`${settings.interfaceFontSize}`}
|
||||
defaultValue="14"
|
||||
options={fontSizeOptions}
|
||||
onChange={(v) => patchModel(settings, { interfaceFontSize: Number.parseInt(v, 10) })}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
title="Editor font"
|
||||
description="Font used in request and response editors."
|
||||
controlClassName="gap-1"
|
||||
>
|
||||
{fonts.data && (
|
||||
<SettingSelectControl
|
||||
name="editorFont"
|
||||
label="Editor font"
|
||||
selectClassName="w-72!"
|
||||
value={settings.editorFont ?? NULL_FONT_VALUE}
|
||||
defaultValue={NULL_FONT_VALUE}
|
||||
options={[
|
||||
{ label: "System default", value: NULL_FONT_VALUE },
|
||||
...fonts.data.editorFonts.map((f) => ({ label: f, value: f })),
|
||||
]}
|
||||
onChange={async (v) => {
|
||||
const editorFont = v === NULL_FONT_VALUE ? null : v;
|
||||
await patchModel(settings, { editorFont });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<SettingSelectControl
|
||||
name="editorFontSize"
|
||||
label="Editor Font Size"
|
||||
selectClassName="w-20!"
|
||||
value={`${settings.editorFontSize}`}
|
||||
defaultValue="12"
|
||||
options={fontSizeOptions}
|
||||
onChange={(v) =>
|
||||
patchModel(settings, {
|
||||
editorFontSize: clamp(Number.parseInt(v, 10) || 14, 8, 30),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Editor">
|
||||
<ModelSettingRowSelect
|
||||
model={settings}
|
||||
modelKey="editorKeymap"
|
||||
title="Editor keymap"
|
||||
description="Keyboard shortcut preset used by text editors."
|
||||
options={keymaps}
|
||||
/>
|
||||
<ModelSettingRowBoolean
|
||||
model={settings}
|
||||
modelKey="editorSoftWrap"
|
||||
title="Wrap editor lines"
|
||||
description="Wrap long lines in request and response editors."
|
||||
/>
|
||||
<ModelSettingRowBoolean
|
||||
model={settings}
|
||||
modelKey="coloredMethods"
|
||||
title="Colorize request methods"
|
||||
description="Use method-specific colors for HTTP request methods."
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Window">
|
||||
<NativeTitlebarSetting settings={settings} />
|
||||
{type() !== "macos" && (
|
||||
<ModelSettingRowBoolean
|
||||
model={settings}
|
||||
modelKey="hideWindowControls"
|
||||
title="Hide window controls"
|
||||
description="Hide the close, maximize, and minimize controls on Windows or Linux."
|
||||
/>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
}
|
||||
trailingSections={
|
||||
<CargoFeature feature="license">
|
||||
<LicenseSettings settings={settings} />
|
||||
</CargoFeature>
|
||||
</SettingsList>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function NativeTitlebarSetting({ settings }: { settings: Settings }) {
|
||||
const [nativeTitlebar, setNativeTitlebar] = useState(settings.useNativeTitlebar);
|
||||
|
||||
return (
|
||||
<SettingRow
|
||||
title="Native title bar"
|
||||
description="Use the operating system's standard title bar and window controls."
|
||||
controlClassName="gap-2"
|
||||
>
|
||||
<Checkbox
|
||||
hideLabel
|
||||
size="md"
|
||||
checked={nativeTitlebar}
|
||||
title="Native title bar"
|
||||
onChange={setNativeTitlebar}
|
||||
/>
|
||||
{settings.useNativeTitlebar !== nativeTitlebar && (
|
||||
<Button
|
||||
color="primary"
|
||||
size="xs"
|
||||
onClick={async () => {
|
||||
await patchModel(settings, { useNativeTitlebar: nativeTitlebar });
|
||||
await invokeCmd("cmd_restart");
|
||||
}}
|
||||
>
|
||||
Apply and Restart
|
||||
</Button>
|
||||
)}
|
||||
</SettingRow>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,6 @@
|
||||
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";
|
||||
import { formatDate } from "date-fns/format";
|
||||
import { useState } from "react";
|
||||
import { useToggle } from "../../hooks/useToggle";
|
||||
import { pluralizeCount } from "../../lib/pluralize";
|
||||
import { pricingUrl } from "../../lib/pricingUrl";
|
||||
import { SettingsLicense as SharedSettingsLicense } from "@yaakapp-internal/ui";
|
||||
import { useSettingsView } from "../../hooks/useSettingsView";
|
||||
import { CargoFeature } from "../CargoFeature";
|
||||
import { Button } from "../core/Button";
|
||||
import { Link } from "../core/Link";
|
||||
import { PlainInput } from "../core/PlainInput";
|
||||
import { Separator } from "../core/Separator";
|
||||
|
||||
export function SettingsLicense() {
|
||||
return (
|
||||
@@ -22,166 +11,7 @@ export function SettingsLicense() {
|
||||
}
|
||||
|
||||
function SettingsLicenseCmp() {
|
||||
const { check, activate, deactivate } = useLicense();
|
||||
const [key, setKey] = useState<string>("");
|
||||
const [activateFormVisible, toggleActivateFormVisible] = useToggle(false);
|
||||
|
||||
if (check.isPending) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderBanner = () => {
|
||||
if (!check.data) return null;
|
||||
|
||||
switch (check.data.status) {
|
||||
case "active":
|
||||
return <Banner color="success">Your license is active 🥳</Banner>;
|
||||
|
||||
case "trialing":
|
||||
return (
|
||||
<Banner color="info" className="max-w-lg">
|
||||
<p className="w-full">
|
||||
<strong>
|
||||
{pluralizeCount("day", differenceInDays(check.data.data.end, new Date()))}
|
||||
</strong>{" "}
|
||||
left to evaluate Yaak for commercial use.
|
||||
<br />
|
||||
<span className="opacity-50">Personal use is always free, forever.</span>
|
||||
<Separator className="my-2" />
|
||||
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
|
||||
<Link noUnderline href={pricingUrl(`app.license.learn.${check.data.status}`)}>
|
||||
Learn More
|
||||
</Link>
|
||||
</div>
|
||||
</p>
|
||||
</Banner>
|
||||
);
|
||||
|
||||
case "personal_use":
|
||||
return (
|
||||
<Banner color="notice" className="max-w-lg">
|
||||
<p className="w-full">
|
||||
Your commercial-use trial has ended.
|
||||
<br />
|
||||
<span className="opacity-50">
|
||||
You may continue using Yaak for personal use only.
|
||||
<br />A license is required for commercial use.
|
||||
</span>
|
||||
<Separator className="my-2" />
|
||||
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
|
||||
<Link noUnderline href={pricingUrl(`app.license.learn.${check.data.status}`)}>
|
||||
Learn More
|
||||
</Link>
|
||||
</div>
|
||||
</p>
|
||||
</Banner>
|
||||
);
|
||||
|
||||
case "inactive":
|
||||
return (
|
||||
<Banner color="danger">
|
||||
Your license is invalid. Please <Link href="https://yaak.app/dashboard">Sign In</Link>{" "}
|
||||
for more details
|
||||
</Banner>
|
||||
);
|
||||
|
||||
case "expired":
|
||||
return (
|
||||
<Banner color="notice">
|
||||
Your license expired{" "}
|
||||
<strong>{formatDate(check.data.data.periodEnd, "MMMM dd, yyyy")}</strong>. Please{" "}
|
||||
<Link href="https://yaak.app/dashboard">Resubscribe</Link> to continue receiving
|
||||
updates.
|
||||
{check.data.data.changesUrl && (
|
||||
<>
|
||||
<br />
|
||||
<Link href={check.data.data.changesUrl}>What's new in latest builds</Link>
|
||||
</>
|
||||
)}
|
||||
</Banner>
|
||||
);
|
||||
|
||||
case "past_due":
|
||||
return (
|
||||
<Banner color="danger">
|
||||
<strong>Your payment method needs attention.</strong>
|
||||
<br />
|
||||
To re-activate your license, please{" "}
|
||||
<Link href={check.data.data.billingUrl}>update your billing info</Link>.
|
||||
</Banner>
|
||||
);
|
||||
|
||||
case "error":
|
||||
return (
|
||||
<Banner color="danger">
|
||||
License check failed: {check.data.data.message} (Code: {check.data.data.code})
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 max-w-xl">
|
||||
{renderBanner()}
|
||||
|
||||
{check.error && <Banner color="danger">{check.error}</Banner>}
|
||||
{activate.error && <Banner color="danger">{activate.error}</Banner>}
|
||||
|
||||
{check.data?.status === "active" ? (
|
||||
<HStack space={2}>
|
||||
<Button variant="border" color="secondary" size="sm" onClick={() => deactivate.mutate()}>
|
||||
Deactivate License
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={() => openUrl("https://yaak.app/dashboard?intent=app.license.support")}
|
||||
rightSlot={<Icon icon="external_link" />}
|
||||
>
|
||||
Direct Support
|
||||
</Button>
|
||||
</HStack>
|
||||
) : (
|
||||
<HStack space={2}>
|
||||
<Button variant="border" color="secondary" size="sm" onClick={toggleActivateFormVisible}>
|
||||
Activate License
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color="primary"
|
||||
rightSlot={<Icon icon="external_link" />}
|
||||
onClick={() =>
|
||||
openUrl(pricingUrl(`app.license.purchase.${check.data?.status ?? "unknown"}`))
|
||||
}
|
||||
>
|
||||
Purchase License
|
||||
</Button>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{activateFormVisible && (
|
||||
<VStack
|
||||
as="form"
|
||||
space={3}
|
||||
className="max-w-sm"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
await activate.mutateAsync({ licenseKey: key });
|
||||
toggleActivateFormVisible();
|
||||
}}
|
||||
>
|
||||
<PlainInput
|
||||
autoFocus
|
||||
label="License Key"
|
||||
name="key"
|
||||
onChange={setKey}
|
||||
placeholder="YK1-XXXXX-XXXXX-XXXXX-XXXXX"
|
||||
/>
|
||||
<Button type="submit" color="primary" size="sm" isLoading={activate.isPending}>
|
||||
Submit
|
||||
</Button>
|
||||
</VStack>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
const bound = useSettingsView();
|
||||
if (bound == null) return null;
|
||||
return <SharedSettingsLicense view={bound.view} callbacks={bound.callbacks} />;
|
||||
}
|
||||
@@ -1,15 +1,10 @@
|
||||
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";
|
||||
import { checkPluginUpdates, installPlugin, searchPlugins } from "@yaakapp-internal/plugins";
|
||||
import {
|
||||
checkPluginUpdates,
|
||||
installPlugin,
|
||||
searchPlugins,
|
||||
uninstallPlugin,
|
||||
} from "@yaakapp-internal/plugins";
|
||||
import {
|
||||
Banner,
|
||||
HStack,
|
||||
Icon,
|
||||
InlineCode,
|
||||
@@ -21,24 +16,24 @@ import {
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
useDebouncedValue,
|
||||
CountBadge,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useState } from "react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { useInstallPlugin } from "../../hooks/useInstallPlugin";
|
||||
import { usePluginInfo } from "../../hooks/usePluginInfo";
|
||||
import { usePluginInfo, usePluginInfos } from "../../hooks/usePluginInfo";
|
||||
import { usePluginsKey, useRefreshPlugins } from "../../hooks/usePlugins";
|
||||
import { showConfirmDelete } from "../../lib/confirm";
|
||||
import { minPromiseMillis } from "../../lib/minPromiseMillis";
|
||||
import { usePromptUninstallPlugin } from "../../hooks/useUninstallPlugin";
|
||||
import { Button } from "../core/Button";
|
||||
import { Checkbox } from "../core/Checkbox";
|
||||
import { CountBadge } from "../core/CountBadge";
|
||||
import { IconButton } from "../core/IconButton";
|
||||
import { Link } from "../core/Link";
|
||||
import { PlainInput } from "../core/PlainInput";
|
||||
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 +108,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>
|
||||
@@ -205,7 +200,7 @@ function PluginTableRow({
|
||||
mutationKey: ["install_plugin", name],
|
||||
mutationFn: (name: string) => installPlugin(name, null),
|
||||
});
|
||||
const uninstall = usePromptUninstall(plugin?.id ?? null, displayName);
|
||||
const uninstall = usePromptUninstallPlugin(plugin?.id ?? null, displayName);
|
||||
const refreshPlugins = useRefreshPlugins();
|
||||
|
||||
return (
|
||||
@@ -296,7 +291,10 @@ function PluginSearch() {
|
||||
const debouncedQuery = useDebouncedValue(query);
|
||||
const results = useQuery({
|
||||
queryKey: ["plugins", debouncedQuery],
|
||||
queryFn: () => searchPlugins(query),
|
||||
queryFn: async () => {
|
||||
const { plugins } = await searchPlugins(query);
|
||||
return { plugins: plugins.filter((p) => p.deprecated == null) };
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -349,22 +347,77 @@ function InstalledPlugins({ plugins, className }: { plugins: Plugin[]; className
|
||||
</EmptyStateText>
|
||||
</div>
|
||||
) : (
|
||||
<Table scrollable className={className}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell className="w-0" />
|
||||
<TableHeaderCell>Display Name</TableHeaderCell>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>Version</TableHeaderCell>
|
||||
<TableHeaderCell />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<tbody className="divide-y divide-surface-highlight">
|
||||
{plugins.map((p) => (
|
||||
<PluginTableRowForInstalledPlugin key={p.id} plugin={p} />
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
<div className={classNames(className, "h-full grid grid-rows-[auto_minmax(0,1fr)]")}>
|
||||
<DeprecatedPluginBanners />
|
||||
<Table scrollable>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell className="w-0" />
|
||||
<TableHeaderCell>Display Name</TableHeaderCell>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>Version</TableHeaderCell>
|
||||
<TableHeaderCell />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<tbody className="divide-y divide-surface-highlight">
|
||||
{plugins.map((p) => (
|
||||
<PluginTableRowForInstalledPlugin key={p.id} plugin={p} />
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeprecatedPluginBanners() {
|
||||
const deprecated = usePluginInfos().filter(
|
||||
({ plugin, info }) => plugin.source !== "bundled" && info.deprecated != null,
|
||||
);
|
||||
return (
|
||||
<div className={classNames("flex flex-col gap-2", deprecated.length > 0 && "pb-3")}>
|
||||
{deprecated.map(({ plugin, info }) => (
|
||||
<DeprecatedPluginBanner key={plugin.id} plugin={plugin} name={info.displayName}>
|
||||
{withInlineCode(info.deprecated ?? "")}
|
||||
</DeprecatedPluginBanner>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Backtick spans in a package.json string, set as code. */
|
||||
function withInlineCode(text: string): ReactNode {
|
||||
return text
|
||||
.split("`")
|
||||
.map((part, i) => (i % 2 === 1 ? <InlineCode key={i}>{part}</InlineCode> : part));
|
||||
}
|
||||
|
||||
export function DeprecatedPluginBanner({
|
||||
children,
|
||||
name,
|
||||
plugin,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
name: string;
|
||||
plugin: Plugin;
|
||||
}) {
|
||||
const uninstall = usePromptUninstallPlugin(plugin.id, name);
|
||||
return (
|
||||
<Banner color="info" className="flex items-center gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text text-text">The {name} plugin is deprecated.</div>
|
||||
<p className="mt-1 text-sm text-text-subtle">{children}</p>
|
||||
</div>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="border"
|
||||
title="Uninstall plugin"
|
||||
className="shrink-0"
|
||||
isLoading={uninstall?.isPending}
|
||||
onClick={() => uninstall?.mutate()}
|
||||
>
|
||||
Uninstall
|
||||
</Button>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -393,31 +446,6 @@ function BundledPlugins({ plugins }: { plugins: Plugin[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
function usePromptUninstall(pluginId: string | null, name: string) {
|
||||
const mut = useMutation({
|
||||
mutationKey: ["uninstall_plugin", pluginId],
|
||||
mutationFn: async () => {
|
||||
if (pluginId == null) return;
|
||||
|
||||
const confirmed = await showConfirmDelete({
|
||||
id: `uninstall-plugin-${pluginId}`,
|
||||
title: "Uninstall Plugin",
|
||||
confirmText: "Uninstall",
|
||||
description: (
|
||||
<>
|
||||
Permanently uninstall <InlineCode>{name}</InlineCode>?
|
||||
</>
|
||||
),
|
||||
});
|
||||
if (confirmed) {
|
||||
await minPromiseMillis(uninstallPlugin(pluginId), 700);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return pluginId == null ? null : mut;
|
||||
}
|
||||
|
||||
function usePluginUpdates() {
|
||||
return useQuery({
|
||||
queryKey: ["plugin_updates", usePluginsKey()],
|
||||
|
||||
@@ -1,173 +1,15 @@
|
||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
||||
import { Heading, HStack, Icon, type IconProps, VStack } from "@yaakapp-internal/ui";
|
||||
import { SettingsTheme as SharedSettingsTheme } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { lazy, Suspense } from "react";
|
||||
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
|
||||
import { useResolvedAppearance } from "../../hooks/useResolvedAppearance";
|
||||
import { useResolvedTheme } from "../../hooks/useResolvedTheme";
|
||||
import type { ButtonProps } from "../core/Button";
|
||||
import { IconButton } from "../core/IconButton";
|
||||
import { Link } from "../core/Link";
|
||||
import type { SelectProps } from "../core/Select";
|
||||
import {
|
||||
ModelSettingRowSelect,
|
||||
SettingRowSelect,
|
||||
SettingsList,
|
||||
SettingsSection,
|
||||
} from "../core/SettingRow";
|
||||
|
||||
const Editor = lazy(() => import("../core/Editor/Editor").then((m) => ({ default: m.Editor })));
|
||||
|
||||
const buttonColors: ButtonProps["color"][] = [
|
||||
"primary",
|
||||
"info",
|
||||
"success",
|
||||
"notice",
|
||||
"warning",
|
||||
"danger",
|
||||
"secondary",
|
||||
"default",
|
||||
];
|
||||
|
||||
const icons: IconProps["icon"][] = [
|
||||
"info",
|
||||
"box",
|
||||
"update",
|
||||
"alert_triangle",
|
||||
"arrow_big_right_dash",
|
||||
"download",
|
||||
"copy",
|
||||
"magic_wand",
|
||||
"settings",
|
||||
"trash",
|
||||
"sparkles",
|
||||
"pencil",
|
||||
"paste",
|
||||
"search",
|
||||
"send_horizontal",
|
||||
];
|
||||
import { useSettingsView } from "../../hooks/useSettingsView";
|
||||
|
||||
export function SettingsTheme() {
|
||||
const workspace = useAtomValue(activeWorkspaceAtom);
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const appearance = useResolvedAppearance();
|
||||
const activeTheme = useResolvedTheme();
|
||||
const bound = useSettingsView();
|
||||
|
||||
if (settings == null || workspace == null || activeTheme.data == null) {
|
||||
if (workspace == null || bound == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lightThemes: SelectProps<string>["options"] = activeTheme.data.themes
|
||||
.filter((theme) => !theme.dark)
|
||||
.map((theme) => ({
|
||||
label: theme.label,
|
||||
value: theme.id,
|
||||
}));
|
||||
|
||||
const darkThemes: SelectProps<string>["options"] = activeTheme.data.themes
|
||||
.filter((theme) => theme.dark)
|
||||
.map((theme) => ({
|
||||
label: theme.label,
|
||||
value: theme.id,
|
||||
}));
|
||||
|
||||
return (
|
||||
<VStack space={1.5} className="mb-4">
|
||||
<div className="mb-3">
|
||||
<Heading>Theme</Heading>
|
||||
<p className="text-text-subtle">
|
||||
Make Yaak your own by selecting a theme, or{" "}
|
||||
<Link href="https://yaak.app/docs/plugin-development/plugins-quick-start">
|
||||
Create Your Own
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<SettingsList className="space-y-8">
|
||||
<SettingsSection title="Theme">
|
||||
<ModelSettingRowSelect
|
||||
model={settings}
|
||||
modelKey="appearance"
|
||||
title="Appearance"
|
||||
description="Choose whether Yaak follows your system appearance or uses a fixed mode."
|
||||
options={[
|
||||
{ label: "Automatic", value: "system" },
|
||||
{ label: "Light", value: "light" },
|
||||
{ label: "Dark", value: "dark" },
|
||||
]}
|
||||
/>
|
||||
{(settings.appearance === "system" || settings.appearance === "light") && (
|
||||
<SettingRowSelect
|
||||
name="lightTheme"
|
||||
title="Light theme"
|
||||
description="Theme used when Yaak is in light mode."
|
||||
value={activeTheme.data.light.id}
|
||||
options={lightThemes}
|
||||
onChange={(themeLight) => patchModel(settings, { themeLight })}
|
||||
/>
|
||||
)}
|
||||
{(settings.appearance === "system" || settings.appearance === "dark") && (
|
||||
<SettingRowSelect
|
||||
name="darkTheme"
|
||||
title="Dark theme"
|
||||
description="Theme used when Yaak is in dark mode."
|
||||
value={activeTheme.data.dark.id}
|
||||
options={darkThemes}
|
||||
onChange={(themeDark) => patchModel(settings, { themeDark })}
|
||||
/>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Preview">
|
||||
<VStack
|
||||
space={3}
|
||||
className="mt-4 w-full bg-surface p-3 border border-dashed border-border-subtle rounded-sm overflow-x-auto"
|
||||
>
|
||||
<HStack className="text" space={1.5}>
|
||||
<Icon icon={appearance === "dark" ? "moon" : "sun"} />
|
||||
<strong>{activeTheme.data.active.label}</strong>
|
||||
<em>(preview)</em>
|
||||
</HStack>
|
||||
<HStack space={1.5} className="w-full">
|
||||
{buttonColors.map((c, i) => (
|
||||
<IconButton
|
||||
key={c}
|
||||
color={c}
|
||||
size="2xs"
|
||||
iconSize="xs"
|
||||
icon={icons[i % icons.length] ?? "info"}
|
||||
iconClassName="text"
|
||||
title={`${c}`}
|
||||
/>
|
||||
))}
|
||||
{buttonColors.map((c, i) => (
|
||||
<IconButton
|
||||
key={c}
|
||||
color={c}
|
||||
variant="border"
|
||||
size="2xs"
|
||||
iconSize="xs"
|
||||
icon={icons[i % icons.length] ?? "info"}
|
||||
iconClassName="text"
|
||||
title={`${c}`}
|
||||
/>
|
||||
))}
|
||||
</HStack>
|
||||
<Suspense>
|
||||
<Editor
|
||||
defaultValue={[
|
||||
"let foo = { // Demo code editor",
|
||||
' foo: ("bar" || "baz" ?? \'qux\'),',
|
||||
" baz: [1, 10.2, null, false, true],",
|
||||
"};",
|
||||
].join("\n")}
|
||||
heightMode="auto"
|
||||
language="javascript"
|
||||
stateKey={null}
|
||||
/>
|
||||
</Suspense>
|
||||
</VStack>
|
||||
</SettingsSection>
|
||||
</SettingsList>
|
||||
</VStack>
|
||||
);
|
||||
return <SharedSettingsTheme view={bound.view} callbacks={bound.callbacks} />;
|
||||
}
|
||||
@@ -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,25 @@ 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,
|
||||
hidden: appInfo.cliVersion != null || !platform.capabilities.agents,
|
||||
leftSlot: <Icon icon="square_terminal" />,
|
||||
rightSlot: <Icon icon="external_link" color="secondary" />,
|
||||
onSelect: () => openUrl("https://yaak.app/docs/cli"),
|
||||
onSelect: () => openSettings.mutate("agents"),
|
||||
},
|
||||
{
|
||||
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}`),
|
||||
},
|
||||
]}
|
||||
>
|
||||
|
||||
@@ -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";
|
||||
@@ -62,13 +63,10 @@ import { isSidebarFocused } from "../lib/scopes";
|
||||
import { navigateToRequestOrFolderOrWorkspace } from "../lib/setWorkspaceSearchParams";
|
||||
import type { ContextMenuProps, DropdownItem } from "./core/Dropdown";
|
||||
import { ContextMenu, Dropdown } from "./core/Dropdown";
|
||||
import type { FieldDef } from "./core/Editor/filter/extension";
|
||||
import { filter } from "./core/Editor/filter/extension";
|
||||
import type { Ast } from "./core/Editor/filter/query";
|
||||
import { evaluate, parseQuery } from "./core/Editor/filter/query";
|
||||
import { formatFieldFilter } from "./core/Editor/filter/format";
|
||||
import type { Node as QueryNode, Schema } from "@yaakapp-internal/query";
|
||||
import { formatField, parseQuery, QueryMatcher } from "@yaakapp-internal/query";
|
||||
import { queryFilter } from "@yaakapp-internal/ui/editor";
|
||||
import { HttpMethodTag } from "./core/HttpMethodTag";
|
||||
import { HttpStatusTag } from "./core/HttpStatusTag";
|
||||
import {
|
||||
Icon,
|
||||
LoadingIcon,
|
||||
@@ -76,6 +74,7 @@ import {
|
||||
isSelectedFamily,
|
||||
selectedIdsFamily,
|
||||
InlineCode,
|
||||
HttpStatusTag,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import type { TreeNode, TreeHandle, TreeProps, TreeItemProps } from "@yaakapp-internal/ui";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
@@ -111,7 +110,8 @@ function Sidebar({ className }: { className?: string }) {
|
||||
const activeWorkspaceId = useAtomValue(activeWorkspaceAtom)?.id;
|
||||
const treeId = `tree.${activeWorkspaceId ?? "unknown"}`;
|
||||
const filterText = useAtomValue(sidebarFilterAtom);
|
||||
const [tree, allFields, emptyFilterSuggestions] = useAtomValue(sidebarTreeAtom) ?? [];
|
||||
const [tree, fieldValues, emptyFilterSuggestions] = useAtomValue(sidebarTreeAtom) ?? [];
|
||||
|
||||
const wrapperRef = useRef<HTMLElement>(null);
|
||||
const treeRef = useRef<TreeHandle>(null);
|
||||
const filterRef = useRef<InputHandle>(null);
|
||||
@@ -130,11 +130,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(() => {
|
||||
@@ -545,18 +551,18 @@ function Sidebar({ className }: { className?: string }) {
|
||||
const filterCompartmentMountExtRef = useRef<Extension | null>(null);
|
||||
if (filterCompartmentMountExtRef.current == null) {
|
||||
filterCompartmentMountExtRef.current = filterLanguageCompartmentRef.current.of(
|
||||
filter({ fields: allFields ?? [] }),
|
||||
queryFilter({ schema: sidebarFilterSchema, values: fieldValues }),
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const view = filterRef.current;
|
||||
if (!view) return;
|
||||
const ext = filter({ fields: allFields ?? [] });
|
||||
const ext = queryFilter({ schema: sidebarFilterSchema, values: fieldValues });
|
||||
view.dispatch({
|
||||
effects: filterLanguageCompartmentRef.current.reconfigure(ext),
|
||||
});
|
||||
}, [allFields]);
|
||||
}, [fieldValues]);
|
||||
|
||||
if (tree == null || hidden) {
|
||||
return null;
|
||||
@@ -724,7 +730,11 @@ function Sidebar({ className }: { className?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default Sidebar;
|
||||
// Memoized so route navigations (which re-render the workspace layout) don't
|
||||
// re-render the sidebar subtree. In large workspaces a sidebar re-render is
|
||||
// very expensive: it re-renders DndContext, whose context churn re-renders
|
||||
// every visible TreeItem regardless of their memo comparators.
|
||||
export default memo(Sidebar);
|
||||
|
||||
function getGitContextMenuItems({
|
||||
items,
|
||||
@@ -840,16 +850,44 @@ function setSidebarFilterText(text: string) {
|
||||
jotaiStore.set(sidebarFilterAtom, { text, key: `${Math.random()}` });
|
||||
}
|
||||
|
||||
function getSidebarSuggestionValue(ast: Ast | null) {
|
||||
/**
|
||||
* The fields `getItemFields` fills in, each with what it is and a term using it, as completion
|
||||
* shows them. Bare words match `getItemText` by word prefix
|
||||
*/
|
||||
const sidebarFilterFields: [name: string, info: string, example: string][] = [
|
||||
["method", "GET, POST, …", "@method:post"],
|
||||
["url", "Request URL", "@url:*/users*"],
|
||||
["name", "Request name", '@name:"Get user"'],
|
||||
["type", "http, grpc or ws", "@type:grpc"],
|
||||
["folder", "Parent folder", "@folder:auth"],
|
||||
["grpc_service", "gRPC service", "@grpc_service:*Greeter"],
|
||||
["grpc_method", "gRPC method", "@grpc_method:SayHello"],
|
||||
];
|
||||
const sidebarFilterColumns = sidebarFilterFields.map(([name]) => name);
|
||||
const sidebarFilterSchema: Schema = {
|
||||
fields: sidebarFilterFields.map(([name, info, example]) => ({
|
||||
name,
|
||||
kind: { type: "text" },
|
||||
info,
|
||||
example,
|
||||
})),
|
||||
freeText: "fuzzy",
|
||||
};
|
||||
|
||||
function getSidebarSuggestionValue(ast: QueryNode | null, query: string) {
|
||||
if (ast == null) return null;
|
||||
|
||||
if (ast.type === "Term" || ast.type === "Phrase") {
|
||||
const value = ast.value.trim();
|
||||
const value = ast.text.trim();
|
||||
return value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
if (ast.type === "Field") {
|
||||
const value = ast.value.trim();
|
||||
const raw =
|
||||
ast.value.type === "text"
|
||||
? ast.value.text
|
||||
: query.slice(ast.valueSpan.start, ast.valueSpan.end);
|
||||
const value = raw.trim();
|
||||
return value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
@@ -871,7 +909,7 @@ const sidebarSuggestionFieldOrder = [
|
||||
];
|
||||
|
||||
const sidebarTreeAtom = atom<
|
||||
[TreeNode<SidebarModel>, FieldDef[], SidebarFilterSuggestion[]] | null
|
||||
[TreeNode<SidebarModel>, Record<string, string[]>, SidebarFilterSuggestion[]] | null
|
||||
>((get) => {
|
||||
const allModels = get(memoAllPotentialChildrenAtom);
|
||||
const activeWorkspace = get(activeWorkspaceAtom);
|
||||
@@ -892,15 +930,18 @@ const sidebarTreeAtom = atom<
|
||||
return null;
|
||||
}
|
||||
|
||||
const queryAst = parseQuery(filter.text);
|
||||
const suggestionValue = getSidebarSuggestionValue(queryAst);
|
||||
const suggestionValue = getSidebarSuggestionValue(parseQuery(filter.text), filter.text);
|
||||
const matcher = new QueryMatcher(filter.text, sidebarFilterSchema);
|
||||
|
||||
// Every request goes into one flat table (its text, then a cell per column), matched in a
|
||||
// single call after the tree is built
|
||||
const leaves: TreeNode<SidebarModel>[] = [];
|
||||
const cells: (string | null)[] = [];
|
||||
|
||||
// returns true if this node OR any child matches the filter
|
||||
const allFields: Record<string, Set<string>> = {};
|
||||
const suggestionFields = new Set<string>();
|
||||
const build = (node: TreeNode<SidebarModel>, depth: number): boolean => {
|
||||
const build = (node: TreeNode<SidebarModel>, depth: number) => {
|
||||
const childItems = childrenMap[node.item.id] ?? [];
|
||||
let matchesSelf = true;
|
||||
const fields = getItemFields(node);
|
||||
const model = node.item.model;
|
||||
const isLeafNode = !(model === "folder" || model === "workspace");
|
||||
@@ -918,38 +959,44 @@ const sidebarTreeAtom = atom<
|
||||
}
|
||||
}
|
||||
|
||||
if (queryAst != null) {
|
||||
matchesSelf = isLeafNode && evaluate(queryAst, { text: getItemText(node.item), fields });
|
||||
if (isLeafNode && !matcher.isEmpty) {
|
||||
leaves.push(node);
|
||||
cells.push(getItemText(node.item));
|
||||
for (const column of sidebarFilterColumns) cells.push(fields[column] ?? null);
|
||||
}
|
||||
|
||||
let matchesChild = false;
|
||||
|
||||
// Recurse to children
|
||||
node.children = !isLeafNode ? [] : undefined;
|
||||
|
||||
if (node.children != null) {
|
||||
childItems.sort((a, b) => {
|
||||
if (a.sortPriority === b.sortPriority) {
|
||||
return a.updatedAt > b.updatedAt ? 1 : -1;
|
||||
if (a.sortPriority !== b.sortPriority) {
|
||||
return a.sortPriority - b.sortPriority;
|
||||
}
|
||||
return a.sortPriority - b.sortPriority;
|
||||
// Keep equal-priority items in a stable order when they are edited.
|
||||
if (a.createdAt !== b.createdAt) {
|
||||
return a.createdAt > b.createdAt ? 1 : -1;
|
||||
}
|
||||
return a.id === b.id ? 0 : a.id > b.id ? 1 : -1;
|
||||
});
|
||||
|
||||
for (const item of childItems) {
|
||||
const childNode = { item, parent: node, depth };
|
||||
const childMatches = build(childNode, depth + 1);
|
||||
if (childMatches) {
|
||||
matchesChild = true;
|
||||
}
|
||||
build(childNode, depth + 1);
|
||||
node.children.push(childNode);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// hide node IFF nothing in its subtree matches
|
||||
const anyMatch = matchesSelf || matchesChild;
|
||||
node.hidden = !anyMatch;
|
||||
|
||||
return anyMatch;
|
||||
// Hide a node IFF nothing in its subtree matches; with no query, nothing is hidden
|
||||
let matched: Set<TreeNode<SidebarModel>> | null = null;
|
||||
const markHidden = (node: TreeNode<SidebarModel>): boolean => {
|
||||
let visible = matched == null || matched.has(node);
|
||||
for (const child of node.children ?? []) {
|
||||
if (markHidden(child)) visible = true;
|
||||
}
|
||||
node.hidden = !visible;
|
||||
return visible;
|
||||
};
|
||||
|
||||
const root: TreeNode<SidebarModel> = {
|
||||
@@ -959,15 +1006,20 @@ const sidebarTreeAtom = atom<
|
||||
depth: 0,
|
||||
};
|
||||
|
||||
// Build tree and mark visibility in one pass
|
||||
build(root, 1);
|
||||
if (!matcher.isEmpty) {
|
||||
const rows = matcher.matchingRows(sidebarFilterColumns, cells);
|
||||
matched = new Set(Array.from(rows, (i) => leaves[i]!));
|
||||
}
|
||||
matcher.free();
|
||||
markHidden(root);
|
||||
|
||||
const fields: FieldDef[] = [];
|
||||
// Values offered after `@field:`. URLs and names are left out: nearly every one is unique,
|
||||
// so a list of them is noise rather than a shortcut.
|
||||
const fieldValues: Record<string, string[]> = {};
|
||||
for (const [name, values] of Object.entries(allFields)) {
|
||||
fields.push({
|
||||
name,
|
||||
values: Array.from(values).filter((v) => v.length < 20),
|
||||
});
|
||||
if (name === "url" || name === "name") continue;
|
||||
fieldValues[name] = Array.from(values);
|
||||
}
|
||||
const suggestions = Array.from(suggestionFields)
|
||||
.sort((a, b) => {
|
||||
@@ -978,9 +1030,9 @@ const sidebarTreeAtom = atom<
|
||||
})
|
||||
.map((field) => ({
|
||||
field,
|
||||
filterText: formatFieldFilter(field, suggestionValue ?? ""),
|
||||
filterText: formatField(field, suggestionValue ?? ""),
|
||||
}));
|
||||
return [root, fields, suggestions] as const;
|
||||
return [root, fieldValues, suggestions] as const;
|
||||
});
|
||||
|
||||
const sidebarGitStatusByModelIdAtom = atom<Record<string, GitStatus>>((get) => {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { Workspace } from "@yaakapp-internal/models";
|
||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
||||
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import { Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useState } from "react";
|
||||
import { switchWorkspace } from "../commands/switchWorkspace";
|
||||
import { Button } from "./core/Button";
|
||||
import { Checkbox } from "./core/Checkbox";
|
||||
import { DialogFooter } from "./core/Dialog";
|
||||
|
||||
interface Props {
|
||||
hide: () => void;
|
||||
@@ -16,45 +16,33 @@ export function SwitchWorkspaceDialog({ hide, workspace }: Props) {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const [remember, setRemember] = useState<boolean>(false);
|
||||
|
||||
const open = async (inNewWindow: boolean) => {
|
||||
hide();
|
||||
switchWorkspace.mutate({ workspaceId: workspace.id, inNewWindow });
|
||||
if (remember) {
|
||||
await patchModel(settings, { openWorkspaceNewWindow: inNewWindow });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack space={3}>
|
||||
<p>
|
||||
Where would you like to open <InlineCode>{workspace.name}</InlineCode>?
|
||||
</p>
|
||||
<HStack space={2} justifyContent="start" className="flex-row-reverse">
|
||||
<Button
|
||||
className="focus"
|
||||
color="primary"
|
||||
onClick={async () => {
|
||||
hide();
|
||||
switchWorkspace.mutate({ workspaceId: workspace.id, inNewWindow: false });
|
||||
if (remember) {
|
||||
await patchModel(settings, { openWorkspaceNewWindow: false });
|
||||
}
|
||||
}}
|
||||
>
|
||||
This Window
|
||||
</Button>
|
||||
<Button
|
||||
className="focus"
|
||||
color="secondary"
|
||||
rightSlot={<Icon icon="external_link" />}
|
||||
onClick={async () => {
|
||||
hide();
|
||||
switchWorkspace.mutate({ workspaceId: workspace.id, inNewWindow: true });
|
||||
if (remember) {
|
||||
await patchModel(settings, { openWorkspaceNewWindow: true });
|
||||
}
|
||||
}}
|
||||
>
|
||||
New Window
|
||||
</Button>
|
||||
</HStack>
|
||||
{settings && (
|
||||
<HStack justifyContent="end">
|
||||
<Checkbox checked={remember} title="Remember my choice" onChange={setRemember} />
|
||||
</HStack>
|
||||
<Checkbox checked={remember} title="Remember my choice" onChange={setRemember} />
|
||||
)}
|
||||
<DialogFooter
|
||||
inline
|
||||
actions={[
|
||||
{
|
||||
label: "New Window",
|
||||
rightSlot: <Icon icon="external_link" />,
|
||||
onClick: () => open(true),
|
||||
},
|
||||
{ label: "This Window", color: "primary", autoFocus: true, onClick: () => open(false) },
|
||||
]}
|
||||
/>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import type { Workspace } from "@yaakapp-internal/models";
|
||||
import type { TemplateFunction } from "@yaakapp-internal/plugins";
|
||||
import type { Tokens } from "@yaakapp-internal/templates";
|
||||
import { act, type ReactNode } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeAll, beforeEach, expect, test, vi } from "vite-plus/test";
|
||||
import { TemplateFunctionDialog } from "./TemplateFunctionDialog";
|
||||
|
||||
interface FormProps {
|
||||
data: Record<string, string | boolean>;
|
||||
onChange: (values: Record<string, string | boolean>) => void;
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
parse: vi.fn<(text: string) => Tokens>(),
|
||||
print: vi.fn<(tokens: Tokens) => Promise<string>>(),
|
||||
serialize: vi.fn<(tokens: Tokens) => { data: string }>(),
|
||||
preview: vi.fn<({ template }: { template: string }) => { data: string }>(),
|
||||
form: null as FormProps | null,
|
||||
config: {
|
||||
name: "timestamp.format",
|
||||
args: [
|
||||
{ type: "text", name: "date" },
|
||||
{ type: "text", name: "format" },
|
||||
],
|
||||
} as TemplateFunction,
|
||||
}));
|
||||
|
||||
vi.mock("@yaakapp-internal/templates", () => ({ parseTemplate: mocks.parse }));
|
||||
vi.mock("@yaakapp-internal/ui", () => ({
|
||||
HStack: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
InlineCode: "code",
|
||||
LoadingIcon: () => null,
|
||||
useDebouncedValue: (value: string) => value,
|
||||
}));
|
||||
vi.mock("../hooks/useActiveWorkspace", () => ({ activeWorkspaceAtom: null }));
|
||||
vi.mock("../hooks/useRenderTemplate", () => ({ useRenderTemplate: mocks.preview }));
|
||||
vi.mock("../hooks/useTemplateFunctionConfig", () => ({
|
||||
useTemplateFunctionConfig: () => ({ data: mocks.config }),
|
||||
}));
|
||||
vi.mock("../hooks/useTemplateTokensToString", () => ({
|
||||
templateTokensToString: mocks.print,
|
||||
useTemplateTokensToString: mocks.serialize,
|
||||
}));
|
||||
vi.mock("../lib/dialog", () => ({ showDialog: vi.fn() }));
|
||||
vi.mock("../lib/encryption", () => ({ convertTemplateToInsecure: vi.fn() }));
|
||||
vi.mock("../lib/jotai", () => ({ jotaiStore: { get: vi.fn() } }));
|
||||
vi.mock("../lib/setupOrConfigureEncryption", () => ({ setupOrConfigureEncryption: vi.fn() }));
|
||||
vi.mock("./core/Dialog", () => ({ DialogFooter: () => <button type="submit">Save</button> }));
|
||||
vi.mock("./core/IconButton", () => ({ IconButton: () => <button type="button" /> }));
|
||||
vi.mock("./core/PlainInput", () => ({ PlainInput: () => null }));
|
||||
vi.mock("./DynamicForm", () => ({
|
||||
DYNAMIC_FORM_NULL_ARG: "__NULL__",
|
||||
DynamicForm: (props: FormProps) => {
|
||||
mocks.form = props;
|
||||
return <input aria-label="Timestamp" readOnly value={String(props.data.date ?? "")} />;
|
||||
},
|
||||
}));
|
||||
|
||||
beforeAll(async () => {
|
||||
const base = pathToFileURL(
|
||||
resolve(import.meta.dirname, "../../../crates/yaak-templates/pkg") + "/",
|
||||
);
|
||||
const bindings = await import(new URL("yaak_templates_bg.js", base).href);
|
||||
const { instance } = await WebAssembly.instantiate(
|
||||
await readFile(new URL("yaak_templates_bg.wasm", base)),
|
||||
{ "./yaak_templates_bg.js": bindings },
|
||||
);
|
||||
bindings.__wbg_set_wasm(instance.exports);
|
||||
(instance.exports.__wbindgen_start as () => void)();
|
||||
mocks.parse.mockImplementation(bindings.parse_template);
|
||||
});
|
||||
|
||||
let root: Root;
|
||||
let host: HTMLDivElement;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
mocks.form = null;
|
||||
// The transport returns an opaque string. Carry the AST as JSON so the assertions can
|
||||
// inspect what Preview and Save receive, without duplicating the backend's printer.
|
||||
mocks.serialize.mockImplementation((tokens) => ({ data: JSON.stringify(tokens) }));
|
||||
mocks.preview.mockImplementation(({ template }) => ({ data: template }));
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function functionValue(tokens: Tokens) {
|
||||
const first = tokens.tokens[0];
|
||||
if (first?.type !== "tag" || first.val.type !== "fn") throw new Error("Expected function");
|
||||
return first.val;
|
||||
}
|
||||
|
||||
test.each([
|
||||
["timestamp.offset(expression='-1h')", "${[ timestamp.offset(expression='-1h') ]}"],
|
||||
["date_var", "${[ date_var ]}"],
|
||||
["dates.previous", "${[ dates.previous ]}"],
|
||||
["'${[ date_var ]}'", "${[ date_var ]}"],
|
||||
])("Preview and Save preserve %s until that input changes", async (expression, display) => {
|
||||
const initialTokens = mocks.parse(`\${[ timestamp.format(date=${expression}, format='HH:mm') ]}`);
|
||||
const originalDate = functionValue(initialTokens).args[0];
|
||||
const saved = vi.fn<(value: string) => void>();
|
||||
mocks.print.mockResolvedValue(display);
|
||||
|
||||
await act(async () =>
|
||||
root.render(
|
||||
<TemplateFunctionDialog
|
||||
templateFunction={mocks.config}
|
||||
initialTokens={initialTokens}
|
||||
model={{ id: "wk_test" } as Workspace}
|
||||
onChange={saved}
|
||||
hide={() => {}}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
expect(host.querySelector<HTMLInputElement>("input")?.value).toBe(display);
|
||||
|
||||
async function submit() {
|
||||
const form = host.querySelector("form");
|
||||
if (form == null) throw new Error("Dialog did not initialize");
|
||||
await act(async () => {
|
||||
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
const value = saved.mock.calls.at(-1)?.[0];
|
||||
expect(value).toBe(mocks.preview.mock.calls.at(-1)?.[0].template);
|
||||
return functionValue(JSON.parse(value ?? "null") as Tokens).args;
|
||||
}
|
||||
|
||||
expect((await submit())[0]).toEqual(originalDate);
|
||||
await act(async () => mocks.form?.onChange({ ...mocks.form.data, format: "yyyy" }));
|
||||
expect(await submit()).toEqual([
|
||||
originalDate,
|
||||
{ name: "format", value: { type: "str", text: "yyyy" } },
|
||||
]);
|
||||
|
||||
const edited = "${[ timestamp.offset(expression='-2h') ]}";
|
||||
await act(async () => mocks.form?.onChange({ ...mocks.form.data, date: edited }));
|
||||
expect((await submit())[0]).toEqual({ name: "date", value: { type: "str", text: edited } });
|
||||
await act(async () => mocks.form?.onChange({ ...mocks.form.data, date: "" }));
|
||||
expect((await submit())[0]).toEqual({ name: "date", value: { type: "str", text: "" } });
|
||||
});
|
||||
@@ -11,7 +11,7 @@ import type { FnArg, Tokens } from "@yaakapp-internal/templates";
|
||||
import { parseTemplate } from "@yaakapp-internal/templates";
|
||||
import { HStack, InlineCode, LoadingIcon, useDebouncedValue } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useId, useMemo, useState } from "react";
|
||||
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
|
||||
import { useRenderTemplate } from "../hooks/useRenderTemplate";
|
||||
import { useTemplateFunctionConfig } from "../hooks/useTemplateFunctionConfig";
|
||||
@@ -24,8 +24,11 @@ import { showDialog } from "../lib/dialog";
|
||||
import { convertTemplateToInsecure } from "../lib/encryption";
|
||||
import { jotaiStore } from "../lib/jotai";
|
||||
import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
|
||||
import { Button } from "./core/Button";
|
||||
import { collectArgumentValues } from "./core/Editor/twig/util";
|
||||
import {
|
||||
collectEditableArgumentValues,
|
||||
preserveUnchangedArgumentExpressions,
|
||||
} from "../lib/templateFunctionArguments";
|
||||
import { DialogFooter } from "./core/Dialog";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import { DYNAMIC_FORM_NULL_ARG, DynamicForm } from "./DynamicForm";
|
||||
@@ -48,7 +51,11 @@ export function TemplateFunctionDialog({ initialTokens, templateFunction, ...pro
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const initial = collectArgumentValues(initialTokens, templateFunction);
|
||||
const initial = await collectEditableArgumentValues(
|
||||
initialTokens,
|
||||
templateFunction,
|
||||
templateTokensToString,
|
||||
);
|
||||
|
||||
// HACK: Replace the secure() function's encrypted `value` arg with the decrypted version so
|
||||
// we can display it in the editor input.
|
||||
@@ -74,6 +81,7 @@ export function TemplateFunctionDialog({ initialTokens, templateFunction, ...pro
|
||||
<InitializedTemplateFunctionDialog
|
||||
{...props}
|
||||
templateFunction={templateFunction}
|
||||
initialTokens={initialTokens}
|
||||
initialArgValues={initialArgValues}
|
||||
/>
|
||||
);
|
||||
@@ -82,15 +90,17 @@ export function TemplateFunctionDialog({ initialTokens, templateFunction, ...pro
|
||||
function InitializedTemplateFunctionDialog({
|
||||
templateFunction: { name, previewType: ogPreviewType },
|
||||
initialArgValues,
|
||||
initialTokens,
|
||||
hide,
|
||||
onChange,
|
||||
model,
|
||||
}: Omit<Props, "initialTokens"> & {
|
||||
}: Props & {
|
||||
initialArgValues: Record<string, string | boolean>;
|
||||
}) {
|
||||
const previewType = ogPreviewType == null ? "live" : ogPreviewType;
|
||||
const [showSecretsInPreview, toggleShowSecretsInPreview] = useToggle(false);
|
||||
const [argValues, setArgValues] = useState<Record<string, string | boolean>>(initialArgValues);
|
||||
const formId = useId();
|
||||
|
||||
const tokens: Tokens = useMemo(() => {
|
||||
const argTokens: FnArg[] = Object.keys(argValues).map((name) => ({
|
||||
@@ -110,12 +120,17 @@ function InitializedTemplateFunctionDialog({
|
||||
val: {
|
||||
type: "fn",
|
||||
name,
|
||||
args: argTokens,
|
||||
args: preserveUnchangedArgumentExpressions(
|
||||
argTokens,
|
||||
initialTokens,
|
||||
initialArgValues,
|
||||
argValues,
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [argValues, name]);
|
||||
}, [argValues, initialArgValues, initialTokens, name]);
|
||||
|
||||
const tagText = useTemplateTokensToString(tokens);
|
||||
const templateFunction = useTemplateFunctionConfig(name, argValues, model);
|
||||
@@ -160,13 +175,14 @@ function InitializedTemplateFunctionDialog({
|
||||
|
||||
return (
|
||||
<form
|
||||
className="grid grid-rows-[minmax(0,1fr)_auto_auto] h-full max-h-[90vh]"
|
||||
id={formId}
|
||||
className="grid grid-rows-[minmax(0,1fr)_auto] h-full"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleDone();
|
||||
}}
|
||||
>
|
||||
<div className="overflow-y-auto h-full px-6">
|
||||
<div className="overflow-y-auto h-full">
|
||||
{name === "secure" ? (
|
||||
<PlainInput
|
||||
required
|
||||
@@ -188,75 +204,71 @@ function InitializedTemplateFunctionDialog({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-6 border-t border-t-border pt-3 pb-6 bg-surface-highlight w-full flex flex-col gap-4">
|
||||
{previewType !== "none" ? (
|
||||
<div className="w-full grid grid-cols-1 grid-rows-[auto_auto]">
|
||||
<HStack space={0.5}>
|
||||
<HStack className="text-sm text-text-subtle" space={1.5}>
|
||||
Rendered Preview
|
||||
{rendered.isLoading && <LoadingIcon size="xs" />}
|
||||
</HStack>
|
||||
{previewType !== "none" ? (
|
||||
<div className="w-full grid grid-cols-1 grid-rows-[auto_auto] border-t border-t-border pt-3">
|
||||
<HStack space={0.5}>
|
||||
<HStack className="text-sm text-text-subtle" space={1.5}>
|
||||
Rendered Preview
|
||||
{rendered.isLoading && <LoadingIcon size="xs" />}
|
||||
</HStack>
|
||||
<IconButton
|
||||
size="xs"
|
||||
iconSize="sm"
|
||||
icon={showSecretsInPreview ? "lock" : "lock_open"}
|
||||
title={showSecretsInPreview ? "Show preview" : "Hide preview"}
|
||||
onClick={toggleShowSecretsInPreview}
|
||||
className={classNames(
|
||||
"ml-auto text-text-subtlest",
|
||||
!dataContainsSecrets && "invisible",
|
||||
)}
|
||||
/>
|
||||
</HStack>
|
||||
<div className="relative w-full max-h-40">
|
||||
<InlineCode
|
||||
className={classNames(
|
||||
"block whitespace-pre-wrap select-text! cursor-text max-h-40 overflow-auto hide-scrollbars border-text-subtlest!",
|
||||
tooLarge && "italic text-danger",
|
||||
)}
|
||||
>
|
||||
{rendered.error || tagText.error ? (
|
||||
<em className="text-danger">
|
||||
{`${rendered.error || tagText.error}`.replace(/^Render Error: /, "")}
|
||||
</em>
|
||||
) : dataContainsSecrets && !showSecretsInPreview ? (
|
||||
<span className="italic text-text-subtle">
|
||||
------ sensitive values hidden ------
|
||||
</span>
|
||||
) : tooLarge ? (
|
||||
"too large to preview"
|
||||
) : (
|
||||
rendered.data || <> </>
|
||||
)}
|
||||
</InlineCode>
|
||||
<div className="absolute right-0.5 top-0 bottom-0 flex items-center">
|
||||
<IconButton
|
||||
size="xs"
|
||||
iconSize="sm"
|
||||
icon={showSecretsInPreview ? "lock" : "lock_open"}
|
||||
title={showSecretsInPreview ? "Show preview" : "Hide preview"}
|
||||
onClick={toggleShowSecretsInPreview}
|
||||
className={classNames(
|
||||
"ml-auto text-text-subtlest",
|
||||
!dataContainsSecrets && "invisible",
|
||||
)}
|
||||
icon="refresh"
|
||||
className="text-text-subtle"
|
||||
title="Refresh preview"
|
||||
spin={rendered.isPending}
|
||||
onClick={() => {
|
||||
setRenderKey(new Date().toISOString());
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
<div className="relative w-full max-h-40">
|
||||
<InlineCode
|
||||
className={classNames(
|
||||
"block whitespace-pre-wrap select-text! cursor-text max-h-40 overflow-auto hide-scrollbars border-text-subtlest!",
|
||||
tooLarge && "italic text-danger",
|
||||
)}
|
||||
>
|
||||
{rendered.error || tagText.error ? (
|
||||
<em className="text-danger">
|
||||
{`${rendered.error || tagText.error}`.replace(/^Render Error: /, "")}
|
||||
</em>
|
||||
) : dataContainsSecrets && !showSecretsInPreview ? (
|
||||
<span className="italic text-text-subtle">
|
||||
------ sensitive values hidden ------
|
||||
</span>
|
||||
) : tooLarge ? (
|
||||
"too large to preview"
|
||||
) : (
|
||||
rendered.data || <> </>
|
||||
)}
|
||||
</InlineCode>
|
||||
<div className="absolute right-0.5 top-0 bottom-0 flex items-center">
|
||||
<IconButton
|
||||
size="xs"
|
||||
icon="refresh"
|
||||
className="text-text-subtle"
|
||||
title="Refresh preview"
|
||||
spin={rendered.isPending}
|
||||
onClick={() => {
|
||||
setRenderKey(new Date().toISOString());
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<div className="flex justify-stretch w-full grow gap-2 *:flex-1">
|
||||
{templateFunction.data.name === "secure" && (
|
||||
<Button variant="border" color="secondary" onClick={setupOrConfigureEncryption}>
|
||||
Reveal Encryption Key
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" color="primary">
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<DialogFooter
|
||||
actions={[
|
||||
...(templateFunction.data.name === "secure"
|
||||
? [{ label: "Reveal Encryption Key", onClick: setupOrConfigureEncryption }]
|
||||
: []),
|
||||
{ label: "Save", color: "primary", form: formId },
|
||||
]}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -272,7 +284,6 @@ TemplateFunctionDialog.show = (
|
||||
id: `template-function-${Math.random()}`, // Allow multiple at once
|
||||
size: "md",
|
||||
className: "h-240",
|
||||
noPadding: true,
|
||||
title: <InlineCode>{fn.name}(…)</InlineCode>,
|
||||
description: fn.description,
|
||||
render: ({ hide }) => {
|
||||
|
||||
@@ -1,43 +1,6 @@
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AnimatePresence } from "motion/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { hideToast, toastsAtom } from "../lib/toast";
|
||||
import { Toast, type ToastProps } from "./core/Toast";
|
||||
import { ErrorBoundary } from "./ErrorBoundary";
|
||||
import { Portal } from "@yaakapp-internal/ui";
|
||||
import { Toasts as BaseToasts } from "@yaakapp-internal/ui";
|
||||
import { showErrorDialog } from "./ErrorBoundary";
|
||||
|
||||
export type ToastInstance = {
|
||||
id: string;
|
||||
uniqueKey: string;
|
||||
message: ReactNode;
|
||||
timeout: 3000 | 5000 | 8000 | (number & {}) | null;
|
||||
onClose?: ToastProps["onClose"];
|
||||
} & Omit<ToastProps, "onClose" | "open" | "children" | "timeout">;
|
||||
export type { ToastInstance } from "@yaakapp-internal/ui";
|
||||
|
||||
export const Toasts = () => {
|
||||
const toasts = useAtomValue(toastsAtom);
|
||||
return (
|
||||
<Portal name="toasts">
|
||||
<div className="absolute right-0 bottom-0 z-50">
|
||||
<AnimatePresence>
|
||||
{toasts.map((toast: ToastInstance) => {
|
||||
const { message, uniqueKey, ...props } = toast;
|
||||
return (
|
||||
<ErrorBoundary key={uniqueKey} name={`Toast ${uniqueKey}`}>
|
||||
<Toast
|
||||
open
|
||||
{...props}
|
||||
// We call onClose inside actions.hide instead of passing to toast so that
|
||||
// it gets called from external close calls as well
|
||||
onClose={() => hideToast(toast)}
|
||||
>
|
||||
{message}
|
||||
</Toast>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
export const Toasts = () => <BaseToasts onShowError={showErrorDialog} />;
|
||||
@@ -1,113 +1,14 @@
|
||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||
import type { IconProps } from "@yaakapp-internal/ui";
|
||||
import { HStack } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import type { FormEvent, ReactNode } from "react";
|
||||
import { memo, useCallback, useRef, useState } from "react";
|
||||
import { useHotKey } from "../hooks/useHotKey";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import type { InputHandle, InputProps } from "./core/Input";
|
||||
import { Input } from "./core/Input";
|
||||
import { UrlBar as BaseUrlBar, type UrlBarProps } from "@yaakapp-internal/ui";
|
||||
import { memo } from "react";
|
||||
|
||||
type Props = Pick<HttpRequest, "url"> & {
|
||||
className?: string;
|
||||
placeholder: string;
|
||||
type Props = Omit<UrlBarProps, "sendHotkeyAction" | "focusHotkeyAction" | "readOnly"> & {
|
||||
onSend: () => void;
|
||||
onUrlChange: (url: string) => void;
|
||||
onPaste?: (v: string) => void;
|
||||
onPasteOverwrite?: InputProps["onPasteOverwrite"];
|
||||
onCancel: () => void;
|
||||
submitIcon?: IconProps["icon"] | null;
|
||||
isLoading: boolean;
|
||||
forceUpdateKey: string;
|
||||
rightSlot?: ReactNode;
|
||||
leftSlot?: ReactNode;
|
||||
autocomplete?: InputProps["autocomplete"];
|
||||
stateKey: InputProps["stateKey"];
|
||||
};
|
||||
|
||||
export const UrlBar = memo(function UrlBar({
|
||||
forceUpdateKey,
|
||||
onUrlChange,
|
||||
url,
|
||||
placeholder,
|
||||
className,
|
||||
onSend,
|
||||
onCancel,
|
||||
onPaste,
|
||||
onPasteOverwrite,
|
||||
submitIcon = "send_horizontal",
|
||||
autocomplete,
|
||||
leftSlot,
|
||||
rightSlot,
|
||||
isLoading,
|
||||
stateKey,
|
||||
}: Props) {
|
||||
const inputRef = useRef<InputHandle>(null);
|
||||
const [isFocused, setIsFocused] = useState<boolean>(false);
|
||||
|
||||
const handleInitInputRef = useCallback((h: InputHandle | null) => {
|
||||
inputRef.current = h;
|
||||
}, []);
|
||||
|
||||
useHotKey("url_bar.focus", () => {
|
||||
inputRef.current?.selectAll();
|
||||
});
|
||||
|
||||
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (isLoading) onCancel();
|
||||
else onSend();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className={classNames("x-theme-urlBar", className)}>
|
||||
<Input
|
||||
setRef={handleInitInputRef}
|
||||
autocompleteFunctions
|
||||
autocompleteVariables
|
||||
stateKey={stateKey}
|
||||
size="sm"
|
||||
wrapLines={isFocused}
|
||||
hideLabel
|
||||
language="url"
|
||||
className="px-1.5 py-0.5"
|
||||
label="Enter URL"
|
||||
name="url"
|
||||
autocomplete={autocomplete}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
onPaste={onPaste}
|
||||
onPasteOverwrite={onPasteOverwrite}
|
||||
onChange={onUrlChange}
|
||||
defaultValue={url}
|
||||
placeholder={placeholder}
|
||||
leftSlot={leftSlot}
|
||||
rightSlot={
|
||||
<HStack space={0.5}>
|
||||
{rightSlot && <div className="py-0.5 h-full">{rightSlot}</div>}
|
||||
{submitIcon !== null && (
|
||||
<div className="py-0.5 h-full">
|
||||
<IconButton
|
||||
size="xs"
|
||||
iconSize="md"
|
||||
title="Send Request"
|
||||
type="submit"
|
||||
className="w-8 mr-0.5 h-full!"
|
||||
iconColor="secondary"
|
||||
icon={isLoading ? "x" : submitIcon}
|
||||
hotkeyAction="request.send"
|
||||
onMouseDown={(e) => {
|
||||
// Prevent the button from taking focus
|
||||
e.preventDefault();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
/** The shared URL bar, sending and focusing on the client's hotkeys */
|
||||
export const UrlBar = memo(function UrlBar(props: Props) {
|
||||
return <BaseUrlBar {...props} sendHotkeyAction="request.send" focusHotkeyAction="url_bar.focus" />;
|
||||
});
|
||||
@@ -1,17 +1,20 @@
|
||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||
import { VStack } from "@yaakapp-internal/ui";
|
||||
import {
|
||||
type EditablePair,
|
||||
type PairEditorHandle,
|
||||
type PairEditorProps,
|
||||
UrlParametersEditor as BaseUrlParametersEditor,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
|
||||
import type { PairEditorHandle, PairEditorProps } from "./core/PairEditor";
|
||||
import { PairOrBulkEditor } from "./core/PairOrBulkEditor";
|
||||
|
||||
type Props = {
|
||||
forceUpdateKey: string;
|
||||
pairs: HttpRequest["headers"];
|
||||
pairs: EditablePair[];
|
||||
stateKey: PairEditorProps["stateKey"];
|
||||
onChange: (headers: HttpRequest["urlParameters"]) => void;
|
||||
onChange: PairEditorProps["onChange"];
|
||||
};
|
||||
|
||||
/** The shared parameters editor, wired to the request editor's focus events */
|
||||
export function UrlParametersEditor({ pairs, forceUpdateKey, onChange, stateKey }: Props) {
|
||||
const pairEditorRef = useRef<PairEditorHandle>(null);
|
||||
const handleInitPairEditorRef = useCallback((ref: PairEditorHandle) => {
|
||||
@@ -34,22 +37,12 @@ export function UrlParametersEditor({ pairs, forceUpdateKey, onChange, stateKey
|
||||
);
|
||||
|
||||
return (
|
||||
<VStack className="h-full">
|
||||
<PairOrBulkEditor
|
||||
setRef={handleInitPairEditorRef}
|
||||
allowMultilineValues
|
||||
forceUpdateKey={forceUpdateKey + urlParametersKey}
|
||||
nameAutocompleteFunctions
|
||||
nameAutocompleteVariables
|
||||
namePlaceholder="param_name"
|
||||
onChange={onChange}
|
||||
pairs={pairs}
|
||||
preferenceName="url_parameters"
|
||||
stateKey={stateKey}
|
||||
valueAutocompleteFunctions
|
||||
valueAutocompleteVariables
|
||||
valuePlaceholder="Value"
|
||||
/>
|
||||
</VStack>
|
||||
<BaseUrlParametersEditor
|
||||
setRef={handleInitPairEditorRef}
|
||||
forceUpdateKey={forceUpdateKey + urlParametersKey}
|
||||
onChange={onChange}
|
||||
pairs={pairs}
|
||||
stateKey={stateKey}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { WebsocketRequest } from "@yaakapp-internal/models";
|
||||
import { patchModel } from "@yaakapp-internal/models";
|
||||
import {
|
||||
flushAllModelWrites,
|
||||
getModel,
|
||||
patchModel,
|
||||
patchModelDebounced,
|
||||
} from "@yaakapp-internal/models";
|
||||
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
|
||||
import { closeWebsocket, connectWebsocket, sendWebsocket } from "@yaakapp-internal/ws";
|
||||
import classNames from "classnames";
|
||||
@@ -8,8 +13,7 @@ import type { CSSProperties } from "react";
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { getActiveCookieJar } from "../hooks/useActiveCookieJar";
|
||||
import { getActiveEnvironment } from "../hooks/useActiveEnvironment";
|
||||
import { activeRequestIdAtom } from "../hooks/useActiveRequestId";
|
||||
import { allRequestsAtom } from "../hooks/useAllRequests";
|
||||
import { allRequestUrlsAtom } from "../hooks/useAllRequests";
|
||||
import { useAuthTab } from "../hooks/useAuthTab";
|
||||
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
|
||||
import { useHeadersTab } from "../hooks/useHeadersTab";
|
||||
@@ -18,17 +22,13 @@ import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
|
||||
import { activeWebsocketConnectionAtom } from "../hooks/usePinnedWebsocketConnection";
|
||||
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
|
||||
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
|
||||
import { deepEqualAtom } from "../lib/atoms";
|
||||
import { languageFromContentType } from "../lib/contentType";
|
||||
import { generateId } from "../lib/generateId";
|
||||
import { extractPathPlaceholders } from "../lib/pathPlaceholders";
|
||||
import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
|
||||
import { prepareImportQuerystring } from "../lib/prepareImportQuerystring";
|
||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
||||
import { CountBadge } from "./core/CountBadge";
|
||||
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
||||
import { getUrlCompletionConfig } from "./core/Editor/url/completion";
|
||||
import { Editor } from "./core/Editor/LazyEditor";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import type { Pair } from "./core/PairEditor";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
|
||||
import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs";
|
||||
@@ -38,6 +38,7 @@ import { MarkdownEditor } from "./MarkdownEditor";
|
||||
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
|
||||
import { UrlBar } from "./UrlBar";
|
||||
import { UrlParametersEditor } from "./UrlParameterEditor";
|
||||
import { languageFromContentType, CountBadge } from "@yaakapp-internal/ui";
|
||||
|
||||
interface Props {
|
||||
style: CSSProperties;
|
||||
@@ -54,15 +55,12 @@ const TAB_SETTINGS = "settings";
|
||||
const TAB_DESCRIPTION = "description";
|
||||
const TABS_STORAGE_KEY = "websocket_request_tabs";
|
||||
|
||||
const nonActiveRequestUrlsAtom = atom((get) => {
|
||||
const activeRequestId = get(activeRequestIdAtom);
|
||||
const requests = get(allRequestsAtom);
|
||||
return requests
|
||||
.filter((r) => r.id !== activeRequestId)
|
||||
.map((r): GenericCompletionOption => ({ type: "constant", label: r.url }));
|
||||
});
|
||||
|
||||
const memoNotActiveRequestUrlsAtom = deepEqualAtom(nonActiveRequestUrlsAtom);
|
||||
// Derived from the identity-stable URL list so this only recomputes when a URL
|
||||
// actually changes. The active request's own URL is included, but exact matches
|
||||
// are filtered out at completion time by genericCompletion.
|
||||
const requestUrlOptionsAtom = atom((get): GenericCompletionOption[] =>
|
||||
get(allRequestUrlsAtom).map((url) => ({ type: "constant", label: url })),
|
||||
);
|
||||
|
||||
export function WebsocketRequestPane({ style, fullHeight, className, activeRequest }: Props) {
|
||||
const activeRequestId = activeRequest.id;
|
||||
@@ -83,20 +81,35 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
||||
[],
|
||||
);
|
||||
|
||||
const { urlParameterPairs, urlParametersKey } = useMemo(() => {
|
||||
const placeholderNames = extractPathPlaceholders(activeRequest.url);
|
||||
const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value);
|
||||
const items: Pair[] = [...nonEmptyParameters];
|
||||
for (const name of placeholderNames) {
|
||||
const item = items.find((p) => p.name === name);
|
||||
if (item) {
|
||||
item.readOnlyName = true;
|
||||
} else {
|
||||
items.push({ name, value: "", enabled: true, readOnlyName: true, id: generateId() });
|
||||
}
|
||||
}
|
||||
return { urlParameterPairs: items, urlParametersKey: placeholderNames.join(",") };
|
||||
}, [activeRequest.url, activeRequest.urlParameters]);
|
||||
// Renaming a path placeholder has to rewrite the URL and rename the parameter together, or the
|
||||
// value detaches from the placeholder.
|
||||
// NOTE: Reads the request fresh rather than closing over `activeRequest`. The row that calls this
|
||||
// holds onto it until the URL's placeholders change, so a captured request would go stale and
|
||||
// patch its parameter list back over newer edits.
|
||||
const handleRenamePathPlaceholder = useCallback(
|
||||
(oldName: string, newName: string) => {
|
||||
const request = getModel("websocket_request", activeRequestId);
|
||||
if (request == null) return false;
|
||||
|
||||
const patch = renamePathPlaceholder(request, oldName, newName);
|
||||
if (patch == null) return false; // Unusable name, so the editor reverts the field
|
||||
void patchModel(request, patch);
|
||||
return true;
|
||||
},
|
||||
[activeRequestId],
|
||||
);
|
||||
|
||||
const { urlParameterPairs, urlParametersKey } = useMemo(
|
||||
() =>
|
||||
derivePathPlaceholderPairs(
|
||||
activeRequest.url,
|
||||
activeRequest.urlParameters,
|
||||
handleRenamePathPlaceholder,
|
||||
),
|
||||
[activeRequest.url, activeRequest.urlParameters, handleRenamePathPlaceholder],
|
||||
);
|
||||
|
||||
const hasDescription = activeRequest.description.trim().length > 0;
|
||||
|
||||
const tabs = useMemo<TabItem[]>(() => {
|
||||
return [
|
||||
@@ -119,31 +132,24 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
||||
{
|
||||
value: TAB_DESCRIPTION,
|
||||
label: "Info",
|
||||
rightSlot: hasDescription && <CountBadge count={true} />,
|
||||
},
|
||||
];
|
||||
}, [authTab, headersTab, numSettingsOverrides, urlParameterPairs.length]);
|
||||
}, [authTab, hasDescription, headersTab, numSettingsOverrides, urlParameterPairs.length]);
|
||||
|
||||
const { activeResponse } = usePinnedHttpResponse(activeRequestId);
|
||||
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
|
||||
const connection = useAtomValue(activeWebsocketConnectionAtom);
|
||||
|
||||
const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom);
|
||||
const autocompleteUrls = useAtomValue(requestUrlOptionsAtom);
|
||||
|
||||
const autocomplete: GenericCompletionConfig = useMemo(
|
||||
() => ({
|
||||
minMatch: 3,
|
||||
options:
|
||||
autocompleteUrls.length > 0
|
||||
? autocompleteUrls
|
||||
: [
|
||||
{ label: "http://", type: "constant" },
|
||||
{ label: "https://", type: "constant" },
|
||||
],
|
||||
}),
|
||||
() => getUrlCompletionConfig(autocompleteUrls),
|
||||
[autocompleteUrls],
|
||||
);
|
||||
|
||||
const handleConnect = useCallback(async () => {
|
||||
await flushAllModelWrites(); // The backend reads the request from the DB
|
||||
await connectWebsocket({
|
||||
requestId: activeRequest.id,
|
||||
environmentId: getActiveEnvironment()?.id ?? null,
|
||||
@@ -153,6 +159,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (connection == null) return;
|
||||
await flushAllModelWrites(); // The backend reads the message from the DB
|
||||
await sendWebsocket({
|
||||
connectionId: connection?.id,
|
||||
environmentId: getActiveEnvironment()?.id ?? null,
|
||||
@@ -165,7 +172,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
||||
}, [connection]);
|
||||
|
||||
const handleUrlChange = useCallback(
|
||||
(url: string) => patchModel(activeRequest, { url }),
|
||||
(url: string) => patchModelDebounced(activeRequest, { url }),
|
||||
[activeRequest],
|
||||
);
|
||||
|
||||
@@ -249,7 +256,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
headers={activeRequest.headers}
|
||||
stateKey={`headers.${activeRequest.id}`}
|
||||
onChange={(headers) => patchModel(activeRequest, { headers })}
|
||||
onChange={(headers) => patchModelDebounced(activeRequest, { headers })}
|
||||
/>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_PARAMS}>
|
||||
@@ -257,7 +264,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
||||
stateKey={`params.${activeRequest.id}`}
|
||||
forceUpdateKey={forceUpdateKey + urlParametersKey}
|
||||
pairs={urlParameterPairs}
|
||||
onChange={(urlParameters) => patchModel(activeRequest, { urlParameters })}
|
||||
onChange={(urlParameters) => patchModelDebounced(activeRequest, { urlParameters })}
|
||||
/>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_MESSAGE}>
|
||||
@@ -269,7 +276,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
||||
heightMode={fullHeight ? "full" : "auto"}
|
||||
defaultValue={activeRequest.message}
|
||||
language={messageLanguage}
|
||||
onChange={(message) => patchModel(activeRequest, { message })}
|
||||
onChange={(message) => patchModelDebounced(activeRequest, { message })}
|
||||
stateKey={`json.${activeRequest.id}`}
|
||||
/>
|
||||
</TabContent>
|
||||
@@ -286,7 +293,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
||||
className="font-sans text-xl! px-0!"
|
||||
containerClassName="border-0"
|
||||
placeholder={resolvedModelName(activeRequest)}
|
||||
onChange={(name) => patchModel(activeRequest, { name })}
|
||||
onChange={(name) => patchModelDebounced(activeRequest, { name })}
|
||||
/>
|
||||
<MarkdownEditor
|
||||
name="request-description"
|
||||
@@ -294,7 +301,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
||||
defaultValue={activeRequest.description}
|
||||
stateKey={`description.${activeRequest.id}`}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
onChange={(description) => patchModel(activeRequest, { description })}
|
||||
onChange={(description) => patchModelDebounced(activeRequest, { description })}
|
||||
/>
|
||||
</div>
|
||||
</TabContent>
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { WebsocketEvent, WebsocketRequest } from "@yaakapp-internal/models";
|
||||
import { HStack, Icon, LoadingIcon, VStack } from "@yaakapp-internal/ui";
|
||||
import { hexy } from "hexy";
|
||||
import {
|
||||
HStack,
|
||||
hexdump,
|
||||
HexdumpViewer,
|
||||
Icon,
|
||||
LoadingIcon,
|
||||
VStack,
|
||||
useStateWithDeps,
|
||||
languageFromContentType,
|
||||
} from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useFormatText } from "../hooks/useFormatText";
|
||||
@@ -10,10 +18,9 @@ import {
|
||||
setPinnedWebsocketConnectionId,
|
||||
useWebsocketEvents,
|
||||
} from "../hooks/usePinnedWebsocketConnection";
|
||||
import { useStateWithDeps } from "../hooks/useStateWithDeps";
|
||||
import { languageFromContentType } from "../lib/contentType";
|
||||
import { Button } from "./core/Button";
|
||||
import { Editor } from "./core/Editor/LazyEditor";
|
||||
import { renderEditor } from "./core/Editor/renderEditor";
|
||||
import { type EventDetailAction, EventDetailHeader, EventViewer } from "./core/EventViewer";
|
||||
import { EventViewerRow } from "./core/EventViewerRow";
|
||||
import { HotkeyList } from "./core/HotkeyList";
|
||||
@@ -117,10 +124,10 @@ function WebsocketEventRow({
|
||||
messageType === "error"
|
||||
? "alert_triangle"
|
||||
: messageType === "close" || messageType === "open"
|
||||
? "info"
|
||||
: isServer
|
||||
? "arrow_big_down_dash"
|
||||
: "arrow_big_up_dash";
|
||||
? "info"
|
||||
: isServer
|
||||
? "arrow_big_down_dash"
|
||||
: "arrow_big_up_dash";
|
||||
|
||||
const content =
|
||||
messageType === "close" ? (
|
||||
@@ -167,7 +174,7 @@ function WebsocketEventDetail({
|
||||
}) {
|
||||
const message = useMemo(() => {
|
||||
if (hexDump) {
|
||||
return event.message ? hexy(event.message) : "";
|
||||
return event.message ? hexdump(event.message) : "";
|
||||
}
|
||||
return event.message ? new TextDecoder("utf-8").decode(Uint8Array.from(event.message)) : "";
|
||||
}, [event.message, hexDump]);
|
||||
@@ -227,6 +234,8 @@ function WebsocketEventDetail({
|
||||
</VStack>
|
||||
) : event.message.length === 0 ? (
|
||||
<EmptyStateText>No Content</EmptyStateText>
|
||||
) : hexDump ? (
|
||||
<HexdumpViewer data={event.message} renderEditor={renderEditor} />
|
||||
) : (
|
||||
<Editor
|
||||
language={language}
|
||||
|
||||
Loaded 100 of 1226 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user