mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-02-16 04:37:44 +01:00
Compare commits
12 Commits
v2025.6.1
...
v2025.7.0-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d46479cd22 | ||
|
|
19cae33382 | ||
|
|
267cd079ad | ||
|
|
19c1efc73e | ||
|
|
dfa9a22861 | ||
|
|
533f9bacc4 | ||
|
|
0358748729 | ||
|
|
1540d0a5a5 | ||
|
|
d177e164f1 | ||
|
|
f1355c9d15 | ||
|
|
485a9ea47c | ||
|
|
dbc606fb53 |
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -114,4 +114,4 @@ jobs:
|
||||
releaseBody: '[Changelog __VERSION__](https://yaak.app/blog/__VERSION__)'
|
||||
releaseDraft: true
|
||||
prerelease: true
|
||||
args: '${{ matrix.args }} --config ./src-tauri/tauri.commercial.conf.json'
|
||||
args: '${{ matrix.args }} --config ./src-tauri/tauri.release.conf.json'
|
||||
|
||||
@@ -42,7 +42,7 @@ Built with [Tauri](https://tauri.app), Rust, and React, it’s fast, lightweight
|
||||
|
||||
### 🔐 Stay secure
|
||||
- Use OAuth 2.0, JWT, Basic Auth, or custom plugins for authentication.
|
||||
- Secure sensitive values with end-to-end encryption.
|
||||
- Secure sensitive values with encrypted secrets.
|
||||
- Store secrets in your OS keychain.
|
||||
|
||||
### ☁️ Organize & collaborate
|
||||
@@ -58,7 +58,7 @@ Built with [Tauri](https://tauri.app), Rust, and React, it’s fast, lightweight
|
||||
|
||||
## Contribution Policy
|
||||
|
||||
Yaak is open source, but only accepting contributions for bug fixes. To get started,
|
||||
Yaak is open source but only accepting contributions for bug fixes. To get started,
|
||||
visit [`DEVELOPMENT.md`](DEVELOPMENT.md) for tips on setting up your environment.
|
||||
|
||||
## Useful Resources
|
||||
@@ -68,4 +68,3 @@ visit [`DEVELOPMENT.md`](DEVELOPMENT.md) for tips on setting up your environment
|
||||
- [Yaak vs Postman](https://yaak.app/alternatives/postman)
|
||||
- [Yaak vs Bruno](https://yaak.app/alternatives/bruno)
|
||||
- [Yaak vs Insomnia](https://yaak.app/alternatives/insomnia)
|
||||
|
||||
|
||||
90
package-lock.json
generated
90
package-lock.json
generated
@@ -8,16 +8,17 @@
|
||||
"name": "yaak-app",
|
||||
"version": "0.0.0",
|
||||
"workspaces": [
|
||||
"packages/common-lib",
|
||||
"packages/plugin-runtime",
|
||||
"packages/plugin-runtime-types",
|
||||
"packages/common-lib",
|
||||
"plugins/action-copy-curl",
|
||||
"plugins/action-copy-grpcurl",
|
||||
"plugins/auth-apikey",
|
||||
"plugins/auth-aws",
|
||||
"plugins/auth-basic",
|
||||
"plugins/auth-bearer",
|
||||
"plugins/auth-jwt",
|
||||
"plugins/auth-oauth2",
|
||||
"plugins/action-copy-curl",
|
||||
"plugins/action-copy-grpcurl",
|
||||
"plugins/filter-jsonpath",
|
||||
"plugins/filter-xpath",
|
||||
"plugins/importer-curl",
|
||||
@@ -26,7 +27,6 @@
|
||||
"plugins/importer-postman",
|
||||
"plugins/importer-yaak",
|
||||
"plugins/template-function-cookie",
|
||||
"plugins/template-function-timestamp",
|
||||
"plugins/template-function-encode",
|
||||
"plugins/template-function-fs",
|
||||
"plugins/template-function-hash",
|
||||
@@ -35,13 +35,14 @@
|
||||
"plugins/template-function-regex",
|
||||
"plugins/template-function-request",
|
||||
"plugins/template-function-response",
|
||||
"plugins/template-function-timestamp",
|
||||
"plugins/template-function-uuid",
|
||||
"plugins/template-function-xml",
|
||||
"plugins/themes-yaak",
|
||||
"src-tauri",
|
||||
"src-tauri/yaak-crypto",
|
||||
"src-tauri/yaak-git",
|
||||
"src-tauri/yaak-fonts",
|
||||
"src-tauri/yaak-git",
|
||||
"src-tauri/yaak-license",
|
||||
"src-tauri/yaak-mac-window",
|
||||
"src-tauri/yaak-models",
|
||||
@@ -819,6 +820,45 @@
|
||||
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.6",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.6.tgz",
|
||||
@@ -3377,6 +3417,16 @@
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/aws4": {
|
||||
"version": "1.11.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/aws4/-/aws4-1.11.6.tgz",
|
||||
"integrity": "sha512-5CnVUkHNyLGpD9AnOcK66YyP0qvIh6nhJJoeK8zSl5YKikUcUbdB7SlHevUYVqicgeh6j5AJa1qa/h08dSZHoA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@@ -4112,6 +4162,10 @@
|
||||
"resolved": "plugins/auth-apikey",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@yaak/auth-aws": {
|
||||
"resolved": "plugins/auth-aws",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@yaak/auth-basic": {
|
||||
"resolved": "plugins/auth-basic",
|
||||
"link": true
|
||||
@@ -4887,6 +4941,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/aws4": {
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz",
|
||||
"integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axe-core": {
|
||||
"version": "4.10.3",
|
||||
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.3.tgz",
|
||||
@@ -14391,12 +14451,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-dnd-html5-backend": {
|
||||
"node_modules/react-dnd-touch-backend": {
|
||||
"version": "16.0.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz",
|
||||
"integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==",
|
||||
"resolved": "https://registry.npmjs.org/react-dnd-touch-backend/-/react-dnd-touch-backend-16.0.1.tgz",
|
||||
"integrity": "sha512-NonoCABzzjyWGZuDxSG77dbgMZ2Wad7eQiCd/ECtsR2/NBLTjGksPUx9UPezZ1nQ/L7iD130Tz3RUshL/ClKLA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-dnd/invariant": "^4.0.1",
|
||||
"dnd-core": "^16.0.1"
|
||||
}
|
||||
},
|
||||
@@ -18823,6 +18884,16 @@
|
||||
"name": "@yaak/auth-apikey",
|
||||
"version": "0.1.0"
|
||||
},
|
||||
"plugins/auth-aws": {
|
||||
"name": "@yaak/auth-aws",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"aws4": "^1.13.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/aws4": "^1.11.6"
|
||||
}
|
||||
},
|
||||
"plugins/auth-basic": {
|
||||
"name": "@yaak/auth-basic",
|
||||
"version": "0.1.0"
|
||||
@@ -19058,6 +19129,7 @@
|
||||
"@codemirror/lang-xml": "^6.1.0",
|
||||
"@codemirror/language": "^6.11.0",
|
||||
"@codemirror/search": "^6.5.11",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@gilbarbara/deep-equal": "^0.3.1",
|
||||
"@lezer/highlight": "^1.1.3",
|
||||
"@lezer/lr": "^1.3.3",
|
||||
@@ -19098,7 +19170,7 @@
|
||||
"react": "^19.1.0",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
"react-dnd-touch-backend": "^16.0.1",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-pdf": "^10.0.1",
|
||||
|
||||
15
package.json
15
package.json
@@ -7,16 +7,17 @@
|
||||
"url": "git+https://github.com/mountain-loop/yaak.git"
|
||||
},
|
||||
"workspaces": [
|
||||
"packages/common-lib",
|
||||
"packages/plugin-runtime",
|
||||
"packages/plugin-runtime-types",
|
||||
"packages/common-lib",
|
||||
"plugins/action-copy-curl",
|
||||
"plugins/action-copy-grpcurl",
|
||||
"plugins/auth-apikey",
|
||||
"plugins/auth-aws",
|
||||
"plugins/auth-basic",
|
||||
"plugins/auth-bearer",
|
||||
"plugins/auth-jwt",
|
||||
"plugins/auth-oauth2",
|
||||
"plugins/action-copy-curl",
|
||||
"plugins/action-copy-grpcurl",
|
||||
"plugins/filter-jsonpath",
|
||||
"plugins/filter-xpath",
|
||||
"plugins/importer-curl",
|
||||
@@ -25,7 +26,6 @@
|
||||
"plugins/importer-postman",
|
||||
"plugins/importer-yaak",
|
||||
"plugins/template-function-cookie",
|
||||
"plugins/template-function-timestamp",
|
||||
"plugins/template-function-encode",
|
||||
"plugins/template-function-fs",
|
||||
"plugins/template-function-hash",
|
||||
@@ -34,13 +34,14 @@
|
||||
"plugins/template-function-regex",
|
||||
"plugins/template-function-request",
|
||||
"plugins/template-function-response",
|
||||
"plugins/template-function-timestamp",
|
||||
"plugins/template-function-uuid",
|
||||
"plugins/template-function-xml",
|
||||
"plugins/themes-yaak",
|
||||
"src-tauri",
|
||||
"src-tauri/yaak-crypto",
|
||||
"src-tauri/yaak-git",
|
||||
"src-tauri/yaak-fonts",
|
||||
"src-tauri/yaak-git",
|
||||
"src-tauri/yaak-license",
|
||||
"src-tauri/yaak-mac-window",
|
||||
"src-tauri/yaak-models",
|
||||
@@ -60,8 +61,8 @@
|
||||
"build-plugins": "npm run --workspaces --if-present build",
|
||||
"test": "npm run --workspaces --if-present test",
|
||||
"icons": "run-p icons:*",
|
||||
"icons:dev": "tauri icon src-tauri/icons/icon.png --output src-tauri/icons/release",
|
||||
"icons:release": "tauri icon src-tauri/icons/icon-dev.png --output src-tauri/icons/dev",
|
||||
"icons:dev": "tauri icon src-tauri/icons/icon-dev.png --output src-tauri/icons/dev",
|
||||
"icons:release": "tauri icon src-tauri/icons/icon.png --output src-tauri/icons/release",
|
||||
"bootstrap": "run-p bootstrap:* && npm run --workspaces --if-present bootstrap",
|
||||
"bootstrap:vendor-node": "node scripts/vendor-node.cjs",
|
||||
"bootstrap:vendor-plugins": "node scripts/vendor-plugins.cjs",
|
||||
|
||||
@@ -437,7 +437,7 @@ export type SetKeyValueRequest = { key: string, value: string, };
|
||||
|
||||
export type SetKeyValueResponse = {};
|
||||
|
||||
export type ShowToastRequest = { message: string, color?: Color, icon?: Icon, };
|
||||
export type ShowToastRequest = { message: string, color?: Color, icon?: Icon, timeout?: number, };
|
||||
|
||||
export type TemplateFunction = { name: string, description?: string,
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, variables: Array<EnvironmentVariable>, color: string | null, parentModel: string, parentId: string | null, };
|
||||
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null, variables: Array<EnvironmentVariable>, color: string | null, };
|
||||
|
||||
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };
|
||||
|
||||
|
||||
@@ -454,6 +454,8 @@ export class PluginInstance {
|
||||
show: async (args) => {
|
||||
await this.#sendAndWaitForReply(windowContext, {
|
||||
type: 'show_toast_request',
|
||||
// Handle default here because null/undefined both convert to None in Rust translation
|
||||
timeout: args.timeout === undefined ? 5000 : args.timeout,
|
||||
...args,
|
||||
});
|
||||
},
|
||||
|
||||
49
plugins/auth-aws/README.md
Normal file
49
plugins/auth-aws/README.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# AWS Signature Version 4 Auth
|
||||
|
||||
A plugin for authenticating AWS-compatible requests using the
|
||||
[AWS Signature Version 4 signing process](https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html).
|
||||
This enables secure, signed requests to AWS services (or any S3-compatible APIs like
|
||||
Cloudflare R2).
|
||||
|
||||

|
||||
|
||||
## Overview
|
||||
|
||||
This plugin provides AWS Signature authentication for API requests in Yaak. SigV4 is used
|
||||
by nearly all AWS APIs to verify the authenticity and integrity of requests using
|
||||
cryptographic signatures.
|
||||
|
||||
With this plugin, you can securely sign requests to AWS services such as S3, STS, Lambda,
|
||||
API Gateway, DynamoDB, and more. You can also authenticate against S3-compatible services
|
||||
like **Cloudflare R2**, **MinIO**, or **Wasabi**.
|
||||
|
||||
## How AWS Signature Version 4 Works
|
||||
|
||||
SigV4 signs requests by creating a hash of key request components (method, URL, headers,
|
||||
and optionally the payload) using your AWS credentials. The resulting HMAC signature is
|
||||
added in the `Authorization` header along with credential scope metadata.
|
||||
|
||||
Example header:
|
||||
|
||||
```
|
||||
Authorization: AWS4-HMAC-SHA256 Credential=AKIA…/20251011/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-date, Signature=abcdef123456…
|
||||
```
|
||||
|
||||
Each request must include a timestamp (`X-Amz-Date`) and may include a session token if
|
||||
using temporary credentials.
|
||||
|
||||
## Configuration
|
||||
|
||||
The plugin presents the following fields:
|
||||
|
||||
- **Access Key ID** – Your AWS access key identifier
|
||||
- **Secret Access Key** – The secret associated with the access key
|
||||
- **Session Token** *(optional)* – Used for temporary or assumed-role credentials (treated as secret)
|
||||
- **Region** – AWS region (e.g., `us-east-1`)
|
||||
- **Service** – AWS service identifier (e.g., `sts`, `s3`, `execute-api`)
|
||||
|
||||
## Usage
|
||||
|
||||
1. Configure a request, folder, or workspace to use **AWS SigV4 Authentication**
|
||||
2. Enter your AWS credentials and target service/region
|
||||
3. The plugin will automatically sign outgoing requests with valid SigV4 headers
|
||||
23
plugins/auth-aws/package.json
Normal file
23
plugins/auth-aws/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@yaak/auth-aws",
|
||||
"displayName": "AWS SigV4",
|
||||
"description": "Authenticate requests using AWS SigV4 signing",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/mountain-loop/yaak.git",
|
||||
"directory": "plugins/auth-aws"
|
||||
},
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"build": "yaakcli build",
|
||||
"dev": "yaakcli dev",
|
||||
"lint": "tsc --noEmit && eslint . --ext .ts,.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"aws4": "^1.13.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/aws4": "^1.11.6"
|
||||
}
|
||||
}
|
||||
BIN
plugins/auth-aws/screenshot.png
Normal file
BIN
plugins/auth-aws/screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 790 KiB |
97
plugins/auth-aws/src/index.ts
Normal file
97
plugins/auth-aws/src/index.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { CallHttpAuthenticationResponse } from '@yaakapp-internal/plugins';
|
||||
import type { PluginDefinition } from '@yaakapp/api';
|
||||
import aws4 from 'aws4';
|
||||
import type { Request } from 'aws4';
|
||||
import { URL } from 'node:url';
|
||||
|
||||
export const plugin: PluginDefinition = {
|
||||
authentication: {
|
||||
name: 'auth-aws-sig-v4',
|
||||
label: 'AWS Signature',
|
||||
shortLabel: 'AWS v4',
|
||||
args: [
|
||||
{ name: 'accessKeyId', label: 'Access Key ID', type: 'text', password: true },
|
||||
{
|
||||
name: 'secretAccessKey',
|
||||
label: 'Secret Access Key',
|
||||
type: 'text',
|
||||
password: true,
|
||||
},
|
||||
{
|
||||
name: 'service',
|
||||
label: 'Service Name',
|
||||
type: 'text',
|
||||
defaultValue: 'sts',
|
||||
placeholder: 'sts',
|
||||
description: 'The service that is receiving the request (sts, s3, sqs, ...)',
|
||||
},
|
||||
{
|
||||
name: 'region',
|
||||
label: 'Region',
|
||||
type: 'text',
|
||||
placeholder: 'us-east-1',
|
||||
description: 'The region that is receiving the request (defaults to us-east-1)',
|
||||
optional: true,
|
||||
},
|
||||
{
|
||||
name: 'sessionToken',
|
||||
label: 'Session Token',
|
||||
type: 'text',
|
||||
password: true,
|
||||
optional: true,
|
||||
description: 'Only required if you are using temporary credentials',
|
||||
},
|
||||
],
|
||||
onApply(_ctx, { values, ...args }): CallHttpAuthenticationResponse {
|
||||
const accessKeyId = String(values.accessKeyId || '');
|
||||
const secretAccessKey = String(values.secretAccessKey || '');
|
||||
const sessionToken = String(values.sessionToken || '') || undefined;
|
||||
|
||||
const url = new URL(args.url);
|
||||
|
||||
const headers: NonNullable<Request['headers']> = {};
|
||||
for (const headerName of ['content-type', 'host', 'x-amz-date', 'x-amz-security-token']) {
|
||||
const v = args.headers.find((h) => h.name.toLowerCase() === headerName);
|
||||
if (v != null) {
|
||||
headers[headerName] = v.value;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Support body signing here
|
||||
headers['x-amz-content-sha256'] = 'UNSIGNED-PAYLOAD';
|
||||
|
||||
const signature = aws4.sign(
|
||||
{
|
||||
host: url.host,
|
||||
method: args.method,
|
||||
path: url.pathname + (url.search || '') || undefined,
|
||||
service: String(values.service || 'sts') || undefined,
|
||||
region: String(values.region || 'us-east-1') || undefined,
|
||||
headers,
|
||||
},
|
||||
{
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
sessionToken,
|
||||
},
|
||||
);
|
||||
|
||||
// After signing, aws4 will set:
|
||||
// - opts.headers["Authorization"]
|
||||
// - opts.headers["X-Amz-Date"]
|
||||
// - optionally content sha256 header etc
|
||||
|
||||
console.log('ADDING STUFF', signature);
|
||||
|
||||
if (signature.headers == null) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
setHeaders: Object.entries(signature.headers)
|
||||
.filter(([name]) => name !== 'content-type') // Don't add this because we already have it
|
||||
.map(([name, value]) => ({ name, value: String(value || '') })),
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
3
plugins/auth-aws/tsconfig.json
Normal file
3
plugins/auth-aws/tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json"
|
||||
}
|
||||
@@ -271,6 +271,12 @@ export const plugin: PluginDefinition = {
|
||||
label: 'Advanced',
|
||||
inputs: [
|
||||
{ type: 'text', name: 'scope', label: 'Scope', optional: true },
|
||||
{
|
||||
type: 'text',
|
||||
name: 'headerName',
|
||||
label: 'Header Name',
|
||||
defaultValue: 'Authorization',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'headerPrefix',
|
||||
@@ -397,15 +403,9 @@ export const plugin: PluginDefinition = {
|
||||
throw new Error('Invalid grant type ' + grantType);
|
||||
}
|
||||
|
||||
const headerName = stringArg(values, 'headerName') || 'Authorization';
|
||||
const headerValue = `${headerPrefix} ${token.response[tokenName]}`.trim();
|
||||
return {
|
||||
setHeaders: [
|
||||
{
|
||||
name: 'Authorization',
|
||||
value: headerValue,
|
||||
},
|
||||
],
|
||||
};
|
||||
return { setHeaders: [{ name: headerName, value: headerValue }] };
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { HttpUrlParameter } from '@yaakapp-internal/models';
|
||||
import type { AnyModel, HttpUrlParameter } from '@yaakapp-internal/models';
|
||||
import type { CallTemplateFunctionArgs, Context, PluginDefinition } from '@yaakapp/api';
|
||||
|
||||
export const plugin: PluginDefinition = {
|
||||
@@ -96,5 +96,59 @@ export const plugin: PluginDefinition = {
|
||||
return renderedValue;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'request.name',
|
||||
args: [
|
||||
{
|
||||
name: 'requestId',
|
||||
label: 'Http Request',
|
||||
type: 'http_request',
|
||||
},
|
||||
],
|
||||
async onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
|
||||
const requestId = String(args.values.requestId ?? 'n/a');
|
||||
const httpRequest = await ctx.httpRequest.getById({ id: requestId });
|
||||
if (httpRequest == null) return null;
|
||||
|
||||
return resolvedModelName(httpRequest);
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// TODO: Use a common function for this, but it fails to build on windows during CI if I try importing it here
|
||||
export function resolvedModelName(r: AnyModel | null): string {
|
||||
if (r == null) return '';
|
||||
|
||||
if (!('url' in r) || r.model === 'plugin') {
|
||||
return 'name' in r ? r.name : '';
|
||||
}
|
||||
|
||||
// Return name if it has one
|
||||
if ('name' in r && r.name) {
|
||||
return r.name;
|
||||
}
|
||||
|
||||
// Replace variable syntax with variable name
|
||||
const withoutVariables = r.url.replace(/\$\{\[\s*([^\]\s]+)\s*]}/g, '$1');
|
||||
if (withoutVariables.trim() === '') {
|
||||
return r.model === 'http_request'
|
||||
? r.bodyType && r.bodyType === 'graphql'
|
||||
? 'GraphQL Request'
|
||||
: 'HTTP Request'
|
||||
: r.model === 'websocket_request'
|
||||
? 'WebSocket Request'
|
||||
: 'gRPC Request';
|
||||
}
|
||||
|
||||
// GRPC gets nice short names
|
||||
if (r.model === 'grpc_request' && r.service != null && r.method != null) {
|
||||
const shortService = r.service.split('.').pop();
|
||||
return `${shortService}/${r.method}`;
|
||||
}
|
||||
|
||||
// Strip unnecessary protocol
|
||||
const withoutProto = withoutVariables.replace(/^(http|https|ws|wss):\/\//, '');
|
||||
|
||||
return withoutProto;
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ export function formatDatetime(args: {
|
||||
format?: string;
|
||||
in?: ContextFn<Date>;
|
||||
}): string {
|
||||
const { date, format = 'yyyy-MM-dd HH:mm:ss' } = args;
|
||||
const { date, format } = args;
|
||||
const d = parseDateString(date ?? '');
|
||||
return formatDate(d, String(format), { in: args.in });
|
||||
return formatDate(d, String(format || 'yyyy-MM-dd HH:mm:ss'), { in: args.in });
|
||||
}
|
||||
|
||||
@@ -35,6 +35,9 @@ pub enum Error {
|
||||
#[error(transparent)]
|
||||
CommonError(#[from] yaak_common::error::Error),
|
||||
|
||||
#[error(transparent)]
|
||||
ClipboardError(#[from] tauri_plugin_clipboard_manager::Error),
|
||||
|
||||
#[error("Updater error: {0}")]
|
||||
UpdaterError(#[from] tauri_plugin_updater::Error),
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
extern crate core;
|
||||
use crate::encoding::read_response_body;
|
||||
use crate::error::Error::GenericError;
|
||||
use crate::error::Result;
|
||||
use crate::grpc::{build_metadata, metadata_to_map, resolve_grpc_request};
|
||||
use crate::http_request::{resolve_http_request, send_http_request};
|
||||
use crate::import::import_data;
|
||||
@@ -49,7 +50,7 @@ use yaak_plugins::plugin_meta::PluginMetadata;
|
||||
use yaak_plugins::template_callback::PluginTemplateCallback;
|
||||
use yaak_sse::sse::ServerSentEvent;
|
||||
use yaak_templates::format::format_json;
|
||||
use yaak_templates::{Tokens, transform_args, RenderOptions, RenderErrorBehavior};
|
||||
use yaak_templates::{RenderErrorBehavior, RenderOptions, Tokens, transform_args};
|
||||
|
||||
mod commands;
|
||||
mod encoding;
|
||||
@@ -1006,6 +1007,35 @@ async fn cmd_save_response<R: Runtime>(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn cmd_send_folder<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
window: WebviewWindow<R>,
|
||||
environment_id: Option<String>,
|
||||
cookie_jar_id: Option<String>,
|
||||
folder_id: &str,
|
||||
) -> YaakResult<()> {
|
||||
let requests = app_handle.db().list_http_requests_for_folder_recursive(folder_id)?;
|
||||
for request in requests {
|
||||
let app_handle = app_handle.clone();
|
||||
let window = window.clone();
|
||||
let environment_id = environment_id.clone();
|
||||
let cookie_jar_id = cookie_jar_id.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = cmd_send_http_request(
|
||||
app_handle,
|
||||
window,
|
||||
environment_id.as_deref(),
|
||||
cookie_jar_id.as_deref(),
|
||||
request,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn cmd_send_http_request<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
@@ -1281,7 +1311,6 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||
.plugin(tauri_plugin_deep_link::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_updater::Builder::default().build())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_os::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
@@ -1299,6 +1328,11 @@ pub fn run() {
|
||||
builder = builder.plugin(yaak_license::init());
|
||||
}
|
||||
|
||||
#[cfg(feature = "updater")]
|
||||
{
|
||||
builder = builder.plugin(tauri_plugin_updater::Builder::default().build());
|
||||
}
|
||||
|
||||
builder
|
||||
.setup(|app| {
|
||||
{
|
||||
@@ -1381,6 +1415,7 @@ pub fn run() {
|
||||
cmd_save_response,
|
||||
cmd_send_ephemeral_request,
|
||||
cmd_send_http_request,
|
||||
cmd_send_folder,
|
||||
cmd_template_functions,
|
||||
cmd_template_tokens_to_string,
|
||||
//
|
||||
@@ -1499,7 +1534,30 @@ fn monitor_plugin_events<R: Runtime>(app_handle: &AppHandle<R>) {
|
||||
// We might have recursive back-and-forth calls between app and plugin, so we don't
|
||||
// want to block here
|
||||
tauri::async_runtime::spawn(async move {
|
||||
plugin_events::handle_plugin_event(&app_handle, &event, &plugin).await;
|
||||
let ev = plugin_events::handle_plugin_event(&app_handle, &event, &plugin).await;
|
||||
|
||||
let ev = match ev {
|
||||
Ok(Some(ev)) => ev,
|
||||
Ok(None) => return,
|
||||
Err(e) => {
|
||||
warn!("Failed to handle plugin event: {e:?}");
|
||||
let _ = app_handle.emit(
|
||||
"show_toast",
|
||||
InternalEventPayload::ShowToastRequest(ShowToastRequest {
|
||||
message: e.to_string(),
|
||||
color: Some(Color::Danger),
|
||||
icon: None,
|
||||
timeout: Some(30000),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let plugin_manager: State<'_, PluginManager> = app_handle.state();
|
||||
if let Err(e) = plugin_manager.reply(&event, &ev).await {
|
||||
warn!("Failed to reply to plugin manager: {:?}", e)
|
||||
}
|
||||
});
|
||||
}
|
||||
plugin_manager.unsubscribe(rx_id.as_str()).await;
|
||||
@@ -1534,11 +1592,16 @@ async fn call_frontend<R: Runtime>(
|
||||
fn get_window_from_window_context<R: Runtime>(
|
||||
app_handle: &AppHandle<R>,
|
||||
window_context: &PluginWindowContext,
|
||||
) -> Option<WebviewWindow<R>> {
|
||||
) -> Result<WebviewWindow<R>> {
|
||||
let label = match window_context {
|
||||
PluginWindowContext::Label { label, .. } => label,
|
||||
PluginWindowContext::None => {
|
||||
return app_handle.webview_windows().iter().next().map(|(_, w)| w.to_owned());
|
||||
return app_handle
|
||||
.webview_windows()
|
||||
.iter()
|
||||
.next()
|
||||
.map(|(_, w)| w.to_owned())
|
||||
.ok_or(GenericError("No windows open".to_string()));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1551,7 +1614,7 @@ fn get_window_from_window_context<R: Runtime>(
|
||||
error!("Failed to find window by {window_context:?}");
|
||||
}
|
||||
|
||||
window
|
||||
Ok(window.ok_or(GenericError(format!("Failed to find window for {}", label)))?)
|
||||
}
|
||||
|
||||
fn workspace_from_window<R: Runtime>(window: &WebviewWindow<R>) -> Option<Workspace> {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::error::Result;
|
||||
use crate::http_request::send_http_request;
|
||||
use crate::render::{render_grpc_request, render_http_request, render_json_value};
|
||||
use crate::window::{CreateWindowConfig, create_window};
|
||||
@@ -7,10 +8,12 @@ use crate::{
|
||||
};
|
||||
use chrono::Utc;
|
||||
use cookie::Cookie;
|
||||
use log::{error, warn};
|
||||
use tauri::{AppHandle, Emitter, Manager, Runtime, State};
|
||||
use log::error;
|
||||
use tauri::{AppHandle, Emitter, Manager, Runtime};
|
||||
use tauri_plugin_clipboard_manager::ClipboardExt;
|
||||
use yaak_common::window::WorkspaceWindowTrait;
|
||||
use yaak_models::models::{HttpResponse, Plugin};
|
||||
use yaak_models::queries::any_request::AnyRequest;
|
||||
use yaak_models::query_manager::QueryManagerExt;
|
||||
use yaak_models::util::UpdateSource;
|
||||
use yaak_plugins::events::{
|
||||
@@ -20,7 +23,6 @@ use yaak_plugins::events::{
|
||||
RenderHttpRequestResponse, SendHttpRequestResponse, SetKeyValueResponse, ShowToastRequest,
|
||||
TemplateRenderResponse, WindowNavigateEvent,
|
||||
};
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
use yaak_plugins::plugin_handle::PluginHandle;
|
||||
use yaak_plugins::template_callback::PluginTemplateCallback;
|
||||
use yaak_templates::{RenderErrorBehavior, RenderOptions};
|
||||
@@ -29,109 +31,111 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
|
||||
app_handle: &AppHandle<R>,
|
||||
event: &InternalEvent,
|
||||
plugin_handle: &PluginHandle,
|
||||
) {
|
||||
) -> Result<Option<InternalEventPayload>> {
|
||||
// debug!("Got event to app {event:?}");
|
||||
let window_context = event.window_context.to_owned();
|
||||
let response_event: Option<InternalEventPayload> = match event.clone().payload {
|
||||
match event.clone().payload {
|
||||
InternalEventPayload::CopyTextRequest(req) => {
|
||||
app_handle
|
||||
.clipboard()
|
||||
.write_text(req.text.as_str())
|
||||
.expect("Failed to write text to clipboard");
|
||||
Some(InternalEventPayload::CopyTextResponse(EmptyPayload {}))
|
||||
app_handle.clipboard().write_text(req.text.as_str())?;
|
||||
Ok(Some(InternalEventPayload::CopyTextResponse(EmptyPayload {})))
|
||||
}
|
||||
InternalEventPayload::ShowToastRequest(req) => {
|
||||
match window_context {
|
||||
PluginWindowContext::Label { label, .. } => app_handle
|
||||
.emit_to(label, "show_toast", req)
|
||||
.expect("Failed to emit show_toast to window"),
|
||||
_ => app_handle.emit("show_toast", req).expect("Failed to emit show_toast"),
|
||||
PluginWindowContext::Label { label, .. } => {
|
||||
app_handle.emit_to(label, "show_toast", req)?
|
||||
}
|
||||
_ => app_handle.emit("show_toast", req)?,
|
||||
};
|
||||
Some(InternalEventPayload::ShowToastResponse(EmptyPayload {}))
|
||||
Ok(Some(InternalEventPayload::ShowToastResponse(EmptyPayload {})))
|
||||
}
|
||||
InternalEventPayload::PromptTextRequest(_) => {
|
||||
let window = get_window_from_window_context(app_handle, &window_context)
|
||||
.expect("Failed to find window for render");
|
||||
call_frontend(&window, event).await
|
||||
let window = get_window_from_window_context(app_handle, &window_context)?;
|
||||
Ok(call_frontend(&window, event).await)
|
||||
}
|
||||
InternalEventPayload::FindHttpResponsesRequest(req) => {
|
||||
let http_responses = app_handle
|
||||
.db()
|
||||
.list_http_responses_for_request(&req.request_id, req.limit.map(|l| l as u64))
|
||||
.unwrap_or_default();
|
||||
Some(InternalEventPayload::FindHttpResponsesResponse(FindHttpResponsesResponse {
|
||||
Ok(Some(InternalEventPayload::FindHttpResponsesResponse(FindHttpResponsesResponse {
|
||||
http_responses,
|
||||
}))
|
||||
})))
|
||||
}
|
||||
InternalEventPayload::GetHttpRequestByIdRequest(req) => {
|
||||
let http_request = app_handle.db().get_http_request(&req.id).ok();
|
||||
Some(InternalEventPayload::GetHttpRequestByIdResponse(GetHttpRequestByIdResponse {
|
||||
Ok(Some(InternalEventPayload::GetHttpRequestByIdResponse(GetHttpRequestByIdResponse {
|
||||
http_request,
|
||||
}))
|
||||
})))
|
||||
}
|
||||
InternalEventPayload::RenderGrpcRequestRequest(req) => {
|
||||
let window = get_window_from_window_context(app_handle, &window_context)
|
||||
.expect("Failed to find window for render grpc request");
|
||||
let window = get_window_from_window_context(app_handle, &window_context)?;
|
||||
|
||||
let workspace =
|
||||
workspace_from_window(&window).expect("Failed to get workspace_id from window URL");
|
||||
let environment_id = environment_from_window(&window).map(|e| e.id);
|
||||
let environment_chain = window
|
||||
.db()
|
||||
.resolve_environments(&workspace.id, None, environment_id.as_deref())
|
||||
.expect("Failed to resolve environments");
|
||||
let environment_chain = window.db().resolve_environments(
|
||||
&workspace.id,
|
||||
req.grpc_request.folder_id.as_deref(),
|
||||
environment_id.as_deref(),
|
||||
)?;
|
||||
let cb = PluginTemplateCallback::new(app_handle, &window_context, req.purpose);
|
||||
let opt = RenderOptions {
|
||||
error_behavior: RenderErrorBehavior::Throw,
|
||||
};
|
||||
let grpc_request = render_grpc_request(&req.grpc_request, environment_chain, &cb, &opt)
|
||||
.await
|
||||
.expect("Failed to render grpc request");
|
||||
Some(InternalEventPayload::RenderGrpcRequestResponse(RenderGrpcRequestResponse {
|
||||
let grpc_request =
|
||||
render_grpc_request(&req.grpc_request, environment_chain, &cb, &opt).await?;
|
||||
Ok(Some(InternalEventPayload::RenderGrpcRequestResponse(RenderGrpcRequestResponse {
|
||||
grpc_request,
|
||||
}))
|
||||
})))
|
||||
}
|
||||
InternalEventPayload::RenderHttpRequestRequest(req) => {
|
||||
let window = get_window_from_window_context(app_handle, &window_context)
|
||||
.expect("Failed to find window for render http request");
|
||||
let window = get_window_from_window_context(app_handle, &window_context)?;
|
||||
|
||||
let workspace =
|
||||
workspace_from_window(&window).expect("Failed to get workspace_id from window URL");
|
||||
let environment_id = environment_from_window(&window).map(|e| e.id);
|
||||
let environment_chain = window
|
||||
.db()
|
||||
.resolve_environments(&workspace.id, None, environment_id.as_deref())
|
||||
.expect("Failed to resolve environments");
|
||||
let environment_chain = window.db().resolve_environments(
|
||||
&workspace.id,
|
||||
req.http_request.folder_id.as_deref(),
|
||||
environment_id.as_deref(),
|
||||
)?;
|
||||
let cb = PluginTemplateCallback::new(app_handle, &window_context, req.purpose);
|
||||
let opt = &RenderOptions {
|
||||
error_behavior: RenderErrorBehavior::Throw,
|
||||
};
|
||||
let http_request = render_http_request(&req.http_request, environment_chain, &cb, &opt)
|
||||
.await
|
||||
.expect("Failed to render http request");
|
||||
Some(InternalEventPayload::RenderHttpRequestResponse(RenderHttpRequestResponse {
|
||||
let http_request =
|
||||
render_http_request(&req.http_request, environment_chain, &cb, &opt).await?;
|
||||
Ok(Some(InternalEventPayload::RenderHttpRequestResponse(RenderHttpRequestResponse {
|
||||
http_request,
|
||||
}))
|
||||
})))
|
||||
}
|
||||
InternalEventPayload::TemplateRenderRequest(req) => {
|
||||
let window = get_window_from_window_context(app_handle, &window_context)
|
||||
.expect("Failed to find window for render");
|
||||
let window = get_window_from_window_context(app_handle, &window_context)?;
|
||||
|
||||
let workspace =
|
||||
workspace_from_window(&window).expect("Failed to get workspace_id from window URL");
|
||||
let environment_id = environment_from_window(&window).map(|e| e.id);
|
||||
let environment_chain = window
|
||||
.db()
|
||||
.resolve_environments(&workspace.id, None, environment_id.as_deref())
|
||||
.expect("Failed to resolve environments");
|
||||
let folder_id = if let Some(id) = window.request_id() {
|
||||
match window.db().get_any_request(&id) {
|
||||
Ok(AnyRequest::HttpRequest(r)) => r.folder_id,
|
||||
Ok(AnyRequest::GrpcRequest(r)) => r.folder_id,
|
||||
Ok(AnyRequest::WebsocketRequest(r)) => r.folder_id,
|
||||
Err(_) => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let environment_chain = window.db().resolve_environments(
|
||||
&workspace.id,
|
||||
folder_id.as_deref(),
|
||||
environment_id.as_deref(),
|
||||
)?;
|
||||
let cb = PluginTemplateCallback::new(app_handle, &window_context, req.purpose);
|
||||
let opt = RenderOptions {
|
||||
error_behavior: RenderErrorBehavior::Throw,
|
||||
};
|
||||
let data = render_json_value(req.data, environment_chain, &cb, &opt)
|
||||
.await
|
||||
.expect("Failed to render template");
|
||||
Some(InternalEventPayload::TemplateRenderResponse(TemplateRenderResponse { data }))
|
||||
let data = render_json_value(req.data, environment_chain, &cb, &opt).await?;
|
||||
Ok(Some(InternalEventPayload::TemplateRenderResponse(TemplateRenderResponse { data })))
|
||||
}
|
||||
InternalEventPayload::ErrorResponse(resp) => {
|
||||
error!("Plugin error: {}: {:?}", resp.error, resp);
|
||||
@@ -144,16 +148,15 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
|
||||
resp.error
|
||||
),
|
||||
color: Some(Color::Danger),
|
||||
timeout: None,
|
||||
timeout: Some(30000),
|
||||
..Default::default()
|
||||
}),
|
||||
None,
|
||||
);
|
||||
Box::pin(handle_plugin_event(app_handle, &toast_event, plugin_handle)).await;
|
||||
None
|
||||
Box::pin(handle_plugin_event(app_handle, &toast_event, plugin_handle)).await
|
||||
}
|
||||
InternalEventPayload::ReloadResponse(req) => {
|
||||
let plugins = app_handle.db().list_plugins().unwrap();
|
||||
let plugins = app_handle.db().list_plugins()?;
|
||||
for plugin in plugins {
|
||||
if plugin.directory != plugin_handle.dir {
|
||||
continue;
|
||||
@@ -163,7 +166,7 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
|
||||
updated_at: Utc::now().naive_utc(), // TODO: Add reloaded_at field to use instead
|
||||
..plugin
|
||||
};
|
||||
app_handle.db().upsert_plugin(&new_plugin, &UpdateSource::Plugin).unwrap();
|
||||
app_handle.db().upsert_plugin(&new_plugin, &UpdateSource::Plugin)?;
|
||||
}
|
||||
|
||||
if !req.silent {
|
||||
@@ -178,13 +181,13 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
|
||||
}),
|
||||
None,
|
||||
);
|
||||
Box::pin(handle_plugin_event(app_handle, &toast_event, plugin_handle)).await;
|
||||
Box::pin(handle_plugin_event(app_handle, &toast_event, plugin_handle)).await
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
None
|
||||
}
|
||||
InternalEventPayload::SendHttpRequestRequest(req) => {
|
||||
let window = get_window_from_window_context(app_handle, &window_context)
|
||||
.expect("Failed to find window for sending HTTP request");
|
||||
let window = get_window_from_window_context(app_handle, &window_context)?;
|
||||
let mut http_request = req.http_request;
|
||||
let workspace =
|
||||
workspace_from_window(&window).expect("Failed to get workspace_id from window URL");
|
||||
@@ -198,20 +201,17 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
|
||||
let http_response = if http_request.id.is_empty() {
|
||||
HttpResponse::default()
|
||||
} else {
|
||||
window
|
||||
.db()
|
||||
.upsert_http_response(
|
||||
&HttpResponse {
|
||||
request_id: http_request.id.clone(),
|
||||
workspace_id: http_request.workspace_id.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Plugin,
|
||||
)
|
||||
.unwrap()
|
||||
window.db().upsert_http_response(
|
||||
&HttpResponse {
|
||||
request_id: http_request.id.clone(),
|
||||
workspace_id: http_request.workspace_id.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Plugin,
|
||||
)?
|
||||
};
|
||||
|
||||
let result = send_http_request(
|
||||
let http_response = send_http_request(
|
||||
&window,
|
||||
&http_request,
|
||||
&http_response,
|
||||
@@ -219,16 +219,11 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
|
||||
cookie_jar,
|
||||
&mut tokio::sync::watch::channel(false).1, // No-op cancel channel
|
||||
)
|
||||
.await;
|
||||
.await?;
|
||||
|
||||
let http_response = match result {
|
||||
Ok(r) => r,
|
||||
Err(_e) => return,
|
||||
};
|
||||
|
||||
Some(InternalEventPayload::SendHttpRequestResponse(SendHttpRequestResponse {
|
||||
Ok(Some(InternalEventPayload::SendHttpRequestResponse(SendHttpRequestResponse {
|
||||
http_response,
|
||||
}))
|
||||
})))
|
||||
}
|
||||
InternalEventPayload::OpenWindowRequest(req) => {
|
||||
let (navigation_tx, mut navigation_rx) = tokio::sync::mpsc::channel(128);
|
||||
@@ -251,8 +246,8 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
|
||||
}),
|
||||
None,
|
||||
);
|
||||
Box::pin(handle_plugin_event(app_handle, &error_event, plugin_handle)).await;
|
||||
return;
|
||||
return Box::pin(handle_plugin_event(app_handle, &error_event, plugin_handle))
|
||||
.await;
|
||||
}
|
||||
|
||||
{
|
||||
@@ -288,32 +283,33 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
|
||||
});
|
||||
}
|
||||
|
||||
None
|
||||
Ok(None)
|
||||
}
|
||||
InternalEventPayload::CloseWindowRequest(req) => {
|
||||
if let Some(window) = app_handle.webview_windows().get(&req.label) {
|
||||
window.close().expect("Failed to close window");
|
||||
window.close()?;
|
||||
}
|
||||
None
|
||||
Ok(None)
|
||||
}
|
||||
InternalEventPayload::SetKeyValueRequest(req) => {
|
||||
let name = plugin_handle.info().name;
|
||||
app_handle.db().set_plugin_key_value(&name, &req.key, &req.value);
|
||||
Some(InternalEventPayload::SetKeyValueResponse(SetKeyValueResponse {}))
|
||||
Ok(Some(InternalEventPayload::SetKeyValueResponse(SetKeyValueResponse {})))
|
||||
}
|
||||
InternalEventPayload::GetKeyValueRequest(req) => {
|
||||
let name = plugin_handle.info().name;
|
||||
let value = app_handle.db().get_plugin_key_value(&name, &req.key).map(|v| v.value);
|
||||
Some(InternalEventPayload::GetKeyValueResponse(GetKeyValueResponse { value }))
|
||||
Ok(Some(InternalEventPayload::GetKeyValueResponse(GetKeyValueResponse { value })))
|
||||
}
|
||||
InternalEventPayload::DeleteKeyValueRequest(req) => {
|
||||
let name = plugin_handle.info().name;
|
||||
let deleted = app_handle.db().delete_plugin_key_value(&name, &req.key).unwrap();
|
||||
Some(InternalEventPayload::DeleteKeyValueResponse(DeleteKeyValueResponse { deleted }))
|
||||
let deleted = app_handle.db().delete_plugin_key_value(&name, &req.key)?;
|
||||
Ok(Some(InternalEventPayload::DeleteKeyValueResponse(DeleteKeyValueResponse {
|
||||
deleted,
|
||||
})))
|
||||
}
|
||||
InternalEventPayload::ListCookieNamesRequest(_req) => {
|
||||
let window = get_window_from_window_context(app_handle, &window_context)
|
||||
.expect("Failed to find window for listing cookies");
|
||||
let window = get_window_from_window_context(app_handle, &window_context)?;
|
||||
let names = match cookie_jar_from_window(&window) {
|
||||
None => Vec::new(),
|
||||
Some(j) => j
|
||||
@@ -322,11 +318,12 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
|
||||
.filter_map(|c| Cookie::parse(c.raw_cookie).ok().map(|c| c.name().to_string()))
|
||||
.collect(),
|
||||
};
|
||||
Some(InternalEventPayload::ListCookieNamesResponse(ListCookieNamesResponse { names }))
|
||||
Ok(Some(InternalEventPayload::ListCookieNamesResponse(ListCookieNamesResponse {
|
||||
names,
|
||||
})))
|
||||
}
|
||||
InternalEventPayload::GetCookieValueRequest(req) => {
|
||||
let window = get_window_from_window_context(app_handle, &window_context)
|
||||
.expect("Failed to find window for listing cookies");
|
||||
let window = get_window_from_window_context(app_handle, &window_context)?;
|
||||
let value = match cookie_jar_from_window(&window) {
|
||||
None => None,
|
||||
Some(j) => j.cookies.into_iter().find_map(|c| match Cookie::parse(c.raw_cookie) {
|
||||
@@ -336,15 +333,8 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
|
||||
_ => None,
|
||||
}),
|
||||
};
|
||||
Some(InternalEventPayload::GetCookieValueResponse(GetCookieValueResponse { value }))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(e) = response_event {
|
||||
let plugin_manager: State<'_, PluginManager> = app_handle.state();
|
||||
if let Err(e) = plugin_manager.reply(&event, &e).await {
|
||||
warn!("Failed to reply to plugin manager: {:?}", e)
|
||||
Ok(Some(InternalEventPayload::GetCookieValueResponse(GetCookieValueResponse { value })))
|
||||
}
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::error::Result;
|
||||
use crate::window_menu::app_menu;
|
||||
use log::{info, warn};
|
||||
use rand::random;
|
||||
@@ -6,7 +7,6 @@ use tauri::{
|
||||
};
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
use tokio::sync::mpsc;
|
||||
use crate::error::Result;
|
||||
|
||||
const DEFAULT_WINDOW_WIDTH: f64 = 1100.0;
|
||||
const DEFAULT_WINDOW_HEIGHT: f64 = 600.0;
|
||||
@@ -49,7 +49,6 @@ pub(crate) fn create_window<R: Runtime>(
|
||||
.resizable(true)
|
||||
.visible(false) // To prevent theme flashing, the frontend code calls show() immediately after configuring the theme
|
||||
.fullscreen(false)
|
||||
.disable_drag_drop_handler() // Required for frontend Dnd on windows
|
||||
.min_inner_size(MIN_WINDOW_WIDTH, MIN_WINDOW_HEIGHT);
|
||||
|
||||
if let Some(key) = config.data_dir_key {
|
||||
@@ -216,10 +215,10 @@ pub(crate) fn create_child_window(
|
||||
) -> Result<WebviewWindow> {
|
||||
let app_handle = parent_window.app_handle();
|
||||
let label = format!("{OTHER_WINDOW_PREFIX}_{label}");
|
||||
let scale_factor = parent_window.scale_factor().unwrap();
|
||||
let scale_factor = parent_window.scale_factor()?;
|
||||
|
||||
let current_pos = parent_window.inner_position().unwrap().to_logical::<f64>(scale_factor);
|
||||
let current_size = parent_window.inner_size().unwrap().to_logical::<f64>(scale_factor);
|
||||
let current_pos = parent_window.inner_position()?.to_logical::<f64>(scale_factor);
|
||||
let current_size = parent_window.inner_size()?.to_logical::<f64>(scale_factor);
|
||||
|
||||
// Position the new window in the middle of the parent
|
||||
let position = (
|
||||
|
||||
@@ -5,6 +5,7 @@ pub trait WorkspaceWindowTrait {
|
||||
fn workspace_id(&self) -> Option<String>;
|
||||
fn cookie_jar_id(&self) -> Option<String>;
|
||||
fn environment_id(&self) -> Option<String>;
|
||||
fn request_id(&self) -> Option<String>;
|
||||
}
|
||||
|
||||
impl<R: Runtime> WorkspaceWindowTrait for WebviewWindow<R> {
|
||||
@@ -28,4 +29,10 @@ impl<R: Runtime> WorkspaceWindowTrait for WebviewWindow<R> {
|
||||
let mut query_pairs = url.query_pairs();
|
||||
query_pairs.find(|(k, _v)| k == "environment_id").map(|(_k, v)| v.to_string())
|
||||
}
|
||||
|
||||
fn request_id(&self) -> Option<String> {
|
||||
let url = self.url().unwrap();
|
||||
let mut query_pairs = url.query_pairs();
|
||||
query_pairs.find(|(k, _v)| k == "request_id").map(|(_k, v)| v.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,11 +81,12 @@ export function getAnyModel(id: string): AnyModel | null {
|
||||
}
|
||||
|
||||
export function getModel<M extends AnyModel['model'], T extends ExtractModel<AnyModel, M>>(
|
||||
modelType: M | M[],
|
||||
modelType: M | ReadonlyArray<M>,
|
||||
id: string,
|
||||
): T | null {
|
||||
let data = mustStore().get(modelStoreDataAtom);
|
||||
for (const t of Array.isArray(modelType) ? modelType : [modelType]) {
|
||||
const types: ReadonlyArray<M> = Array.isArray(modelType) ? modelType : [modelType];
|
||||
for (const t of types) {
|
||||
let v = data[t][id];
|
||||
if (v?.model === t) return v as T;
|
||||
}
|
||||
@@ -139,7 +140,7 @@ export async function deleteModel<M extends AnyModel['model'], T extends Extract
|
||||
export function duplicateModelById<
|
||||
M extends AnyModel['model'],
|
||||
T extends ExtractModel<AnyModel, M>,
|
||||
>(modelType: M | M[], id: string) {
|
||||
>(modelType: M | ReadonlyArray<M>, id: string) {
|
||||
let model = getModel<M, T>(modelType, id);
|
||||
return duplicateModel(model);
|
||||
}
|
||||
@@ -150,6 +151,8 @@ export function duplicateModel<M extends AnyModel['model'], T extends ExtractMod
|
||||
if (model == null) {
|
||||
throw new Error('Failed to delete null model');
|
||||
}
|
||||
if ('sortPriority' in model) model.sortPriority = model.sortPriority + 0.0001;
|
||||
|
||||
return invoke<string>('plugin:yaak-models|duplicate', { model });
|
||||
}
|
||||
|
||||
|
||||
23
src-tauri/yaak-models/src/queries/any_request.rs
Normal file
23
src-tauri/yaak-models/src/queries/any_request.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
use crate::db_context::DbContext;
|
||||
use crate::error::Result;
|
||||
use crate::models::{
|
||||
GrpcRequest, HttpRequest, WebsocketRequest,
|
||||
};
|
||||
|
||||
pub enum AnyRequest {
|
||||
HttpRequest(HttpRequest),
|
||||
GrpcRequest(GrpcRequest),
|
||||
WebsocketRequest(WebsocketRequest),
|
||||
}
|
||||
|
||||
impl<'a> DbContext<'a> {
|
||||
pub fn get_any_request(&self, id: &str) -> Result<AnyRequest> {
|
||||
if let Ok(http_request) = self.get_http_request(id) {
|
||||
Ok(AnyRequest::HttpRequest(http_request))
|
||||
} else if let Ok(grpc_request) = self.get_grpc_request(id) {
|
||||
Ok(AnyRequest::GrpcRequest(grpc_request))
|
||||
} else {
|
||||
Ok(AnyRequest::WebsocketRequest(self.get_websocket_request(id)?))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::db_context::DbContext;
|
||||
use crate::error::Result;
|
||||
use crate::models::{HttpRequest, HttpRequestHeader, HttpRequestIden};
|
||||
use crate::models::{Folder, FolderIden, HttpRequest, HttpRequestHeader, HttpRequestIden};
|
||||
use crate::util::UpdateSource;
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
@@ -89,4 +89,18 @@ impl<'a> DbContext<'a> {
|
||||
|
||||
Ok(headers)
|
||||
}
|
||||
|
||||
pub fn list_http_requests_for_folder_recursive(
|
||||
&self,
|
||||
folder_id: &str,
|
||||
) -> Result<Vec<HttpRequest>> {
|
||||
let mut children = Vec::new();
|
||||
for m in self.find_many::<Folder>(FolderIden::FolderId, folder_id, None)? {
|
||||
children.extend(self.list_http_requests_for_folder_recursive(&m.id)?);
|
||||
}
|
||||
for m in self.find_many::<HttpRequest>(FolderIden::FolderId, folder_id, None)? {
|
||||
children.push(m);
|
||||
}
|
||||
Ok(children)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod any_request;
|
||||
mod batch;
|
||||
mod cookie_jars;
|
||||
mod environments;
|
||||
|
||||
@@ -123,6 +123,9 @@ pub enum InternalEventPayload {
|
||||
RenderGrpcRequestRequest(RenderGrpcRequestRequest),
|
||||
RenderGrpcRequestResponse(RenderGrpcRequestResponse),
|
||||
|
||||
TemplateRenderRequest(TemplateRenderRequest),
|
||||
TemplateRenderResponse(TemplateRenderResponse),
|
||||
|
||||
GetKeyValueRequest(GetKeyValueRequest),
|
||||
GetKeyValueResponse(GetKeyValueResponse),
|
||||
SetKeyValueRequest(SetKeyValueRequest),
|
||||
@@ -135,9 +138,6 @@ pub enum InternalEventPayload {
|
||||
WindowCloseEvent,
|
||||
CloseWindowRequest(CloseWindowRequest),
|
||||
|
||||
TemplateRenderRequest(TemplateRenderRequest),
|
||||
TemplateRenderResponse(TemplateRenderResponse),
|
||||
|
||||
ShowToastRequest(ShowToastRequest),
|
||||
ShowToastResponse(EmptyPayload),
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ impl<'de> Deserialize<'de> for SyncModel {
|
||||
fn migrate_environment(obj: &mut Mapping) {
|
||||
match (obj.get("base"), obj.get("parentModel")) {
|
||||
(Some(Value::Bool(base)), None) => {
|
||||
debug!("Migrating legacy environment {}", serde_yaml::to_string(obj).unwrap());
|
||||
debug!("Migrating legacy environment {:?}", obj.get("id"));
|
||||
if *base {
|
||||
obj.insert("parentModel".into(), "workspace".into());
|
||||
} else {
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
export * from './bindings/parser';
|
||||
import { Tokens } from './bindings/parser';
|
||||
import { parse_template } from './pkg';
|
||||
import { escape_template, parse_template, unescape_template } from './pkg';
|
||||
|
||||
export function parseTemplate(template: string) {
|
||||
return parse_template(template) as Tokens;
|
||||
}
|
||||
|
||||
export function escapeTemplate(template: string) {
|
||||
return escape_template(template) as string;
|
||||
}
|
||||
|
||||
export function unescapeTemplate(template: string) {
|
||||
return unescape_template(template) as string;
|
||||
}
|
||||
|
||||
166
src-tauri/yaak-templates/src/escape.rs
Normal file
166
src-tauri/yaak-templates/src/escape.rs
Normal file
@@ -0,0 +1,166 @@
|
||||
pub fn escape_template(text: &str) -> String {
|
||||
let mut result = String::with_capacity(text.len());
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let mut i = 0;
|
||||
|
||||
while i < chars.len() {
|
||||
// Check if we're at "${["
|
||||
if i + 2 < chars.len() && chars[i] == '$' && chars[i + 1] == '{' && chars[i + 2] == '[' {
|
||||
// Count preceding backslashes
|
||||
let mut backslash_count = 0;
|
||||
let mut j = i;
|
||||
while j > 0 && chars[j - 1] == '\\' {
|
||||
backslash_count += 1;
|
||||
j -= 1;
|
||||
}
|
||||
|
||||
// If odd number of backslashes, the $ is escaped
|
||||
// If even number (including 0), the $ is not escaped
|
||||
let already_escaped = backslash_count % 2 == 1;
|
||||
|
||||
if already_escaped {
|
||||
// Already escaped, just add the current character
|
||||
result.push(chars[i]);
|
||||
} else {
|
||||
// Not escaped, add backslash before $
|
||||
result.push('\\');
|
||||
result.push(chars[i]);
|
||||
}
|
||||
} else {
|
||||
result.push(chars[i]);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub fn unescape_template(text: &str) -> String {
|
||||
let mut result = String::with_capacity(text.len());
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let mut i = 0;
|
||||
|
||||
while i < chars.len() {
|
||||
// Check if we're at "\${["
|
||||
if i + 3 < chars.len()
|
||||
&& chars[i] == '\\'
|
||||
&& chars[i + 1] == '$'
|
||||
&& chars[i + 2] == '{'
|
||||
&& chars[i + 3] == '['
|
||||
{
|
||||
// Count preceding backslashes (before the current backslash)
|
||||
let mut backslash_count = 0;
|
||||
let mut j = i;
|
||||
while j > 0 && chars[j - 1] == '\\' {
|
||||
backslash_count += 1;
|
||||
j -= 1;
|
||||
}
|
||||
|
||||
// If even number of preceding backslashes, this backslash escapes the $
|
||||
// If odd number, this backslash is itself escaped
|
||||
let escapes_dollar = backslash_count % 2 == 0;
|
||||
|
||||
if escapes_dollar {
|
||||
// Skip the backslash, just add the $
|
||||
result.push(chars[i + 1]);
|
||||
i += 1; // Skip the backslash
|
||||
} else {
|
||||
// This backslash is escaped itself, keep it
|
||||
result.push(chars[i]);
|
||||
}
|
||||
} else {
|
||||
result.push(chars[i]);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::escape::{escape_template, unescape_template};
|
||||
|
||||
#[test]
|
||||
fn test_escape_simple() {
|
||||
let input = r#"${[foo]}"#;
|
||||
let expected = r#"\${[foo]}"#;
|
||||
assert_eq!(escape_template(input), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_already_escaped() {
|
||||
let input = r#"\${[bar]}"#;
|
||||
let expected = r#"\${[bar]}"#;
|
||||
assert_eq!(escape_template(input), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_double_backslash() {
|
||||
let input = r#"\\${[bar]}"#;
|
||||
let expected = r#"\\\${[bar]}"#;
|
||||
assert_eq!(escape_template(input), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_escape_with_surrounding_text() {
|
||||
let input = r#"text ${[var]} more"#;
|
||||
let expected = r#"text \${[var]} more"#;
|
||||
assert_eq!(escape_template(input), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_preserve_already_escaped() {
|
||||
let input = r#"already \${[escaped]}"#;
|
||||
let expected = r#"already \${[escaped]}"#;
|
||||
assert_eq!(escape_template(input), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_occurrences() {
|
||||
let input = r#"${[one]} and ${[two]}"#;
|
||||
let expected = r#"\${[one]} and \${[two]}"#;
|
||||
assert_eq!(escape_template(input), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mixed_escaped_and_unescaped() {
|
||||
let input = r#"mixed \${[esc]} and ${[unesc]}"#;
|
||||
let expected = r#"mixed \${[esc]} and \${[unesc]}"#;
|
||||
assert_eq!(escape_template(input), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unescape_simple() {
|
||||
let input = r#"\${[foo]}"#;
|
||||
let expected = r#"${[foo]}"#;
|
||||
assert_eq!(unescape_template(input), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unescape_with_text() {
|
||||
let input = r#"text \${[var]} more"#;
|
||||
let expected = r#"text ${[var]} more"#;
|
||||
assert_eq!(unescape_template(input), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unescape_multiple() {
|
||||
let input = r#"\${[one]} and \${[two]}"#;
|
||||
let expected = r#"${[one]} and ${[two]}"#;
|
||||
assert_eq!(unescape_template(input), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unescape_double_backslash() {
|
||||
let input = r#"\\\${[bar]}"#;
|
||||
let expected = r#"\\${[bar]}"#;
|
||||
assert_eq!(unescape_template(input), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unescape_plain_text() {
|
||||
let input = r#"${[foo]}"#;
|
||||
let expected = r#"${[foo]}"#;
|
||||
assert_eq!(unescape_template(input), expected);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
pub mod error;
|
||||
pub mod escape;
|
||||
pub mod format;
|
||||
pub mod parser;
|
||||
pub mod renderer;
|
||||
pub mod error;
|
||||
pub mod wasm;
|
||||
|
||||
pub use parser::*;
|
||||
|
||||
@@ -170,7 +170,13 @@ impl Parser {
|
||||
let start_pos = self.pos;
|
||||
|
||||
while self.pos < self.chars.len() {
|
||||
if self.match_str("${[") {
|
||||
if self.match_str(r#"\\"#) {
|
||||
// Skip double-escapes so we don't trigger our own escapes in the next case
|
||||
self.curr_text += r#"\\"#;
|
||||
} else if self.match_str(r#"\${["#) {
|
||||
// Unescaped template syntax so we treat it as a string
|
||||
self.curr_text += "${[";
|
||||
} else if self.match_str("${[") {
|
||||
let start_curr = self.pos;
|
||||
if let Some(t) = self.parse_tag()? {
|
||||
self.push_token(t);
|
||||
@@ -490,6 +496,39 @@ mod tests {
|
||||
use crate::error::Result;
|
||||
use crate::*;
|
||||
|
||||
#[test]
|
||||
fn escaped() -> Result<()> {
|
||||
let mut p = Parser::new(r#"\${[ foo ]}"#);
|
||||
assert_eq!(
|
||||
p.parse()?.tokens,
|
||||
vec![
|
||||
Token::Raw {
|
||||
text: "${[ foo ]}".to_string()
|
||||
},
|
||||
Token::Eof
|
||||
]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escaped_tricky() -> Result<()> {
|
||||
let mut p = Parser::new(r#"\\${[ foo ]}"#);
|
||||
assert_eq!(
|
||||
p.parse()?.tokens,
|
||||
vec![
|
||||
Token::Raw {
|
||||
text: r#"\\"#.to_string()
|
||||
},
|
||||
Token::Tag {
|
||||
val: Val::Var { name: "foo".into() }
|
||||
},
|
||||
Token::Eof
|
||||
]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn var_simple() -> Result<()> {
|
||||
let mut p = Parser::new("${[ foo ]}");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::error::Result;
|
||||
use crate::Parser;
|
||||
use crate::{escape, Parser};
|
||||
use wasm_bindgen::prelude::wasm_bindgen;
|
||||
use wasm_bindgen::JsValue;
|
||||
|
||||
@@ -7,4 +7,16 @@ use wasm_bindgen::JsValue;
|
||||
pub fn parse_template(template: &str) -> Result<JsValue> {
|
||||
let tokens = Parser::new(template).parse()?;
|
||||
Ok(serde_wasm_bindgen::to_value(&tokens).unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn escape_template(template: &str) -> Result<JsValue> {
|
||||
let escaped = escape::escape_template(template);
|
||||
Ok(serde_wasm_bindgen::to_value(&escaped).unwrap())
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn unescape_template(template: &str) -> Result<JsValue> {
|
||||
let escaped = escape::unescape_template(template);
|
||||
Ok(serde_wasm_bindgen::to_value(&escaped).unwrap())
|
||||
}
|
||||
|
||||
28
src-web/commands/moveToWorkspace.tsx
Normal file
28
src-web/commands/moveToWorkspace.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { GrpcRequest, HttpRequest, WebsocketRequest } from '@yaakapp-internal/models';
|
||||
import React from 'react';
|
||||
import { MoveToWorkspaceDialog } from '../components/MoveToWorkspaceDialog';
|
||||
import { activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace';
|
||||
import { createFastMutation } from '../hooks/useFastMutation';
|
||||
import { showDialog } from '../lib/dialog';
|
||||
import { jotaiStore } from '../lib/jotai';
|
||||
|
||||
export const moveToWorkspace = createFastMutation({
|
||||
mutationKey: ['move_workspace'],
|
||||
mutationFn: async (request: HttpRequest | GrpcRequest | WebsocketRequest) => {
|
||||
const activeWorkspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
||||
if (activeWorkspaceId == null) return;
|
||||
|
||||
showDialog({
|
||||
id: 'change-workspace',
|
||||
title: 'Move Workspace',
|
||||
size: 'sm',
|
||||
render: ({ hide }) => (
|
||||
<MoveToWorkspaceDialog
|
||||
onDone={hide}
|
||||
request={request}
|
||||
activeWorkspaceId={activeWorkspaceId}
|
||||
/>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1,11 +1,21 @@
|
||||
import { getModel } from '@yaakapp-internal/models';
|
||||
import { Icon } from '../components/core/Icon';
|
||||
import { HStack } from '../components/core/Stacks';
|
||||
import type { FolderSettingsTab } from '../components/FolderSettingsDialog';
|
||||
import { FolderSettingsDialog } from '../components/FolderSettingsDialog';
|
||||
import { showDialog } from '../lib/dialog';
|
||||
import { resolvedModelName } from '../lib/resolvedModelName';
|
||||
|
||||
export function openFolderSettings(folderId: string, tab?: FolderSettingsTab) {
|
||||
const folder = getModel('folder', folderId);
|
||||
showDialog({
|
||||
id: 'folder-settings',
|
||||
title: 'Folder Settings',
|
||||
title: (
|
||||
<HStack space={2} alignItems="center">
|
||||
<Icon icon="folder_cog" size="xl" color="secondary" />
|
||||
{resolvedModelName(folder)}
|
||||
</HStack>
|
||||
),
|
||||
size: 'lg',
|
||||
className: 'h-[50rem]',
|
||||
noPadding: true,
|
||||
|
||||
@@ -16,8 +16,8 @@ import { useAllRequests } from '../hooks/useAllRequests';
|
||||
import { useCreateWorkspace } from '../hooks/useCreateWorkspace';
|
||||
import { useDebouncedState } from '../hooks/useDebouncedState';
|
||||
import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown';
|
||||
import { useGrpcRequestActions } from '../hooks/useGrpcRequestActions';
|
||||
import type { HotkeyAction } from '../hooks/useHotKey';
|
||||
import { useHotKey } from '../hooks/useHotKey';
|
||||
import { useHttpRequestActions } from '../hooks/useHttpRequestActions';
|
||||
import { useRecentEnvironments } from '../hooks/useRecentEnvironments';
|
||||
import { useRecentRequests } from '../hooks/useRecentRequests';
|
||||
@@ -61,6 +61,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
||||
const [selectedItemKey, setSelectedItemKey] = useState<string | null>(null);
|
||||
const activeEnvironment = useActiveEnvironment();
|
||||
const httpRequestActions = useHttpRequestActions();
|
||||
const grpcRequestActions = useGrpcRequestActions();
|
||||
const workspaceId = useAtomValue(activeWorkspaceIdAtom);
|
||||
const workspaces = useAtomValue(workspacesAtom);
|
||||
const { baseEnvironment, subEnvironments } = useEnvironmentsBreakdown();
|
||||
@@ -90,7 +91,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
||||
onSelect: createWorkspace,
|
||||
},
|
||||
{
|
||||
key: 'http_request.create',
|
||||
key: 'model.create',
|
||||
label: 'Create HTTP Request',
|
||||
onSelect: () => createRequestAndNavigate({ model: 'http_request', workspaceId }),
|
||||
},
|
||||
@@ -142,8 +143,8 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
||||
|
||||
if (activeRequest?.model === 'http_request') {
|
||||
commands.push({
|
||||
key: 'http_request.send',
|
||||
action: 'http_request.send',
|
||||
key: 'request.send',
|
||||
action: 'request.send',
|
||||
label: 'Send Request',
|
||||
onSelect: () => sendRequest(activeRequest.id),
|
||||
});
|
||||
@@ -157,6 +158,17 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
||||
}
|
||||
}
|
||||
|
||||
if (activeRequest?.model === 'grpc_request') {
|
||||
for (let i = 0; i < grpcRequestActions.length; i++) {
|
||||
const a = grpcRequestActions[i]!;
|
||||
commands.push({
|
||||
key: `grpc_request_action.${i}`,
|
||||
label: a.label,
|
||||
onSelect: () => a.call(activeRequest),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (activeRequest != null) {
|
||||
commands.push({
|
||||
key: 'http_request.rename',
|
||||
@@ -182,6 +194,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
||||
activeRequest,
|
||||
baseEnvironment,
|
||||
createWorkspace,
|
||||
grpcRequestActions,
|
||||
httpRequestActions,
|
||||
sendRequest,
|
||||
setSidebarHidden,
|
||||
@@ -369,7 +382,6 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
const index = filteredAllItems.findIndex((v) => v.key === selectedItem?.key);
|
||||
|
||||
if (e.key === 'ArrowDown' || (e.ctrlKey && e.key === 'n')) {
|
||||
const next = filteredAllItems[index + 1] ?? filteredAllItems[0];
|
||||
setSelectedItemKey(next?.key ?? null);
|
||||
@@ -417,9 +429,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
||||
active={v.key === selectedItem?.key}
|
||||
key={v.key}
|
||||
onClick={() => handleSelectAndClose(v.onSelect)}
|
||||
rightSlot={
|
||||
v.action && <CommandPaletteAction action={v.action} onAction={v.onSelect} />
|
||||
}
|
||||
rightSlot={v.action && <CommandPaletteAction action={v.action} />}
|
||||
>
|
||||
{v.label}
|
||||
</CommandPaletteItem>
|
||||
@@ -465,13 +475,6 @@ function CommandPaletteItem({
|
||||
);
|
||||
}
|
||||
|
||||
function CommandPaletteAction({
|
||||
action,
|
||||
onAction,
|
||||
}: {
|
||||
action: HotkeyAction;
|
||||
onAction: () => void;
|
||||
}) {
|
||||
useHotKey(action, onAction);
|
||||
function CommandPaletteAction({ action }: { action: HotkeyAction }) {
|
||||
return <HotKey className="ml-auto" action={action} />;
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<Banner color="danger" className="flex items-center gap-2">
|
||||
<Banner color="danger" className="flex items-center gap-2 overflow-auto">
|
||||
<div>
|
||||
Error rendering <InlineCode>{this.props.name}</InlineCode> component
|
||||
</div>
|
||||
|
||||
192
src-web/components/FolderLayout.tsx
Normal file
192
src-web/components/FolderLayout.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import type { Folder, GrpcRequest, HttpRequest, WebsocketRequest } from '@yaakapp-internal/models';
|
||||
import { foldersAtom } from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { allRequestsAtom } from '../hooks/useAllRequests';
|
||||
import { useLatestHttpResponse } from '../hooks/useLatestHttpResponse';
|
||||
import { sendAnyHttpRequest } from '../hooks/useSendAnyHttpRequest';
|
||||
import { showDialog } from '../lib/dialog';
|
||||
import { resolvedModelName } from '../lib/resolvedModelName';
|
||||
import { router } from '../lib/router';
|
||||
import { Button } from './core/Button';
|
||||
import { Heading } from './core/Heading';
|
||||
import { HttpResponseDurationTag } from './core/HttpResponseDurationTag';
|
||||
import { HttpStatusTag } from './core/HttpStatusTag';
|
||||
import { Icon } from './core/Icon';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import { LoadingIcon } from './core/LoadingIcon';
|
||||
import { Separator } from './core/Separator';
|
||||
import { SizeTag } from './core/SizeTag';
|
||||
import { HStack } from './core/Stacks';
|
||||
import { HttpResponsePane } from './HttpResponsePane';
|
||||
|
||||
interface Props {
|
||||
folder: Folder;
|
||||
style: CSSProperties;
|
||||
}
|
||||
|
||||
export function FolderLayout({ folder, style }: Props) {
|
||||
const folders = useAtomValue(foldersAtom);
|
||||
const requests = useAtomValue(allRequestsAtom);
|
||||
const children = useMemo(() => {
|
||||
return [
|
||||
...folders.filter((f) => f.folderId === folder.id),
|
||||
...requests.filter((r) => r.folderId === folder.id),
|
||||
];
|
||||
}, [folder.id, folders, requests]);
|
||||
|
||||
return (
|
||||
<div style={style} className="p-6 pt-4 overflow-y-auto @container">
|
||||
<HStack space={2} alignItems="center">
|
||||
<Icon icon="folder" size="xl" color="secondary" />
|
||||
<Heading level={1}>{resolvedModelName(folder)}</Heading>
|
||||
<HStack className="ml-auto" alignItems="center">
|
||||
<Button rightSlot={<Icon icon="send_horizontal" />} color="secondary" size="sm" variant="border">Send All</Button>
|
||||
</HStack>
|
||||
</HStack>
|
||||
<Separator className="mt-3 mb-8" />
|
||||
<div className="grid grid-cols-1 @lg:grid-cols-2 @4xl:grid-cols-3 gap-4 min-w-0">
|
||||
{children.map((child) => (
|
||||
<ChildCard key={child.id} child={child} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChildCard({ child }: { child: Folder | HttpRequest | GrpcRequest | WebsocketRequest }) {
|
||||
let card;
|
||||
if (child.model === 'folder') {
|
||||
card = <FolderCard folder={child} />;
|
||||
} else if (child.model === 'http_request') {
|
||||
card = <HttpRequestCard request={child} />;
|
||||
} else if (child.model === 'grpc_request') {
|
||||
card = <RequestCard request={child} />;
|
||||
} else if (child.model === 'websocket_request') {
|
||||
card = <RequestCard request={child} />;
|
||||
} else {
|
||||
card = <div>Unknown model {child['model']}</div>;
|
||||
}
|
||||
|
||||
const navigate = useCallback(async () => {
|
||||
await router.navigate({
|
||||
to: '/workspaces/$workspaceId',
|
||||
params: { workspaceId: child.workspaceId },
|
||||
search: (prev) => ({ ...prev, request_id: child.id }),
|
||||
});
|
||||
}, [child.id, child.workspaceId]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'rounded-lg bg-surface-highlight p-3 pt-1 border border-border',
|
||||
'flex flex-col gap-3',
|
||||
)}
|
||||
>
|
||||
<HStack space={2}>
|
||||
{child.model === 'folder' && <Icon icon="folder" size="lg" />}
|
||||
<Heading className="truncate" level={2}>
|
||||
{resolvedModelName(child)}
|
||||
</Heading>
|
||||
<HStack space={0.5} className="ml-auto -mr-1.5">
|
||||
<IconButton
|
||||
color="custom"
|
||||
title="Send Request"
|
||||
size="sm"
|
||||
icon="external_link"
|
||||
className="opacity-70 hover:opacity-100"
|
||||
onClick={navigate}
|
||||
/>
|
||||
<IconButton
|
||||
color="custom"
|
||||
title="Send Request"
|
||||
size="sm"
|
||||
icon="send_horizontal"
|
||||
className="opacity-70 hover:opacity-100"
|
||||
onClick={() => {
|
||||
sendAnyHttpRequest.mutate(child.id);
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
<div className="text-text-subtle">{card}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderCard({ folder }: { folder: Folder }) {
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={async () => {
|
||||
await router.navigate({
|
||||
to: '/workspaces/$workspaceId',
|
||||
params: { workspaceId: folder.workspaceId },
|
||||
search: (prev) => {
|
||||
return { ...prev, request_id: null, folder_id: folder.id };
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RequestCard({ request }: { request: HttpRequest | GrpcRequest | WebsocketRequest }) {
|
||||
return <div>TODO {request.id}</div>;
|
||||
}
|
||||
|
||||
function HttpRequestCard({ request }: { request: HttpRequest }) {
|
||||
const latestResponse = useLatestHttpResponse(request.id);
|
||||
|
||||
return (
|
||||
<div className="grid grid-rows-2 grid-cols-[minmax(0,1fr)] gap-2 overflow-hidden">
|
||||
<code className="font-mono text-editor text-info border border-info rounded px-2.5 py-0.5 truncate w-full min-w-0">
|
||||
{request.method} {request.url}
|
||||
</code>
|
||||
{latestResponse ? (
|
||||
<button
|
||||
className="block mr-auto"
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
showDialog({
|
||||
id: 'response-preview',
|
||||
title: 'Response Preview',
|
||||
size: 'md',
|
||||
className: 'h-full',
|
||||
render: () => {
|
||||
return <HttpResponsePane activeRequestId={request.id} />;
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<HStack
|
||||
space={2}
|
||||
alignItems="center"
|
||||
className={classNames(
|
||||
'cursor-default select-none',
|
||||
'whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars',
|
||||
'font-mono text-editor border rounded px-1.5 py-0.5 truncate w-full',
|
||||
)}
|
||||
>
|
||||
{latestResponse.state !== 'closed' && <LoadingIcon size="sm" />}
|
||||
<HttpStatusTag showReason response={latestResponse} />
|
||||
<span>•</span>
|
||||
<HttpResponseDurationTag response={latestResponse} />
|
||||
<span>•</span>
|
||||
<SizeTag contentLength={latestResponse.contentLength ?? 0} />
|
||||
</HStack>
|
||||
</button>
|
||||
) : (
|
||||
<div>No Responses</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -68,14 +68,15 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
|
||||
value={activeTab}
|
||||
onChangeValue={setActiveTab}
|
||||
label="Folder Settings"
|
||||
className="px-1.5 pb-2"
|
||||
className="pt-2 pb-2 pl-3 pr-1"
|
||||
layout="horizontal"
|
||||
addBorders
|
||||
tabs={tabs}
|
||||
>
|
||||
<TabContent value={TAB_AUTH} className="pt-3 overflow-y-auto h-full px-4">
|
||||
<TabContent value={TAB_AUTH} className="overflow-y-auto h-full px-4">
|
||||
<HttpAuthenticationEditor model={folder} />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_GENERAL} className="pt-3 overflow-y-auto h-full px-4">
|
||||
<TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-4">
|
||||
<VStack space={3} className="pb-3 h-full">
|
||||
<Input
|
||||
label="Folder Name"
|
||||
@@ -93,7 +94,7 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
|
||||
/>
|
||||
</VStack>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_HEADERS} className="pt-3 overflow-y-auto h-full px-4">
|
||||
<TabContent value={TAB_HEADERS} className="overflow-y-auto h-full px-4">
|
||||
<HeadersEditor
|
||||
inheritedHeaders={inheritedHeaders}
|
||||
forceUpdateKey={folder.id}
|
||||
@@ -102,7 +103,7 @@ export function FolderSettingsDialog({ folderId, tab }: Props) {
|
||||
stateKey={`headers.${folder.id}`}
|
||||
/>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_VARIABLES} className="pt-3 overflow-y-auto h-full px-4">
|
||||
<TabContent value={TAB_VARIABLES} className="overflow-y-auto h-full px-4">
|
||||
{folderEnvironment == null ? (
|
||||
<EmptyStateText>
|
||||
<VStack alignItems="center" space={1.5}>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useSubscribeActiveWorkspaceId } from '../hooks/useActiveWorkspace';
|
||||
import { useActiveWorkspaceChangedToast } from '../hooks/useActiveWorkspaceChangedToast';
|
||||
import { useSubscribeHotKeys } from '../hooks/useHotKey';
|
||||
import { useSubscribeHttpAuthentication } from '../hooks/useHttpAuthentication';
|
||||
import { useSyncFontSizeSetting } from '../hooks/useSyncFontSizeSetting';
|
||||
import { useSyncWorkspaceChildModels } from '../hooks/useSyncWorkspaceChildModels';
|
||||
@@ -18,6 +19,7 @@ export function GlobalHooks() {
|
||||
|
||||
// Other useful things
|
||||
useActiveWorkspaceChangedToast();
|
||||
useSubscribeHotKeys();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ export function GrpcConnectionLayout({ style }: Props) {
|
||||
) : grpcEvents.length >= 0 ? (
|
||||
<GrpcResponsePane activeRequest={activeRequest} methodType={methodType} />
|
||||
) : (
|
||||
<HotKeyList hotkeys={['grpc_request.send', 'sidebar.focus', 'url_bar.focus']} />
|
||||
<HotKeyList hotkeys={['request.send', 'sidebar.focus', 'url_bar.focus']} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -240,7 +240,7 @@ export function GrpcRequestPane({
|
||||
size="sm"
|
||||
variant="border"
|
||||
title={isStreaming ? 'Connect' : 'Send'}
|
||||
hotkeyAction="grpc_request.send"
|
||||
hotkeyAction="request.send"
|
||||
onClick={isStreaming ? handleSend : handleConnect}
|
||||
icon={isStreaming ? 'send_horizontal' : 'arrow_up_down'}
|
||||
/>
|
||||
@@ -250,7 +250,7 @@ export function GrpcRequestPane({
|
||||
size="sm"
|
||||
variant="border"
|
||||
title={methodType === 'unary' ? 'Send' : 'Connect'}
|
||||
hotkeyAction="grpc_request.send"
|
||||
hotkeyAction="request.send"
|
||||
onClick={isStreaming ? onCancel : handleConnect}
|
||||
disabled={methodType === 'no-schema' || methodType === 'no-method'}
|
||||
icon={
|
||||
|
||||
@@ -74,7 +74,7 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
|
||||
firstSlot={() =>
|
||||
activeConnection == null ? (
|
||||
<HotKeyList
|
||||
hotkeys={['http_request.send', 'http_request.create', 'sidebar.focus', 'url_bar.focus']}
|
||||
hotkeys={['request.send', 'model.create', 'sidebar.focus', 'url_bar.focus']}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1 items-center">
|
||||
|
||||
@@ -67,7 +67,7 @@ export function HttpAuthenticationEditor({ model }: Props) {
|
||||
</EmptyStateText>
|
||||
);
|
||||
} else {
|
||||
return <EmptyStateText>Authentication not configured</EmptyStateText>;
|
||||
return <EmptyStateText>No authentication</EmptyStateText>;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -160,7 +160,11 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
{ label: 'GraphQL', value: BODY_TYPE_GRAPHQL },
|
||||
{ label: 'JSON', value: BODY_TYPE_JSON },
|
||||
{ label: 'XML', value: BODY_TYPE_XML },
|
||||
{ label: 'Other', value: BODY_TYPE_OTHER },
|
||||
{
|
||||
label: 'Other',
|
||||
value: BODY_TYPE_OTHER,
|
||||
shortLabel: nameOfContentTypeOr(contentType, 'Other'),
|
||||
},
|
||||
{ type: 'separator', label: 'Other' },
|
||||
{ label: 'Binary File', value: BODY_TYPE_BINARY },
|
||||
{ label: 'No Body', shortLabel: 'Body', value: BODY_TYPE_NONE },
|
||||
@@ -229,6 +233,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
[
|
||||
activeRequest,
|
||||
authTab,
|
||||
contentType,
|
||||
handleContentTypeChange,
|
||||
headersTab,
|
||||
numParams,
|
||||
@@ -351,7 +356,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
label="Request"
|
||||
onChangeValue={setActiveTab}
|
||||
tabs={tabs}
|
||||
tabListClassName="mt-1 !mb-1.5"
|
||||
tabListClassName="mt-1 mb-1.5"
|
||||
>
|
||||
<TabContent value={TAB_AUTH}>
|
||||
<HttpAuthenticationEditor model={activeRequest} />
|
||||
@@ -471,3 +476,11 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function nameOfContentTypeOr(contentType: string | null, fallback: string) {
|
||||
const language = languageFromContentType(contentType);
|
||||
if (language === 'markdown') {
|
||||
return 'Markdown';
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||
>
|
||||
{activeResponse == null ? (
|
||||
<HotKeyList
|
||||
hotkeys={['http_request.send', 'http_request.create', 'sidebar.focus', 'url_bar.focus']}
|
||||
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">
|
||||
|
||||
@@ -14,7 +14,6 @@ export function LocalImage({ src: srcPath, className }: Props) {
|
||||
queryKey: ['local-image', srcPath],
|
||||
queryFn: async () => {
|
||||
const p = await resolveResource(srcPath);
|
||||
console.log("LOADING SRC", srcPath, p)
|
||||
return convertFileSrc(p);
|
||||
},
|
||||
});
|
||||
|
||||
450
src-web/components/NewSidebar.tsx
Normal file
450
src-web/components/NewSidebar.tsx
Normal file
@@ -0,0 +1,450 @@
|
||||
import type {
|
||||
Folder,
|
||||
GrpcRequest,
|
||||
HttpRequest,
|
||||
WebsocketRequest,
|
||||
Workspace,
|
||||
} from '@yaakapp-internal/models';
|
||||
import {
|
||||
duplicateModel,
|
||||
foldersAtom,
|
||||
getModel,
|
||||
grpcConnectionsAtom,
|
||||
httpResponsesAtom,
|
||||
patchModel,
|
||||
websocketConnectionsAtom,
|
||||
workspacesAtom,
|
||||
} from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
import { atom, useAtomValue } from 'jotai';
|
||||
import { selectAtom } from 'jotai/utils';
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { moveToWorkspace } from '../commands/moveToWorkspace';
|
||||
import { openFolderSettings } from '../commands/openFolderSettings';
|
||||
import { activeCookieJarAtom } from '../hooks/useActiveCookieJar';
|
||||
import { activeEnvironmentAtom } from '../hooks/useActiveEnvironment';
|
||||
import { activeFolderIdAtom } from '../hooks/useActiveFolderId';
|
||||
import { activeRequestIdAtom } from '../hooks/useActiveRequestId';
|
||||
import { activeWorkspaceAtom } from '../hooks/useActiveWorkspace';
|
||||
import { allRequestsAtom } from '../hooks/useAllRequests';
|
||||
import { getGrpcRequestActions } from '../hooks/useGrpcRequestActions';
|
||||
import { useHotKey } from '../hooks/useHotKey';
|
||||
import { getHttpRequestActions } from '../hooks/useHttpRequestActions';
|
||||
import { sendAnyHttpRequest } from '../hooks/useSendAnyHttpRequest';
|
||||
import { useSidebarHidden } from '../hooks/useSidebarHidden';
|
||||
import { deepEqualAtom } from '../lib/atoms';
|
||||
import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm';
|
||||
import { jotaiStore } from '../lib/jotai';
|
||||
import { renameModelWithPrompt } from '../lib/renameModelWithPrompt';
|
||||
import { resolvedModelName } from '../lib/resolvedModelName';
|
||||
import { isSidebarFocused } from '../lib/scopes';
|
||||
import { navigateToRequestOrFolderOrWorkspace } from '../lib/setWorkspaceSearchParams';
|
||||
import { invokeCmd } from '../lib/tauri';
|
||||
import type { ContextMenuProps, DropdownItem } from './core/Dropdown';
|
||||
import { HttpMethodTag } from './core/HttpMethodTag';
|
||||
import { HttpStatusTag } from './core/HttpStatusTag';
|
||||
import { Icon } from './core/Icon';
|
||||
import { LoadingIcon } from './core/LoadingIcon';
|
||||
import { isSelectedFamily } from './core/tree/atoms';
|
||||
import type { TreeNode } from './core/tree/common';
|
||||
import type { TreeHandle, TreeProps } from './core/tree/Tree';
|
||||
import { Tree } from './core/tree/Tree';
|
||||
import type { TreeItemProps } from './core/tree/TreeItem';
|
||||
import { GitDropdown } from './GitDropdown';
|
||||
|
||||
type Model = Workspace | Folder | HttpRequest | GrpcRequest | WebsocketRequest;
|
||||
|
||||
const opacitySubtle = 'opacity-80';
|
||||
|
||||
function getItemKey(item: Model) {
|
||||
const responses = jotaiStore.get(httpResponsesAtom);
|
||||
const latestResponse = responses.find((r) => r.requestId === item.id) ?? null;
|
||||
const url = 'url' in item ? item.url : 'n/a';
|
||||
const method = 'method' in item ? item.method : 'n/a';
|
||||
return [
|
||||
item.id,
|
||||
item.name,
|
||||
url,
|
||||
method,
|
||||
latestResponse?.elapsed,
|
||||
latestResponse?.id ?? 'n/a',
|
||||
].join('::');
|
||||
}
|
||||
|
||||
function SidebarLeftSlot({ treeId, item }: { treeId: string; item: Model }) {
|
||||
if (item.model === 'folder') {
|
||||
return <Icon icon="folder" />;
|
||||
} else if (item.model === 'workspace') {
|
||||
return null;
|
||||
} else {
|
||||
const isSelected = jotaiStore.get(isSelectedFamily({ treeId, itemId: item.id }));
|
||||
return (
|
||||
<HttpMethodTag
|
||||
short
|
||||
className={classNames('text-xs', !isSelected && opacitySubtle)}
|
||||
request={item}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function SidebarInnerItem({ item }: { treeId: string; item: Model }) {
|
||||
const response = useAtomValue(
|
||||
useMemo(
|
||||
() =>
|
||||
selectAtom(
|
||||
atom((get) => [
|
||||
...get(grpcConnectionsAtom),
|
||||
...get(httpResponsesAtom),
|
||||
...get(websocketConnectionsAtom),
|
||||
]),
|
||||
(responses) => responses.find((r) => r.requestId === item.id),
|
||||
(a, b) => a?.state === b?.state && a?.id === b?.id, // Only update when the response state changes updated
|
||||
),
|
||||
[item.id],
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 min-w-0 h-full w-full text-left">
|
||||
<div className="truncate">{resolvedModelName(item)}</div>
|
||||
{response != null && (
|
||||
<div className="ml-auto">
|
||||
{response.state !== 'closed' ? (
|
||||
<LoadingIcon size="sm" className="text-text-subtlest" />
|
||||
) : response.model === 'http_response' ? (
|
||||
<HttpStatusTag short className="text-xs" response={response} />
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NewSidebar({ className }: { className?: string }) {
|
||||
const [hidden, setHidden] = useSidebarHidden();
|
||||
const tree = useAtomValue(sidebarTreeAtom);
|
||||
const activeWorkspaceId = useAtomValue(activeWorkspaceAtom)?.id;
|
||||
const treeId = 'tree.' + (activeWorkspaceId ?? 'unknown');
|
||||
const wrapperRef = useRef<HTMLElement>(null);
|
||||
const treeRef = useRef<TreeHandle>(null);
|
||||
|
||||
const focusActiveItem = useCallback(() => {
|
||||
treeRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
useHotKey('sidebar.focus', async function focusHotkey() {
|
||||
// Hide the sidebar if it's already focused
|
||||
if (!hidden && isSidebarFocused()) {
|
||||
await setHidden(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show the sidebar if it's hidden
|
||||
if (hidden) {
|
||||
await setHidden(false);
|
||||
}
|
||||
|
||||
// Select the 0th index on focus if none selected
|
||||
focusActiveItem();
|
||||
});
|
||||
|
||||
const handleDragEnd = useCallback(async function handleDragEnd({
|
||||
items,
|
||||
parent,
|
||||
children,
|
||||
insertAt,
|
||||
}: {
|
||||
items: Model[];
|
||||
parent: Model;
|
||||
children: Model[];
|
||||
insertAt: number;
|
||||
}) {
|
||||
const prev = children[insertAt - 1] as Exclude<Model, Workspace>;
|
||||
const next = children[insertAt] as Exclude<Model, Workspace>;
|
||||
const folderId = parent.model === 'folder' ? parent.id : null;
|
||||
|
||||
const beforePriority = prev?.sortPriority ?? 0;
|
||||
const afterPriority = next?.sortPriority ?? 0;
|
||||
const shouldUpdateAll = afterPriority - beforePriority < 1;
|
||||
|
||||
try {
|
||||
if (shouldUpdateAll) {
|
||||
// Add items to children at insertAt
|
||||
children.splice(insertAt, 0, ...items);
|
||||
await Promise.all(
|
||||
children.map((m, i) => patchModel(m, { sortPriority: i * 1000, folderId })),
|
||||
);
|
||||
} else {
|
||||
const range = afterPriority - beforePriority;
|
||||
const increment = range / (items.length + 2);
|
||||
await Promise.all(
|
||||
items.map((m, i) =>
|
||||
// Spread item sortPriority out over before/after range
|
||||
patchModel(m, { sortPriority: beforePriority + (i + 1) * increment, folderId }),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTreeRefInit = useCallback((n: TreeHandle) => {
|
||||
treeRef.current = n;
|
||||
if (n == null) return;
|
||||
const activeId = jotaiStore.get(activeIdAtom);
|
||||
if (activeId == null) return;
|
||||
n.selectItem(activeId);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return jotaiStore.sub(activeIdAtom, () => {
|
||||
const activeId = jotaiStore.get(activeIdAtom);
|
||||
if (activeId == null) return;
|
||||
treeRef.current?.selectItem(activeId);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (tree == null || hidden) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
ref={wrapperRef}
|
||||
aria-hidden={hidden ?? undefined}
|
||||
className={classNames(className, 'h-full grid grid-rows-[minmax(0,1fr)_auto]')}
|
||||
>
|
||||
<Tree
|
||||
ref={handleTreeRefInit}
|
||||
root={tree}
|
||||
treeId={treeId}
|
||||
hotkeys={hotkeys}
|
||||
getItemKey={getItemKey}
|
||||
ItemInner={SidebarInnerItem}
|
||||
ItemLeftSlot={SidebarLeftSlot}
|
||||
getContextMenu={getContextMenu}
|
||||
onActivate={handleActivate}
|
||||
getEditOptions={getEditOptions}
|
||||
className="pl-2 pr-3 pt-2 pb-2"
|
||||
onDragEnd={handleDragEnd}
|
||||
/>
|
||||
<GitDropdown />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export default NewSidebar;
|
||||
|
||||
const activeIdAtom = atom<string | null>((get) => {
|
||||
return get(activeRequestIdAtom) || get(activeFolderIdAtom);
|
||||
});
|
||||
|
||||
function getEditOptions(
|
||||
item: Model,
|
||||
): ReturnType<NonNullable<TreeItemProps<Model>['getEditOptions']>> {
|
||||
return {
|
||||
onChange: handleSubmitEdit,
|
||||
defaultValue: resolvedModelName(item),
|
||||
placeholder: item.name,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSubmitEdit(item: Model, text: string) {
|
||||
await patchModel(item, { name: text });
|
||||
}
|
||||
|
||||
function handleActivate(item: Model) {
|
||||
// TODO: Add folder layout support
|
||||
if (item.model !== 'folder' && item.model !== 'workspace') {
|
||||
navigateToRequestOrFolderOrWorkspace(item.id, item.model);
|
||||
}
|
||||
}
|
||||
|
||||
const allPotentialChildrenAtom = atom<Model[]>((get) => {
|
||||
const requests = get(allRequestsAtom);
|
||||
const folders = get(foldersAtom);
|
||||
return [...requests, ...folders];
|
||||
});
|
||||
|
||||
const memoAllPotentialChildrenAtom = deepEqualAtom(allPotentialChildrenAtom);
|
||||
|
||||
const sidebarTreeAtom = atom((get) => {
|
||||
const allModels = get(memoAllPotentialChildrenAtom);
|
||||
const activeWorkspace = get(activeWorkspaceAtom);
|
||||
|
||||
const childrenMap: Record<string, Exclude<Model, Workspace>[]> = {};
|
||||
for (const item of allModels) {
|
||||
if ('folderId' in item && item.folderId == null) {
|
||||
childrenMap[item.workspaceId] = childrenMap[item.workspaceId] ?? [];
|
||||
childrenMap[item.workspaceId]!.push(item);
|
||||
} else if ('folderId' in item && item.folderId != null) {
|
||||
childrenMap[item.folderId] = childrenMap[item.folderId] ?? [];
|
||||
childrenMap[item.folderId]!.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
const treeParentMap: Record<string, TreeNode<Model>> = {};
|
||||
|
||||
if (activeWorkspace == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Put requests and folders into a tree structure
|
||||
const next = (node: TreeNode<Model>): TreeNode<Model> => {
|
||||
const childItems = childrenMap[node.item.id] ?? [];
|
||||
|
||||
// Recurse to children
|
||||
childItems.sort((a, b) => a.sortPriority - b.sortPriority);
|
||||
if (node.item.model === 'folder' || node.item.model === 'workspace') {
|
||||
node.children = node.children ?? [];
|
||||
for (const item of childItems) {
|
||||
treeParentMap[item.id] = node;
|
||||
node.children.push(next({ item, parent: node }));
|
||||
}
|
||||
}
|
||||
|
||||
return node;
|
||||
};
|
||||
|
||||
return next({
|
||||
item: activeWorkspace,
|
||||
children: [],
|
||||
parent: null,
|
||||
});
|
||||
});
|
||||
|
||||
const actions = {
|
||||
'sidebar.delete_selected_item': async function (items: Model[]) {
|
||||
await deleteModelWithConfirm(items);
|
||||
},
|
||||
'model.duplicate': async function (items: Model[]) {
|
||||
if (items.length === 1) {
|
||||
const item = items[0]!;
|
||||
const newId = await duplicateModel(item);
|
||||
navigateToRequestOrFolderOrWorkspace(newId, item.model);
|
||||
} else {
|
||||
await Promise.all(items.map(duplicateModel));
|
||||
}
|
||||
},
|
||||
'request.send': async function (items: Model[]) {
|
||||
await Promise.all(
|
||||
items.filter((i) => i.model === 'http_request').map((i) => sendAnyHttpRequest.mutate(i.id)),
|
||||
);
|
||||
},
|
||||
} as const;
|
||||
|
||||
const hotkeys: TreeProps<Model>['hotkeys'] = {
|
||||
priority: 10, // So these ones take precedence over global hotkeys when the sidebar is focused
|
||||
actions,
|
||||
enable: () => isSidebarFocused(),
|
||||
};
|
||||
|
||||
async function getContextMenu(items: Model[]): Promise<DropdownItem[]> {
|
||||
const child = items[0];
|
||||
if (child == null) return [];
|
||||
const workspaces = jotaiStore.get(workspacesAtom);
|
||||
const onlyHttpRequests = items.every((i) => i.model === 'http_request');
|
||||
|
||||
const initialItems: ContextMenuProps['items'] = [
|
||||
{
|
||||
label: 'Folder Settings',
|
||||
hidden: !(items.length === 1 && child.model === 'folder'),
|
||||
leftSlot: <Icon icon="folder_cog" />,
|
||||
onSelect: () => openFolderSettings(child.id),
|
||||
},
|
||||
{
|
||||
label: 'Send All',
|
||||
hidden: !(items.length === 1 && child.model === 'folder'),
|
||||
leftSlot: <Icon icon="send_horizontal" />,
|
||||
onSelect: () => {
|
||||
const environment = jotaiStore.get(activeEnvironmentAtom);
|
||||
const cookieJar = jotaiStore.get(activeCookieJarAtom);
|
||||
invokeCmd('cmd_send_folder', {
|
||||
folderId: child.id,
|
||||
environmentId: environment?.id,
|
||||
cookieJarId: cookieJar?.id,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Send',
|
||||
hotKeyAction: 'request.send',
|
||||
hotKeyLabelOnly: true,
|
||||
hidden: !onlyHttpRequests,
|
||||
leftSlot: <Icon icon="send_horizontal" />,
|
||||
onSelect: () => actions['request.send'](items),
|
||||
},
|
||||
...(items.length === 1 && child.model === 'http_request'
|
||||
? await getHttpRequestActions()
|
||||
: []
|
||||
).map((a) => ({
|
||||
label: a.label,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
leftSlot: <Icon icon={(a.icon as any) ?? 'empty'} />,
|
||||
onSelect: async () => {
|
||||
const request = getModel('http_request', child.id);
|
||||
if (request != null) await a.call(request);
|
||||
},
|
||||
})),
|
||||
...(items.length === 1 && child.model === 'grpc_request'
|
||||
? await getGrpcRequestActions()
|
||||
: []
|
||||
).map((a) => ({
|
||||
label: a.label,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
leftSlot: <Icon icon={(a.icon as any) ?? 'empty'} />,
|
||||
onSelect: async () => {
|
||||
const request = getModel('grpc_request', child.id);
|
||||
if (request != null) await a.call(request);
|
||||
},
|
||||
})),
|
||||
];
|
||||
|
||||
const menuItems: ContextMenuProps['items'] = [
|
||||
...initialItems,
|
||||
{ type: 'separator', hidden: initialItems.filter((v) => !v.hidden).length === 0 },
|
||||
{
|
||||
label: 'Rename',
|
||||
leftSlot: <Icon icon="pencil" />,
|
||||
hidden: items.length > 1,
|
||||
onSelect: async () => {
|
||||
const request = getModel(
|
||||
['folder', 'http_request', 'grpc_request', 'websocket_request'],
|
||||
child.id,
|
||||
);
|
||||
await renameModelWithPrompt(request);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Duplicate',
|
||||
hotKeyAction: 'model.duplicate',
|
||||
hotKeyLabelOnly: true, // Would trigger for every request (bad)
|
||||
leftSlot: <Icon icon="copy" />,
|
||||
onSelect: () => actions['model.duplicate'](items),
|
||||
},
|
||||
{
|
||||
label: 'Move',
|
||||
leftSlot: <Icon icon="arrow_right_circle" />,
|
||||
hidden:
|
||||
workspaces.length <= 1 ||
|
||||
items.length > 1 ||
|
||||
child.model === 'folder' ||
|
||||
child.model === 'workspace',
|
||||
onSelect: () => {
|
||||
if (child.model === 'folder' || child.model === 'workspace') return;
|
||||
moveToWorkspace.mutate(child);
|
||||
},
|
||||
},
|
||||
{
|
||||
color: 'danger',
|
||||
label: 'Delete',
|
||||
hotKeyAction: 'sidebar.delete_selected_item',
|
||||
hotKeyLabelOnly: true,
|
||||
leftSlot: <Icon icon="trash" />,
|
||||
onSelect: () => actions['sidebar.delete_selected_item'](items),
|
||||
},
|
||||
];
|
||||
return menuItems;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import classNames from 'classnames';
|
||||
import { FocusTrap } from 'focus-trap-react';
|
||||
import * as m from 'motion/react-m';
|
||||
import type { ReactNode } from 'react';
|
||||
import React from 'react';
|
||||
import React, { useRef } from 'react';
|
||||
import { Portal } from './Portal';
|
||||
|
||||
interface Props {
|
||||
@@ -32,6 +32,8 @@ export function Overlay({
|
||||
noBackdrop,
|
||||
children,
|
||||
}: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
if (noBackdrop) {
|
||||
return (
|
||||
<Portal name={portalName}>
|
||||
@@ -44,15 +46,33 @@ export function Overlay({
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Portal name={portalName}>
|
||||
{open && (
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
allowOutsideClick: true, // So we can still click toasts and things
|
||||
delayInitialFocus: true,
|
||||
fallbackFocus: () => containerRef.current!, // always have a target
|
||||
initialFocus: () =>
|
||||
// Doing this explicitly seems to work better than the default behavior for some reason
|
||||
containerRef.current?.querySelector<HTMLElement>(
|
||||
[
|
||||
'a[href]',
|
||||
'input:not([disabled])',
|
||||
'select:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'button:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
'[contenteditable]:not([contenteditable="false"])',
|
||||
].join(', '),
|
||||
) ?? undefined,
|
||||
}}
|
||||
>
|
||||
<m.div
|
||||
ref={containerRef}
|
||||
tabIndex={-1}
|
||||
className={classNames('fixed inset-0', zIndexes[zIndex])}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
|
||||
@@ -32,7 +32,7 @@ export function RedirectToLatestWorkspace() {
|
||||
request_id: requestId,
|
||||
};
|
||||
|
||||
console.log("Redirecting to workspace", params, search);
|
||||
console.log('Redirecting to workspace', params, search);
|
||||
await router.navigate({ to: '/workspaces/$workspaceId', params, search });
|
||||
})();
|
||||
}, [recentWorkspaces, workspaces, workspaces.length]);
|
||||
|
||||
@@ -25,9 +25,8 @@ export function ResizeHandle({
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
draggable
|
||||
style={style}
|
||||
onDragStart={onResizeStart}
|
||||
onPointerDown={onResizeStart}
|
||||
onDoubleClick={onReset}
|
||||
className={classNames(
|
||||
className,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import classNames from 'classnames';
|
||||
import mime from 'mime';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { ButtonProps } from './core/Button';
|
||||
import { Button } from './core/Button';
|
||||
import { IconButton } from './core/IconButton';
|
||||
@@ -51,9 +53,43 @@ export function SelectFile({
|
||||
|
||||
const itemLabel = noun ?? (directory ? 'Folder' : 'File');
|
||||
const selectOrChange = (filePath ? 'Change ' : 'Select ') + itemLabel;
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Listen for dropped files on the element
|
||||
// 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();
|
||||
};
|
||||
}, [isHovering, onChange]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div ref={ref} className="w-full">
|
||||
{label && (
|
||||
<Label htmlFor={null} help={help}>
|
||||
{label}
|
||||
@@ -66,8 +102,9 @@ export function SelectFile({
|
||||
'rtl mr-1.5',
|
||||
inline && 'w-full',
|
||||
filePath && inline && 'font-mono text-xs',
|
||||
isHovering && '!border-notice',
|
||||
)}
|
||||
color="secondary"
|
||||
color={isHovering ? 'primary' : 'secondary'}
|
||||
onClick={handleClick}
|
||||
size={size}
|
||||
{...props}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useFloatingSidebarHidden } from '../../hooks/useFloatingSidebarHidden';
|
||||
import { useShouldFloatSidebar } from '../../hooks/useShouldFloatSidebar';
|
||||
import { useSidebarHidden } from '../../hooks/useSidebarHidden';
|
||||
import { IconButton } from '../core/IconButton';
|
||||
import { HStack } from '../core/Stacks';
|
||||
import { CreateDropdown } from '../CreateDropdown';
|
||||
import { useFloatingSidebarHidden } from '../hooks/useFloatingSidebarHidden';
|
||||
import { useShouldFloatSidebar } from '../hooks/useShouldFloatSidebar';
|
||||
import { useSidebarHidden } from '../hooks/useSidebarHidden';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import { HStack } from './core/Stacks';
|
||||
import { CreateDropdown } from './CreateDropdown';
|
||||
|
||||
export function SidebarActions() {
|
||||
const floating = useShouldFloatSidebar();
|
||||
@@ -31,7 +31,7 @@ export function SidebarActions() {
|
||||
icon={hidden ? 'left_panel_hidden' : 'left_panel_visible'}
|
||||
iconColor="secondary"
|
||||
/>
|
||||
<CreateDropdown hotKeyAction="http_request.create">
|
||||
<CreateDropdown hotKeyAction="model.create">
|
||||
<IconButton size="sm" icon="plus_circle" iconColor="secondary" title="Add Resource" />
|
||||
</CreateDropdown>
|
||||
</HStack>
|
||||
@@ -98,7 +98,7 @@ export const UrlBar = memo(function UrlBar({
|
||||
className="w-8 mr-0.5 !h-full"
|
||||
iconColor="secondary"
|
||||
icon={isLoading ? 'x' : submitIcon}
|
||||
hotkeyAction="http_request.send"
|
||||
hotkeyAction="request.send"
|
||||
onMouseDown={(e) => {
|
||||
// Prevent the button from taking focus
|
||||
e.preventDefault();
|
||||
|
||||
@@ -72,7 +72,7 @@ export function WebsocketResponsePane({ activeRequest }: Props) {
|
||||
firstSlot={() =>
|
||||
activeConnection == null ? (
|
||||
<HotKeyList
|
||||
hotkeys={['http_request.send', 'http_request.create', 'sidebar.focus', 'url_bar.focus']}
|
||||
hotkeys={['request.send', 'model.create', 'sidebar.focus', 'url_bar.focus']}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full grid grid-rows-[auto_minmax(0,1fr)] items-center">
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
activeEnvironmentAtom,
|
||||
useSubscribeActiveEnvironmentId,
|
||||
} from '../hooks/useActiveEnvironment';
|
||||
import { activeFolderAtom } from '../hooks/useActiveFolder';
|
||||
import { useSubscribeActiveFolderId } from '../hooks/useActiveFolderId';
|
||||
import { activeRequestAtom } from '../hooks/useActiveRequest';
|
||||
import { useSubscribeActiveRequestId } from '../hooks/useActiveRequestId';
|
||||
import { activeWorkspaceAtom } from '../hooks/useActiveWorkspace';
|
||||
@@ -26,7 +28,7 @@ import { useSidebarHidden } from '../hooks/useSidebarHidden';
|
||||
import { useSidebarWidth } from '../hooks/useSidebarWidth';
|
||||
import { useSyncWorkspaceRequestTitle } from '../hooks/useSyncWorkspaceRequestTitle';
|
||||
import { useToggleCommandPalette } from '../hooks/useToggleCommandPalette';
|
||||
import { duplicateRequestAndNavigate } from '../lib/duplicateRequestAndNavigate';
|
||||
import { duplicateRequestOrFolderAndNavigate } from '../lib/duplicateRequestOrFolderAndNavigate';
|
||||
import { importData } from '../lib/importData';
|
||||
import { jotaiStore } from '../lib/jotai';
|
||||
import { Banner } from './core/Banner';
|
||||
@@ -36,13 +38,14 @@ import { FeedbackLink } from './core/Link';
|
||||
import { HStack } from './core/Stacks';
|
||||
import { CreateDropdown } from './CreateDropdown';
|
||||
import { ErrorBoundary } from './ErrorBoundary';
|
||||
import { FolderLayout } from './FolderLayout';
|
||||
import { GrpcConnectionLayout } from './GrpcConnectionLayout';
|
||||
import { HeaderSize } from './HeaderSize';
|
||||
import { HttpRequestLayout } from './HttpRequestLayout';
|
||||
import NewSidebar from './NewSidebar';
|
||||
import { Overlay } from './Overlay';
|
||||
import { ResizeHandle } from './ResizeHandle';
|
||||
import { Sidebar } from './sidebar/Sidebar';
|
||||
import { SidebarActions } from './sidebar/SidebarActions';
|
||||
import { SidebarActions } from './SidebarActions';
|
||||
import { WebsocketRequestLayout } from './WebsocketRequestLayout';
|
||||
import { WorkspaceHeader } from './WorkspaceHeader';
|
||||
|
||||
@@ -161,7 +164,7 @@ export function Workspace() {
|
||||
<SidebarActions />
|
||||
</HeaderSize>
|
||||
<ErrorBoundary name="Sidebar (Floating)">
|
||||
<Sidebar />
|
||||
<NewSidebar />
|
||||
</ErrorBoundary>
|
||||
</m.div>
|
||||
</Overlay>
|
||||
@@ -169,7 +172,7 @@ export function Workspace() {
|
||||
<>
|
||||
<div style={side} className={classNames('x-theme-sidebar', 'overflow-hidden bg-surface')}>
|
||||
<ErrorBoundary name="Sidebar">
|
||||
<Sidebar className="border-r border-border-subtle" />
|
||||
<NewSidebar className="border-r border-border-subtle" />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
<ResizeHandle
|
||||
@@ -193,7 +196,7 @@ export function Workspace() {
|
||||
style={environmentBgStyle}
|
||||
className="absolute inset-0 opacity-5"
|
||||
/>
|
||||
<div // Add subtle border bottom
|
||||
<div // Add a subtle border bottom
|
||||
style={environmentBgStyle}
|
||||
className="absolute left-0 right-0 bottom-0 h-[0.5px] opacity-20"
|
||||
/>
|
||||
@@ -209,6 +212,7 @@ export function Workspace() {
|
||||
|
||||
function WorkspaceBody() {
|
||||
const activeRequest = useAtomValue(activeRequestAtom);
|
||||
const activeFolder = useAtomValue(activeFolderAtom);
|
||||
const activeWorkspace = useAtomValue(activeWorkspaceAtom);
|
||||
|
||||
if (activeWorkspace == null) {
|
||||
@@ -228,39 +232,40 @@ function WorkspaceBody() {
|
||||
);
|
||||
}
|
||||
|
||||
if (activeRequest == null) {
|
||||
return (
|
||||
<HotKeyList
|
||||
hotkeys={['http_request.create', 'sidebar.focus', 'settings.show']}
|
||||
bottomSlot={
|
||||
<HStack space={1} justifyContent="center" className="mt-3">
|
||||
<Button variant="border" size="sm" onClick={() => importData.mutate()}>
|
||||
Import
|
||||
</Button>
|
||||
<CreateDropdown hideFolder>
|
||||
<Button variant="border" forDropdown size="sm">
|
||||
New Request
|
||||
</Button>
|
||||
</CreateDropdown>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
);
|
||||
if (activeRequest?.model === 'grpc_request') {
|
||||
return <GrpcConnectionLayout style={body} />;
|
||||
} else if (activeRequest?.model === 'websocket_request') {
|
||||
return <WebsocketRequestLayout style={body} activeRequest={activeRequest} />;
|
||||
} else if (activeRequest?.model === 'http_request') {
|
||||
return <HttpRequestLayout activeRequest={activeRequest} style={body} />;
|
||||
} else if (activeFolder != null) {
|
||||
return <FolderLayout folder={activeFolder} style={body} />;
|
||||
}
|
||||
|
||||
if (activeRequest.model === 'grpc_request') {
|
||||
return <GrpcConnectionLayout style={body} />;
|
||||
} else if (activeRequest.model === 'websocket_request') {
|
||||
return <WebsocketRequestLayout style={body} activeRequest={activeRequest} />;
|
||||
} else {
|
||||
return <HttpRequestLayout activeRequest={activeRequest} style={body} />;
|
||||
}
|
||||
return (
|
||||
<HotKeyList
|
||||
hotkeys={['model.create', 'sidebar.focus', 'settings.show']}
|
||||
bottomSlot={
|
||||
<HStack space={1} justifyContent="center" className="mt-3">
|
||||
<Button variant="border" size="sm" onClick={() => importData.mutate()}>
|
||||
Import
|
||||
</Button>
|
||||
<CreateDropdown hideFolder>
|
||||
<Button variant="border" forDropdown size="sm">
|
||||
New Request
|
||||
</Button>
|
||||
</CreateDropdown>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function useGlobalWorkspaceHooks() {
|
||||
useEnsureActiveCookieJar();
|
||||
|
||||
useSubscribeActiveRequestId();
|
||||
useSubscribeActiveFolderId();
|
||||
useSubscribeActiveEnvironmentId();
|
||||
useSubscribeActiveCookieJarId();
|
||||
|
||||
@@ -274,7 +279,7 @@ function useGlobalWorkspaceHooks() {
|
||||
const toggleCommandPalette = useToggleCommandPalette();
|
||||
useHotKey('command_palette.toggle', toggleCommandPalette);
|
||||
|
||||
useHotKey('http_request.duplicate', () =>
|
||||
duplicateRequestAndNavigate(jotaiStore.get(activeRequestAtom)),
|
||||
useHotKey('model.duplicate', () =>
|
||||
duplicateRequestOrFolderAndNavigate(jotaiStore.get(activeRequestAtom)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { ImportCurlButton } from './ImportCurlButton';
|
||||
import { LicenseBadge } from './LicenseBadge';
|
||||
import { RecentRequestsDropdown } from './RecentRequestsDropdown';
|
||||
import { SettingsDropdown } from './SettingsDropdown';
|
||||
import { SidebarActions } from './sidebar/SidebarActions';
|
||||
import { SidebarActions } from './SidebarActions';
|
||||
import { WorkspaceActionsDropdown } from './WorkspaceActionsDropdown';
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -221,7 +221,7 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
|
||||
);
|
||||
});
|
||||
|
||||
interface ContextMenuProps {
|
||||
export interface ContextMenuProps {
|
||||
triggerPosition: { x: number; y: number } | null;
|
||||
className?: string;
|
||||
items: DropdownProps['items'];
|
||||
|
||||
@@ -24,11 +24,13 @@ import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import type { WrappedEnvironmentVariable } from '../../../hooks/useEnvironmentVariables';
|
||||
import { useEnvironmentVariables } from '../../../hooks/useEnvironmentVariables';
|
||||
import { useRandomKey } from '../../../hooks/useRandomKey';
|
||||
import { useRequestEditor } from '../../../hooks/useRequestEditor';
|
||||
import { useTemplateFunctionCompletionOptions } from '../../../hooks/useTemplateFunctions';
|
||||
import { showDialog } from '../../../lib/dialog';
|
||||
@@ -114,7 +116,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
|
||||
disabled,
|
||||
extraExtensions,
|
||||
forcedEnvironmentId,
|
||||
forceUpdateKey,
|
||||
forceUpdateKey: forceUpdateKeyFromAbove,
|
||||
format,
|
||||
heightMode,
|
||||
hideGutter,
|
||||
@@ -145,6 +147,10 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
|
||||
? allEnvironmentVariables.filter(autocompleteVariables)
|
||||
: allEnvironmentVariables;
|
||||
}, [allEnvironmentVariables, autocompleteVariables]);
|
||||
// Track a local key for updates. If the default value is changed when the input is not in focus,
|
||||
// regenerate this to force the field to update.
|
||||
const [focusedUpdateKey, regenerateFocusedUpdateKey] = useRandomKey();
|
||||
const forceUpdateKey = `${forceUpdateKeyFromAbove}::${focusedUpdateKey}`;
|
||||
|
||||
if (settings && wrapLines === undefined) {
|
||||
wrapLines = settings.editorSoftWrap;
|
||||
@@ -340,6 +346,17 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
|
||||
[],
|
||||
);
|
||||
|
||||
// Force input to update when receiving change and not in focus
|
||||
useLayoutEffect(() => {
|
||||
const currDoc = cm.current?.view.state.doc.toString() || '';
|
||||
const nextDoc = defaultValue || '';
|
||||
const notFocused = !cm.current?.view.hasFocus;
|
||||
const hasChanged = currDoc !== nextDoc;
|
||||
if (notFocused && hasChanged) {
|
||||
regenerateFocusedUpdateKey();
|
||||
}
|
||||
}, [defaultValue, regenerateFocusedUpdateKey]);
|
||||
|
||||
const [, { focusParamValue }] = useRequestEditor();
|
||||
const onClickPathParameter = useCallback(
|
||||
async (name: string) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { settingsAtom } from '@yaakapp-internal/models';
|
||||
import type { GrpcRequest, HttpRequest, WebsocketRequest } from '@yaakapp-internal/models';
|
||||
import { settingsAtom } from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
import { useAtomValue } from 'jotai';
|
||||
|
||||
@@ -18,42 +18,69 @@ const methodNames: Record<string, string> = {
|
||||
options: 'OPTN',
|
||||
head: 'HEAD',
|
||||
query: 'QURY',
|
||||
graphql: 'GQL',
|
||||
grpc: 'GRPC',
|
||||
websocket: 'WS',
|
||||
};
|
||||
|
||||
export function HttpMethodTag({ request, className, short }: Props) {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const method =
|
||||
request.model === 'http_request' && request.bodyType === 'graphql'
|
||||
? 'GQL'
|
||||
? 'graphql'
|
||||
: request.model === 'grpc_request'
|
||||
? 'GRPC'
|
||||
? 'grpc'
|
||||
: request.model === 'websocket_request'
|
||||
? 'WS'
|
||||
? 'websocket'
|
||||
: request.method;
|
||||
let label = method.toUpperCase();
|
||||
|
||||
return (
|
||||
<HttpMethodTagRaw
|
||||
method={method}
|
||||
colored={settings.coloredMethods}
|
||||
className={className}
|
||||
short={short}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function HttpMethodTagRaw({
|
||||
className,
|
||||
method,
|
||||
colored,
|
||||
short,
|
||||
}: {
|
||||
method: string;
|
||||
className?: string;
|
||||
colored: boolean;
|
||||
short?: boolean;
|
||||
}) {
|
||||
let label = method.toUpperCase();
|
||||
if (short) {
|
||||
label = methodNames[method.toLowerCase()] ?? method.slice(0, 4);
|
||||
label = label.padStart(4, ' ');
|
||||
}
|
||||
|
||||
const m = method.toUpperCase();
|
||||
|
||||
return (
|
||||
<span
|
||||
className={classNames(
|
||||
className,
|
||||
!settings.coloredMethods && 'text-text-subtle',
|
||||
settings.coloredMethods && method === 'GQL' && 'text-info',
|
||||
settings.coloredMethods && method === 'WS' && 'text-info',
|
||||
settings.coloredMethods && method === 'GRPC' && 'text-info',
|
||||
settings.coloredMethods && method === 'OPTIONS' && 'text-info',
|
||||
settings.coloredMethods && method === 'HEAD' && 'text-info',
|
||||
settings.coloredMethods && method === 'GET' && 'text-primary',
|
||||
settings.coloredMethods && method === 'PUT' && 'text-warning',
|
||||
settings.coloredMethods && method === 'PATCH' && 'text-notice',
|
||||
settings.coloredMethods && method === 'POST' && 'text-success',
|
||||
settings.coloredMethods && method === 'DELETE' && 'text-danger',
|
||||
!colored && 'text-text-subtle',
|
||||
colored && m === 'GRAPHQL' && 'text-info',
|
||||
colored && m === 'WEBSOCKET' && 'text-info',
|
||||
colored && m === 'GRPC' && 'text-info',
|
||||
colored && m === 'QUERY' && 'text-secondary',
|
||||
colored && m === 'OPTIONS' && 'text-info',
|
||||
colored && m === 'HEAD' && 'text-secondary',
|
||||
colored && m === 'GET' && 'text-primary',
|
||||
colored && m === 'PUT' && 'text-warning',
|
||||
colored && m === 'PATCH' && 'text-notice',
|
||||
colored && m === 'POST' && 'text-success',
|
||||
colored && m === 'DELETE' && 'text-danger',
|
||||
'font-mono flex-shrink-0 whitespace-pre',
|
||||
'pt-[0.25em]', // Fix for monospace font not vertically centering
|
||||
'pt-[0.15em]', // Fix for monospace font not vertically centering
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
|
||||
@@ -12,6 +12,7 @@ export function HttpResponseDurationTag({ response }: Props) {
|
||||
// Calculate the duration of the response for use when the response hasn't finished yet
|
||||
useEffect(() => {
|
||||
clearInterval(timeout.current);
|
||||
if (response.state === 'closed') return;
|
||||
timeout.current = setInterval(() => {
|
||||
setFallbackElapsed(Date.now() - new Date(response.createdAt + 'Z').getTime());
|
||||
}, 100);
|
||||
|
||||
@@ -33,7 +33,7 @@ export function HttpStatusTag({ response, className, showReason, short }: Props)
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={classNames(className, 'font-mono', colorClass)}>
|
||||
<span className={classNames(className, 'font-mono min-w-0', colorClass)}>
|
||||
{label} {showReason && 'statusReason' in response ? response.statusReason : null}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -39,6 +39,7 @@ const icons = {
|
||||
code: lucide.CodeIcon,
|
||||
columns_2: lucide.Columns2Icon,
|
||||
command: lucide.CommandIcon,
|
||||
corner_right_up: lucide.CornerRightUpIcon,
|
||||
credit_card: lucide.CreditCardIcon,
|
||||
cookie: lucide.CookieIcon,
|
||||
copy: lucide.CopyIcon,
|
||||
@@ -54,6 +55,7 @@ const icons = {
|
||||
flame: lucide.FlameIcon,
|
||||
flask: lucide.FlaskConicalIcon,
|
||||
folder: lucide.FolderIcon,
|
||||
folder_cog: lucide.FolderCogIcon,
|
||||
folder_code: lucide.FolderCodeIcon,
|
||||
folder_git: lucide.FolderGitIcon,
|
||||
folder_input: lucide.FolderInputIcon,
|
||||
@@ -61,6 +63,7 @@ const icons = {
|
||||
folder_output: lucide.FolderOutputIcon,
|
||||
folder_symlink: lucide.FolderSymlinkIcon,
|
||||
folder_sync: lucide.FolderSyncIcon,
|
||||
folder_up: lucide.FolderUpIcon,
|
||||
git_branch: lucide.GitBranchIcon,
|
||||
git_branch_plus: lucide.GitBranchPlusIcon,
|
||||
git_commit: lucide.GitCommitIcon,
|
||||
@@ -118,7 +121,7 @@ const icons = {
|
||||
x: lucide.XIcon,
|
||||
_unknown: lucide.ShieldAlertIcon,
|
||||
|
||||
empty: (props: HTMLAttributes<HTMLSpanElement>) => <span {...props} />,
|
||||
empty: (props: HTMLAttributes<HTMLSpanElement>) => <div {...props} />,
|
||||
};
|
||||
|
||||
export interface IconProps {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { EditorSelection } from '@codemirror/state';
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import type { Color } from '@yaakapp-internal/plugins';
|
||||
import classNames from 'classnames';
|
||||
@@ -164,7 +165,7 @@ const BaseInput = forwardRef<EditorView, InputProps>(function InputBase(
|
||||
setFocused(false);
|
||||
// Move selection to the end on blur
|
||||
editorRef.current?.dispatch({
|
||||
selection: { anchor: editorRef.current.state.doc.length },
|
||||
selection: EditorSelection.single(editorRef.current.state.doc.length ),
|
||||
});
|
||||
onBlur?.();
|
||||
}, [onBlur]);
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import classNames from 'classnames';
|
||||
import type { FocusEvent, HTMLAttributes } from 'react';
|
||||
import { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react';
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useRandomKey } from '../../hooks/useRandomKey';
|
||||
import { useStateWithDeps } from '../../hooks/useStateWithDeps';
|
||||
import { IconButton } from './IconButton';
|
||||
import type { InputProps } from './Input';
|
||||
@@ -22,7 +30,7 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
|
||||
className,
|
||||
containerClassName,
|
||||
defaultValue,
|
||||
forceUpdateKey,
|
||||
forceUpdateKey: forceUpdateKeyFromAbove,
|
||||
help,
|
||||
hideLabel,
|
||||
hideObscureToggle,
|
||||
@@ -47,15 +55,21 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
// Track a local key for updates. If the default value is changed when the input is not in focus,
|
||||
// regenerate this to force the field to update.
|
||||
const [focusedUpdateKey, regenerateFocusedUpdateKey] = useRandomKey();
|
||||
const forceUpdateKey = `${forceUpdateKeyFromAbove}::${focusedUpdateKey}`;
|
||||
|
||||
const [obscured, setObscured] = useStateWithDeps(type === 'password', [type]);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [hasChanged, setHasChanged] = useStateWithDeps<boolean>(false, [forceUpdateKey]);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useImperativeHandle<{ focus: () => void } | null, { focus: () => void } | null>(
|
||||
ref,
|
||||
() => inputRef.current,
|
||||
);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const handleFocus = useCallback(
|
||||
(e: FocusEvent<HTMLInputElement>) => {
|
||||
@@ -75,6 +89,13 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
|
||||
onBlur?.();
|
||||
}, [onBlur]);
|
||||
|
||||
// Force input to update when receiving change and not in focus
|
||||
useLayoutEffect(() => {
|
||||
if (!focused) {
|
||||
regenerateFocusedUpdateKey();
|
||||
}
|
||||
}, [focused, regenerateFocusedUpdateKey, defaultValue]);
|
||||
|
||||
const id = `input-${name}`;
|
||||
const commonClassName = classNames(
|
||||
className,
|
||||
@@ -152,9 +173,9 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
|
||||
)}
|
||||
>
|
||||
<input
|
||||
id={id}
|
||||
ref={inputRef}
|
||||
key={forceUpdateKey}
|
||||
id={id}
|
||||
type={type === 'password' && !obscured ? 'text' : type}
|
||||
defaultValue={defaultValue ?? undefined}
|
||||
autoComplete="off"
|
||||
|
||||
@@ -33,7 +33,15 @@ export function RadioDropdown<T = string | null>({
|
||||
}: RadioDropdownProps<T>) {
|
||||
const dropdownItems = useMemo(
|
||||
() => [
|
||||
...((itemsBefore ? [...itemsBefore, { type: 'separator' }] : []) as DropdownItem[]),
|
||||
...((itemsBefore
|
||||
? [
|
||||
...itemsBefore,
|
||||
{
|
||||
type: 'separator',
|
||||
hidden: itemsBefore[itemsBefore.length - 1]?.type === 'separator',
|
||||
},
|
||||
]
|
||||
: []) as DropdownItem[]),
|
||||
...items.map((item) => {
|
||||
if (item.type === 'separator') {
|
||||
return item;
|
||||
@@ -47,7 +55,9 @@ export function RadioDropdown<T = string | null>({
|
||||
} as DropdownItem;
|
||||
}
|
||||
}),
|
||||
...((itemsAfter ? [{ type: 'separator' }, ...itemsAfter] : []) as DropdownItem[]),
|
||||
...((itemsAfter
|
||||
? [{ type: 'separator', hidden: itemsAfter[0]?.type === 'separator' }, ...itemsAfter]
|
||||
: []) as DropdownItem[]),
|
||||
],
|
||||
[itemsBefore, items, itemsAfter, value, onChange],
|
||||
);
|
||||
|
||||
@@ -88,15 +88,15 @@ export function SplitLayout({
|
||||
|
||||
const unsub = () => {
|
||||
if (moveState.current !== null) {
|
||||
document.documentElement.removeEventListener('mousemove', moveState.current.move);
|
||||
document.documentElement.removeEventListener('mouseup', moveState.current.up);
|
||||
document.documentElement.removeEventListener('pointermove', moveState.current.move);
|
||||
document.documentElement.removeEventListener('pointerup', moveState.current.up);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = useCallback(
|
||||
() => (vertical ? setHeight(defaultRatio) : setWidth(defaultRatio)),
|
||||
[vertical, setHeight, defaultRatio, setWidth],
|
||||
);
|
||||
const handleReset = useCallback(() => {
|
||||
if (vertical) setHeight(defaultRatio);
|
||||
else setWidth(defaultRatio);
|
||||
}, [vertical, setHeight, defaultRatio, setWidth]);
|
||||
|
||||
const handleResizeStart = useCallback(
|
||||
(e: ReactMouseEvent<HTMLDivElement>) => {
|
||||
@@ -112,6 +112,7 @@ export function SplitLayout({
|
||||
|
||||
moveState.current = {
|
||||
move: (e: MouseEvent) => {
|
||||
setIsResizing(true); // Set this here so we don't block double-clicks
|
||||
e.preventDefault(); // Prevent text selection and things
|
||||
if (vertical) {
|
||||
const maxHeightPx = containerRect.height - minHeightPx;
|
||||
@@ -137,9 +138,8 @@ export function SplitLayout({
|
||||
setIsResizing(false);
|
||||
},
|
||||
};
|
||||
document.documentElement.addEventListener('mousemove', moveState.current.move);
|
||||
document.documentElement.addEventListener('mouseup', moveState.current.up);
|
||||
setIsResizing(true);
|
||||
document.documentElement.addEventListener('pointermove', moveState.current.move);
|
||||
document.documentElement.addEventListener('pointerup', moveState.current.up);
|
||||
},
|
||||
[width, height, vertical, minHeightPx, setHeight, minWidthPx, setWidth],
|
||||
);
|
||||
|
||||
@@ -83,12 +83,13 @@ export function Tabs({
|
||||
aria-label={label}
|
||||
className={classNames(
|
||||
tabListClassName,
|
||||
addBorders && '!-ml-1',
|
||||
'flex items-center hide-scrollbars mb-2',
|
||||
addBorders && layout === 'horizontal' && 'pl-3 -ml-1',
|
||||
addBorders && layout === 'vertical' && 'ml-0 mb-2',
|
||||
'flex items-center hide-scrollbars',
|
||||
layout === 'horizontal' && 'h-full overflow-auto p-2 -mr-2',
|
||||
layout === 'vertical' && 'overflow-x-auto overflow-y-visible ',
|
||||
// Give space for button focus states within overflow boundary.
|
||||
layout === 'vertical' && 'py-1 -ml-5 pl-3 pr-1',
|
||||
!addBorders && layout === 'vertical' && 'py-1 pl-3 -ml-5 pr-1',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
@@ -124,6 +125,8 @@ export function Tabs({
|
||||
<RadioDropdown
|
||||
key={t.value}
|
||||
items={t.options.items}
|
||||
itemsAfter={t.options.itemsAfter}
|
||||
itemsBefore={t.options.itemsBefore}
|
||||
value={t.options.value}
|
||||
onChange={t.options.onChange}
|
||||
>
|
||||
|
||||
63
src-web/components/core/tree/AutoScrollWhileDragging.tsx
Normal file
63
src-web/components/core/tree/AutoScrollWhileDragging.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
// AutoScrollWhileDragging.tsx
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useDragLayer } from 'react-dnd';
|
||||
|
||||
type Props = {
|
||||
container: HTMLElement | null | undefined;
|
||||
edgeDistance?: number;
|
||||
maxSpeedPerFrame?: number;
|
||||
};
|
||||
|
||||
export function AutoScrollWhileDragging({
|
||||
container,
|
||||
edgeDistance = 30,
|
||||
maxSpeedPerFrame = 6,
|
||||
}: Props) {
|
||||
const rafId = useRef<number | null>(null);
|
||||
|
||||
const { isDragging, pointer } = useDragLayer((monitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
pointer: monitor.getClientOffset(), // { x, y } | null
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (!container || !isDragging) {
|
||||
if (rafId.current != null) cancelAnimationFrame(rafId.current);
|
||||
rafId.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const tick = () => {
|
||||
if (!container || !isDragging || !pointer) return;
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
const y = pointer.y;
|
||||
|
||||
// Compute vertical speed based on proximity to edges
|
||||
let dy = 0;
|
||||
if (y < rect.top + edgeDistance) {
|
||||
const t = (rect.top + edgeDistance - y) / edgeDistance; // 0..1
|
||||
dy = -Math.min(maxSpeedPerFrame, Math.ceil(t * maxSpeedPerFrame));
|
||||
} else if (y > rect.bottom - edgeDistance) {
|
||||
const t = (y - (rect.bottom - edgeDistance)) / edgeDistance; // 0..1
|
||||
dy = Math.min(maxSpeedPerFrame, Math.ceil(t * maxSpeedPerFrame));
|
||||
}
|
||||
|
||||
if (dy !== 0) {
|
||||
// Only scroll if there’s more content in that direction
|
||||
const prev = container.scrollTop;
|
||||
container.scrollTop = prev + dy;
|
||||
}
|
||||
|
||||
rafId.current = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
rafId.current = requestAnimationFrame(tick);
|
||||
return () => {
|
||||
if (rafId.current != null) cancelAnimationFrame(rafId.current);
|
||||
rafId.current = null;
|
||||
};
|
||||
}, [container, isDragging, pointer, edgeDistance, maxSpeedPerFrame]);
|
||||
|
||||
return null;
|
||||
}
|
||||
557
src-web/components/core/tree/Tree.tsx
Normal file
557
src-web/components/core/tree/Tree.tsx
Normal file
@@ -0,0 +1,557 @@
|
||||
import type { DragEndEvent, DragMoveEvent, DragStartEvent } from '@dnd-kit/core';
|
||||
import {
|
||||
DndContext,
|
||||
PointerSensor,
|
||||
pointerWithin,
|
||||
useDroppable,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { type } from '@tauri-apps/plugin-os';
|
||||
import classNames from 'classnames';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import type { ComponentType, ReactElement, Ref, RefAttributes } from 'react';
|
||||
import {
|
||||
forwardRef,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useKey, useKeyPressEvent } from 'react-use';
|
||||
import type { HotkeyAction, HotKeyOptions } from '../../../hooks/useHotKey';
|
||||
import { useHotKey } from '../../../hooks/useHotKey';
|
||||
import { sidebarCollapsedAtom } from '../../../hooks/useSidebarItemCollapsed';
|
||||
import { jotaiStore } from '../../../lib/jotai';
|
||||
import type { ContextMenuProps } from '../Dropdown';
|
||||
import { draggingIdsFamily, focusIdsFamily, hoveredParentFamily, selectedIdsFamily } from './atoms';
|
||||
import type { SelectableTreeNode, TreeNode } from './common';
|
||||
import { computeSideForDragMove, equalSubtree, getSelectedItems, hasAncestor } from './common';
|
||||
import { TreeDragOverlay } from './TreeDragOverlay';
|
||||
import type { TreeItemProps } from './TreeItem';
|
||||
import type { TreeItemListProps } from './TreeItemList';
|
||||
import { TreeItemList } from './TreeItemList';
|
||||
|
||||
export interface TreeProps<T extends { id: string }> {
|
||||
root: TreeNode<T>;
|
||||
treeId: string;
|
||||
getItemKey: (item: T) => string;
|
||||
getContextMenu?: (items: T[]) => Promise<ContextMenuProps['items']>;
|
||||
ItemInner: ComponentType<{ treeId: string; item: T }>;
|
||||
ItemLeftSlot?: ComponentType<{ treeId: string; item: T }>;
|
||||
className?: string;
|
||||
onActivate?: (item: T) => void;
|
||||
onDragEnd?: (opt: { items: T[]; parent: T; children: T[]; insertAt: number }) => void;
|
||||
hotkeys?: { actions: Partial<Record<HotkeyAction, (items: T[]) => void>> } & HotKeyOptions;
|
||||
getEditOptions?: (item: T) => {
|
||||
defaultValue: string;
|
||||
placeholder?: string;
|
||||
onChange: (item: T, text: string) => void;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TreeHandle {
|
||||
focus: () => void;
|
||||
selectItem: (id: string) => void;
|
||||
}
|
||||
|
||||
function TreeInner<T extends { id: string }>(
|
||||
{
|
||||
className,
|
||||
getContextMenu,
|
||||
getEditOptions,
|
||||
getItemKey,
|
||||
hotkeys,
|
||||
onActivate,
|
||||
onDragEnd,
|
||||
ItemInner,
|
||||
ItemLeftSlot,
|
||||
root,
|
||||
treeId,
|
||||
}: TreeProps<T>,
|
||||
ref: Ref<TreeHandle>,
|
||||
) {
|
||||
const treeRef = useRef<HTMLDivElement>(null);
|
||||
const { treeParentMap, selectableItems } = useTreeParentMap(root, getItemKey);
|
||||
const [isFocused, setIsFocused] = useState<boolean>(false);
|
||||
|
||||
const tryFocus = useCallback(() => {
|
||||
treeRef.current?.querySelector<HTMLButtonElement>('.tree-item button[tabindex="0"]')?.focus();
|
||||
}, []);
|
||||
|
||||
const setSelected = useCallback(
|
||||
function setSelected(ids: string[], focus: boolean) {
|
||||
jotaiStore.set(selectedIdsFamily(treeId), ids);
|
||||
// TODO: Figure out a better way than timeout
|
||||
if (focus) setTimeout(tryFocus, 50);
|
||||
},
|
||||
[treeId, tryFocus],
|
||||
);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
(): TreeHandle => ({
|
||||
focus: tryFocus,
|
||||
selectItem(id) {
|
||||
setSelected([id], false);
|
||||
jotaiStore.set(focusIdsFamily(treeId), { anchorId: id, lastId: id });
|
||||
},
|
||||
}),
|
||||
[setSelected, treeId, tryFocus],
|
||||
);
|
||||
|
||||
const handleGetContextMenu = useMemo(() => {
|
||||
if (getContextMenu == null) return;
|
||||
return (item: T) => {
|
||||
const items = getSelectedItems(treeId, selectableItems);
|
||||
const isSelected = items.find((i) => i.id === item.id);
|
||||
if (isSelected) {
|
||||
// If right-clicked an item that was in the multiple-selection, use the entire selection
|
||||
return getContextMenu(items);
|
||||
} else {
|
||||
// If right-clicked an item that was NOT in the multiple-selection, just use that one
|
||||
// Also update the selection with it
|
||||
jotaiStore.set(selectedIdsFamily(treeId), [item.id]);
|
||||
jotaiStore.set(focusIdsFamily(treeId), (prev) => ({ ...prev, lastId: item.id }));
|
||||
return getContextMenu([item]);
|
||||
}
|
||||
};
|
||||
}, [getContextMenu, selectableItems, treeId]);
|
||||
|
||||
const handleSelect = useCallback<NonNullable<TreeItemProps<T>['onClick']>>(
|
||||
(item, { shiftKey, metaKey, ctrlKey }) => {
|
||||
const anchorSelectedId = jotaiStore.get(focusIdsFamily(treeId)).anchorId;
|
||||
const selectedIdsAtom = selectedIdsFamily(treeId);
|
||||
const selectedIds = jotaiStore.get(selectedIdsAtom);
|
||||
|
||||
// Mark item as the last one selected
|
||||
jotaiStore.set(focusIdsFamily(treeId), (prev) => ({ ...prev, lastId: item.id }));
|
||||
|
||||
if (shiftKey) {
|
||||
const anchorIndex = selectableItems.findIndex((i) => i.node.item.id === anchorSelectedId);
|
||||
const currIndex = selectableItems.findIndex((v) => v.node.item.id === item.id);
|
||||
// Nothing was selected yet, so just select this item
|
||||
if (selectedIds.length === 0 || anchorIndex === -1 || currIndex === -1) {
|
||||
setSelected([item.id], true);
|
||||
jotaiStore.set(focusIdsFamily(treeId), (prev) => ({ ...prev, anchorId: item.id }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (currIndex > anchorIndex) {
|
||||
// Selecting down
|
||||
const itemsToSelect = selectableItems.slice(anchorIndex, currIndex + 1);
|
||||
setSelected(
|
||||
itemsToSelect.map((v) => v.node.item.id),
|
||||
true,
|
||||
);
|
||||
} else if (currIndex < anchorIndex) {
|
||||
// Selecting up
|
||||
const itemsToSelect = selectableItems.slice(currIndex, anchorIndex + 1);
|
||||
setSelected(
|
||||
itemsToSelect.map((v) => v.node.item.id),
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
setSelected([item.id], true);
|
||||
}
|
||||
} else if (type() === 'macos' ? metaKey : ctrlKey) {
|
||||
const withoutCurr = selectedIds.filter((id) => id !== item.id);
|
||||
if (withoutCurr.length === selectedIds.length) {
|
||||
// It wasn't in there, so add it
|
||||
setSelected([...selectedIds, item.id], true);
|
||||
} else {
|
||||
// It was in there, so remove it
|
||||
setSelected(withoutCurr, true);
|
||||
}
|
||||
} else {
|
||||
// Select single
|
||||
setSelected([item.id], true);
|
||||
jotaiStore.set(focusIdsFamily(treeId), (prev) => ({ ...prev, anchorId: item.id }));
|
||||
}
|
||||
},
|
||||
[selectableItems, setSelected, treeId],
|
||||
);
|
||||
|
||||
const handleClick = useCallback<NonNullable<TreeItemProps<T>['onClick']>>(
|
||||
(item, e) => {
|
||||
if (e.shiftKey || e.ctrlKey || e.metaKey) {
|
||||
handleSelect(item, e);
|
||||
} else {
|
||||
handleSelect(item, e);
|
||||
onActivate?.(item);
|
||||
}
|
||||
},
|
||||
[handleSelect, onActivate],
|
||||
);
|
||||
|
||||
useKey(
|
||||
'ArrowUp',
|
||||
(e) => {
|
||||
if (!treeRef.current?.contains(document.activeElement)) return;
|
||||
e.preventDefault();
|
||||
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;
|
||||
const index = selectableItems.findIndex((i) => i.node.item.id === lastSelectedId);
|
||||
const item = selectableItems[index - 1];
|
||||
if (item != null) handleSelect(item.node.item, e);
|
||||
},
|
||||
undefined,
|
||||
[selectableItems, handleSelect],
|
||||
);
|
||||
|
||||
useKey(
|
||||
'ArrowDown',
|
||||
(e) => {
|
||||
if (!treeRef.current?.contains(document.activeElement)) return;
|
||||
e.preventDefault();
|
||||
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;
|
||||
const index = selectableItems.findIndex((i) => i.node.item.id === lastSelectedId);
|
||||
const item = selectableItems[index + 1];
|
||||
if (item != null) handleSelect(item.node.item, e);
|
||||
},
|
||||
undefined,
|
||||
[selectableItems, handleSelect],
|
||||
);
|
||||
|
||||
useKeyPressEvent('Escape', async () => {
|
||||
if (!treeRef.current?.contains(document.activeElement)) return;
|
||||
clearDragState();
|
||||
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;
|
||||
if (lastSelectedId == null) return;
|
||||
setSelected([lastSelectedId], false);
|
||||
});
|
||||
|
||||
const handleDragMove = useCallback(
|
||||
function handleDragMove(e: DragMoveEvent) {
|
||||
const over = e.over;
|
||||
if (!over) {
|
||||
// Clear the drop indicator when hovering outside the tree
|
||||
jotaiStore.set(hoveredParentFamily(treeId), { parentId: null, index: null });
|
||||
return;
|
||||
}
|
||||
|
||||
// Not sure when or if this happens
|
||||
if (e.active.rect.current.initial == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Root is anything past the end of the list, so set it to the end
|
||||
const hoveringRoot = over.id === root.item.id;
|
||||
if (hoveringRoot) {
|
||||
jotaiStore.set(hoveredParentFamily(treeId), {
|
||||
parentId: root.item.id,
|
||||
index: root.children?.length ?? 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const node = selectableItems.find((i) => i.node.item.id === over.id)?.node ?? null;
|
||||
if (node == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const side = computeSideForDragMove(node, e);
|
||||
|
||||
const item = node.item;
|
||||
let hoveredParent = treeParentMap[item.id] ?? null;
|
||||
const dragIndex = hoveredParent?.children?.findIndex((n) => n.item.id === item.id) ?? -99;
|
||||
const hovered = hoveredParent?.children?.[dragIndex] ?? null;
|
||||
let hoveredIndex = dragIndex + (side === 'above' ? 0 : 1);
|
||||
|
||||
const collapsedMap = jotaiStore.get(jotaiStore.get(sidebarCollapsedAtom));
|
||||
const isHoveredItemCollapsed = hovered != null ? collapsedMap[hovered.item.id] : false;
|
||||
|
||||
if (hovered?.children != null && side === 'below' && !isHoveredItemCollapsed) {
|
||||
// Move into the folder if it's open and we're moving below it
|
||||
hoveredParent = hoveredParent?.children?.find((n) => n.item.id === item.id) ?? null;
|
||||
hoveredIndex = 0;
|
||||
}
|
||||
|
||||
jotaiStore.set(hoveredParentFamily(treeId), {
|
||||
parentId: hoveredParent?.item.id ?? null,
|
||||
index: hoveredIndex,
|
||||
});
|
||||
},
|
||||
[root.children?.length, root.item.id, selectableItems, treeId, treeParentMap],
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
function handleDragStart(e: DragStartEvent) {
|
||||
const item = selectableItems.find((i) => i.node.item.id === e.active.id)?.node.item ?? null;
|
||||
if (item == null) return;
|
||||
|
||||
const selectedItems = getSelectedItems(treeId, selectableItems);
|
||||
const isDraggingSelectedItem = selectedItems.find((i) => i.id === item.id);
|
||||
if (isDraggingSelectedItem) {
|
||||
jotaiStore.set(
|
||||
draggingIdsFamily(treeId),
|
||||
selectedItems.map((i) => i.id),
|
||||
);
|
||||
} else {
|
||||
jotaiStore.set(draggingIdsFamily(treeId), [item.id]);
|
||||
// Also update selection to just be this one
|
||||
handleSelect(item, { shiftKey: false, metaKey: false, ctrlKey: false });
|
||||
}
|
||||
},
|
||||
[handleSelect, selectableItems, treeId],
|
||||
);
|
||||
|
||||
const clearDragState = useCallback(() => {
|
||||
jotaiStore.set(hoveredParentFamily(treeId), { parentId: null, index: null });
|
||||
jotaiStore.set(draggingIdsFamily(treeId), []);
|
||||
}, [treeId]);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
function handleDragEnd(e: DragEndEvent) {
|
||||
// Get this from the store so our callback doesn't change all the time
|
||||
const hovered = jotaiStore.get(hoveredParentFamily(treeId));
|
||||
const draggingItems = jotaiStore.get(draggingIdsFamily(treeId));
|
||||
clearDragState();
|
||||
|
||||
// Dropped outside the tree?
|
||||
if (e.over == null) return;
|
||||
|
||||
const hoveredParent =
|
||||
hovered.parentId == root.item.id
|
||||
? root
|
||||
: selectableItems.find((n) => n.node.item.id === hovered.parentId)?.node;
|
||||
|
||||
if (hoveredParent == null || hovered.index == null || !draggingItems?.length) return;
|
||||
|
||||
// Optional tiny guard: don't drop into itself
|
||||
if (draggingItems.some((id) => id === hovered.parentId)) return;
|
||||
|
||||
// Resolve the actual tree nodes for each dragged item (keeps order of draggingItems)
|
||||
const draggedNodes: TreeNode<T>[] = draggingItems
|
||||
.map((id) => {
|
||||
const parent = treeParentMap[id];
|
||||
const idx = parent?.children?.findIndex((n) => n.item.id === id) ?? -1;
|
||||
return idx >= 0 ? parent!.children![idx]! : null;
|
||||
})
|
||||
.filter((n) => n != null)
|
||||
// Filter out invalid drags (dragging into descendant)
|
||||
.filter((n) => !hasAncestor(hoveredParent, n.item.id));
|
||||
|
||||
// Work on a local copy of target children
|
||||
const nextChildren = [...(hoveredParent.children ?? [])];
|
||||
|
||||
// Remove any of the dragged nodes already in the target, adjusting hoveredIndex
|
||||
let insertAt = hovered.index;
|
||||
for (const node of draggedNodes) {
|
||||
const i = nextChildren.findIndex((n) => n.item.id === node.item.id);
|
||||
if (i !== -1) {
|
||||
nextChildren.splice(i, 1);
|
||||
if (i < insertAt) insertAt -= 1; // account for removed-before
|
||||
}
|
||||
}
|
||||
|
||||
// Batch callback
|
||||
onDragEnd?.({
|
||||
items: draggedNodes.map((n) => n.item),
|
||||
parent: hoveredParent.item,
|
||||
children: nextChildren.map((c) => c.item),
|
||||
insertAt,
|
||||
});
|
||||
},
|
||||
[treeId, clearDragState, root, selectableItems, onDragEnd, treeParentMap],
|
||||
);
|
||||
|
||||
const treeItemListProps: Omit<
|
||||
TreeItemListProps<T>,
|
||||
'node' | 'treeId' | 'activeIdAtom' | 'hoveredParent' | 'hoveredIndex'
|
||||
> = {
|
||||
depth: 0,
|
||||
getItemKey,
|
||||
getContextMenu: handleGetContextMenu,
|
||||
onClick: handleClick,
|
||||
getEditOptions,
|
||||
ItemInner,
|
||||
ItemLeftSlot,
|
||||
};
|
||||
|
||||
const handleFocus = useCallback(function handleFocus() {
|
||||
setIsFocused(true);
|
||||
}, []);
|
||||
|
||||
const handleBlur = useCallback(function handleBlur() {
|
||||
setIsFocused(false);
|
||||
}, []);
|
||||
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
|
||||
|
||||
return (
|
||||
<>
|
||||
<TreeHotKeys treeId={treeId} hotkeys={hotkeys} selectableItems={selectableItems} />
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={pointerWithin}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={clearDragState}
|
||||
onDragAbort={clearDragState}
|
||||
onDragMove={handleDragMove}
|
||||
autoScroll
|
||||
>
|
||||
<div
|
||||
ref={treeRef}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
className={classNames(
|
||||
className,
|
||||
'outline-none h-full',
|
||||
'overflow-y-auto overflow-x-hidden',
|
||||
'grid grid-rows-[auto_1fr]',
|
||||
' [&_.tree-item.selected]:text-text',
|
||||
isFocused
|
||||
? '[&_.tree-item.selected]:bg-surface-active'
|
||||
: '[&_.tree-item.selected]:bg-surface-highlight',
|
||||
)}
|
||||
>
|
||||
<TreeItemList node={root} treeId={treeId} {...treeItemListProps} />
|
||||
{/* Assign root ID so we can reuse our same move/end logic */}
|
||||
<DropRegionAfterList id={root.item.id} />
|
||||
<TreeDragOverlay
|
||||
treeId={treeId}
|
||||
root={root}
|
||||
selectableItems={selectableItems}
|
||||
ItemInner={ItemInner}
|
||||
getItemKey={getItemKey}
|
||||
/>
|
||||
</div>
|
||||
</DndContext>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// 1) Preserve generics through forwardRef:
|
||||
const Tree_ = forwardRef(TreeInner) as <T extends { id: string }>(
|
||||
props: TreeProps<T> & RefAttributes<TreeHandle>,
|
||||
) => ReactElement | null;
|
||||
|
||||
export const Tree = memo(
|
||||
Tree_,
|
||||
({ root: prevNode, ...prevProps }, { root: nextNode, ...nextProps }) => {
|
||||
for (const key of Object.keys(prevProps)) {
|
||||
if (prevProps[key as keyof typeof prevProps] !== nextProps[key as keyof typeof nextProps]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return equalSubtree(prevNode, nextNode, nextProps.getItemKey);
|
||||
},
|
||||
) as typeof Tree_;
|
||||
|
||||
function DropRegionAfterList({ id }: { id: string }) {
|
||||
const { setNodeRef } = useDroppable({ id });
|
||||
return <div ref={setNodeRef} />;
|
||||
}
|
||||
|
||||
function useTreeParentMap<T extends { id: string }>(
|
||||
root: TreeNode<T>,
|
||||
getItemKey: (item: T) => string,
|
||||
) {
|
||||
const collapsedMap = useAtomValue(useAtomValue(sidebarCollapsedAtom));
|
||||
const [{ treeParentMap, selectableItems }, setData] = useState(() => {
|
||||
return compute(root, collapsedMap);
|
||||
});
|
||||
|
||||
const prevRoot = useRef<TreeNode<T> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const shouldRecompute =
|
||||
root == null || prevRoot.current == null || !equalSubtree(root, prevRoot.current, getItemKey);
|
||||
if (!shouldRecompute) return;
|
||||
setData(compute(root, collapsedMap));
|
||||
prevRoot.current = root;
|
||||
}, [collapsedMap, getItemKey, root]);
|
||||
|
||||
return { treeParentMap, selectableItems };
|
||||
}
|
||||
|
||||
function compute<T extends { id: string }>(
|
||||
root: TreeNode<T>,
|
||||
collapsedMap: Record<string, boolean>,
|
||||
) {
|
||||
const treeParentMap: Record<string, TreeNode<T>> = {};
|
||||
const selectableItems: SelectableTreeNode<T>[] = [];
|
||||
|
||||
// Put requests and folders into a tree structure
|
||||
const next = (node: TreeNode<T>, depth: number = 0) => {
|
||||
const isCollapsed = collapsedMap[node.item.id] === true;
|
||||
// console.log("IS COLLAPSED", node.item.name, isCollapsed);
|
||||
if (node.children == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Recurse to children
|
||||
let selectableIndex = 0;
|
||||
for (const child of node.children) {
|
||||
treeParentMap[child.item.id] = node;
|
||||
if (!isCollapsed) {
|
||||
selectableItems.push({
|
||||
node: child,
|
||||
index: selectableIndex++,
|
||||
depth,
|
||||
});
|
||||
}
|
||||
|
||||
next(child, depth + 1);
|
||||
}
|
||||
};
|
||||
|
||||
next(root);
|
||||
return { treeParentMap, selectableItems };
|
||||
}
|
||||
|
||||
interface TreeHotKeyProps<T extends { id: string }> extends HotKeyOptions {
|
||||
action: HotkeyAction;
|
||||
selectableItems: SelectableTreeNode<T>[];
|
||||
treeId: string;
|
||||
onDone: (items: T[]) => void;
|
||||
}
|
||||
|
||||
function TreeHotKey<T extends { id: string }>({
|
||||
treeId,
|
||||
action,
|
||||
onDone,
|
||||
selectableItems,
|
||||
...options
|
||||
}: TreeHotKeyProps<T>) {
|
||||
useHotKey(
|
||||
action,
|
||||
() => {
|
||||
onDone(getSelectedItems(treeId, selectableItems));
|
||||
},
|
||||
options,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
function TreeHotKeys<T extends { id: string }>({
|
||||
treeId,
|
||||
hotkeys,
|
||||
selectableItems,
|
||||
}: {
|
||||
treeId: string;
|
||||
hotkeys: TreeProps<T>['hotkeys'];
|
||||
selectableItems: SelectableTreeNode<T>[];
|
||||
}) {
|
||||
if (hotkeys == null) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{Object.entries(hotkeys.actions).map(([hotkey, onDone]) => (
|
||||
<TreeHotKey
|
||||
key={hotkey}
|
||||
action={hotkey as HotkeyAction}
|
||||
priority={hotkeys.priority}
|
||||
enable={hotkeys.enable}
|
||||
treeId={treeId}
|
||||
onDone={onDone}
|
||||
selectableItems={selectableItems}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
44
src-web/components/core/tree/TreeDragOverlay.tsx
Normal file
44
src-web/components/core/tree/TreeDragOverlay.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { DragOverlay } from '@dnd-kit/core';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { draggingIdsFamily } from './atoms';
|
||||
import type { SelectableTreeNode, TreeNode } from './common';
|
||||
import type { TreeProps } from './Tree';
|
||||
import { TreeItemList } from './TreeItemList';
|
||||
|
||||
export function TreeDragOverlay<T extends { id: string }>({
|
||||
treeId,
|
||||
root,
|
||||
selectableItems,
|
||||
getItemKey,
|
||||
ItemInner,
|
||||
ItemLeftSlot,
|
||||
}: {
|
||||
treeId: string;
|
||||
root: TreeNode<T>;
|
||||
selectableItems: SelectableTreeNode<T>[];
|
||||
} & Pick<TreeProps<T>, 'getItemKey' | 'ItemInner' | 'ItemLeftSlot'>) {
|
||||
const draggingItems = useAtomValue(draggingIdsFamily(treeId));
|
||||
return (
|
||||
<DragOverlay dropAnimation={null}>
|
||||
<TreeItemList
|
||||
treeId={treeId + '.dragging'}
|
||||
node={{
|
||||
item: { ...root.item, id: `${root.item.id}_dragging` },
|
||||
parent: null,
|
||||
children: draggingItems
|
||||
.map((id) => {
|
||||
const child = selectableItems.find((i2) => {
|
||||
return i2.node.item.id === id;
|
||||
})?.node;
|
||||
return child == null ? null : { ...child, children: undefined };
|
||||
})
|
||||
.filter((c) => c != null),
|
||||
}}
|
||||
getItemKey={getItemKey}
|
||||
ItemInner={ItemInner}
|
||||
ItemLeftSlot={ItemLeftSlot}
|
||||
depth={0}
|
||||
/>
|
||||
</DragOverlay>
|
||||
);
|
||||
}
|
||||
273
src-web/components/core/tree/TreeItem.tsx
Normal file
273
src-web/components/core/tree/TreeItem.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import type { DragMoveEvent } from '@dnd-kit/core';
|
||||
import { useDndMonitor, useDraggable, useDroppable } from '@dnd-kit/core';
|
||||
import classNames from 'classnames';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import type { MouseEvent, PointerEvent } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { jotaiStore } from '../../../lib/jotai';
|
||||
import type { ContextMenuProps, DropdownItem } from '../Dropdown';
|
||||
import { ContextMenu } from '../Dropdown';
|
||||
import { Icon } from '../Icon';
|
||||
import {
|
||||
isCollapsedFamily,
|
||||
isLastFocusedFamily,
|
||||
isParentHoveredFamily,
|
||||
isSelectedFamily,
|
||||
} from './atoms';
|
||||
import type { TreeNode } from './common';
|
||||
import { computeSideForDragMove } from './common';
|
||||
import type { TreeProps } from './Tree';
|
||||
|
||||
interface OnClickEvent {
|
||||
shiftKey: boolean;
|
||||
ctrlKey: boolean;
|
||||
metaKey: boolean;
|
||||
}
|
||||
|
||||
export type TreeItemProps<T extends { id: string }> = Pick<
|
||||
TreeProps<T>,
|
||||
'ItemInner' | 'ItemLeftSlot' | 'treeId' | 'getEditOptions'
|
||||
> & {
|
||||
node: TreeNode<T>;
|
||||
className?: string;
|
||||
onClick?: (item: T, e: OnClickEvent) => void;
|
||||
getContextMenu?: (item: T) => Promise<ContextMenuProps['items']>;
|
||||
};
|
||||
|
||||
const HOVER_CLOSED_FOLDER_DELAY = 800;
|
||||
|
||||
export function TreeItem<T extends { id: string }>({
|
||||
treeId,
|
||||
node,
|
||||
ItemInner,
|
||||
ItemLeftSlot,
|
||||
getContextMenu,
|
||||
onClick,
|
||||
getEditOptions,
|
||||
className,
|
||||
}: TreeItemProps<T>) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const draggableRef = useRef<HTMLButtonElement>(null);
|
||||
const isSelected = useAtomValue(isSelectedFamily({ treeId, itemId: node.item.id }));
|
||||
const isCollapsed = useAtomValue(isCollapsedFamily({ treeId, itemId: node.item.id }));
|
||||
const isHoveredAsParent = useAtomValue(isParentHoveredFamily({ treeId, parentId: node.item.id }));
|
||||
const isLastSelected = useAtomValue(isLastFocusedFamily({ treeId, itemId: node.item.id }));
|
||||
const [editing, setEditing] = useState<boolean>(false);
|
||||
const [isDropHover, setIsDropHover] = useState<boolean>(false);
|
||||
const startedHoverTimeout = useRef<NodeJS.Timeout>(undefined);
|
||||
|
||||
const [showContextMenu, setShowContextMenu] = useState<{
|
||||
items: DropdownItem[];
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(
|
||||
function scrollIntoViewWhenSelected() {
|
||||
return jotaiStore.sub(isSelectedFamily({ treeId, itemId: node.item.id }), () => {
|
||||
ref.current?.scrollIntoView({ block: 'nearest' });
|
||||
});
|
||||
},
|
||||
[node.item.id, treeId],
|
||||
);
|
||||
|
||||
const handleClick = useCallback(
|
||||
function handleClick(e: MouseEvent<HTMLButtonElement>) {
|
||||
onClick?.(node.item, e);
|
||||
},
|
||||
[node, onClick],
|
||||
);
|
||||
|
||||
const toggleCollapsed = useCallback(
|
||||
function toggleCollapsed() {
|
||||
jotaiStore.set(isCollapsedFamily({ treeId, itemId: node.item.id }), (prev) => !prev);
|
||||
},
|
||||
[node.item.id, treeId],
|
||||
);
|
||||
|
||||
const handleSubmitNameEdit = useCallback(
|
||||
async function submitNameEdit(el: HTMLInputElement) {
|
||||
getEditOptions?.(node.item).onChange(node.item, el.value);
|
||||
// Slight delay for the model to propagate to the local store
|
||||
setTimeout(() => setEditing(false), 200);
|
||||
},
|
||||
[getEditOptions, node.item],
|
||||
);
|
||||
|
||||
const handleEditFocus = useCallback(function handleEditFocus(el: HTMLInputElement | null) {
|
||||
el?.focus();
|
||||
el?.select();
|
||||
}, []);
|
||||
|
||||
const handleEditBlur = useCallback(
|
||||
async function editBlur(e: React.FocusEvent<HTMLInputElement>) {
|
||||
await handleSubmitNameEdit(e.currentTarget);
|
||||
},
|
||||
[handleSubmitNameEdit],
|
||||
);
|
||||
|
||||
const handleEditKeyDown = useCallback(
|
||||
async (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
e.stopPropagation();
|
||||
switch (e.key) {
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
await handleSubmitNameEdit(e.currentTarget);
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
setEditing(false);
|
||||
break;
|
||||
}
|
||||
},
|
||||
[handleSubmitNameEdit],
|
||||
);
|
||||
|
||||
const handleDoubleClick = useCallback(() => {
|
||||
const isFolder = node.children != null;
|
||||
if (isFolder) {
|
||||
toggleCollapsed();
|
||||
} else if (getEditOptions != null) {
|
||||
setEditing(true);
|
||||
}
|
||||
}, [getEditOptions, node.children, toggleCollapsed]);
|
||||
|
||||
const clearHoverTimer = () => {
|
||||
if (startedHoverTimeout.current) {
|
||||
setIsDropHover(false); // NEW
|
||||
clearTimeout(startedHoverTimeout.current); // NEW
|
||||
startedHoverTimeout.current = undefined; // NEW
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle auto-expand of folders when hovering over them
|
||||
useDndMonitor({
|
||||
onDragMove(e: DragMoveEvent) {
|
||||
const side = computeSideForDragMove(node, e);
|
||||
const isFolderWithChildren = (node.children?.length ?? 0) > 0;
|
||||
const isCollapsed = jotaiStore.get(isCollapsedFamily({ treeId, itemId: node.item.id }));
|
||||
if (isCollapsed && isFolderWithChildren && side === 'below') {
|
||||
setIsDropHover(true);
|
||||
clearTimeout(startedHoverTimeout.current);
|
||||
startedHoverTimeout.current = setTimeout(() => {
|
||||
jotaiStore.set(isCollapsedFamily({ treeId, itemId: node.item.id }), false);
|
||||
setIsDropHover(false);
|
||||
}, HOVER_CLOSED_FOLDER_DELAY);
|
||||
} else {
|
||||
clearHoverTimer();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleContextMenu = useCallback(
|
||||
async (e: MouseEvent<HTMLDivElement>) => {
|
||||
if (getContextMenu == null) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const items = await getContextMenu(node.item);
|
||||
setShowContextMenu({ items, x: e.clientX, y: e.clientY });
|
||||
},
|
||||
[getContextMenu, node.item],
|
||||
);
|
||||
|
||||
const handleCloseContextMenu = useCallback(() => {
|
||||
setShowContextMenu(null);
|
||||
}, []);
|
||||
|
||||
const { attributes, listeners, setNodeRef: setDraggableRef } = useDraggable({ id: node.item.id });
|
||||
const { setNodeRef: setDroppableRef } = useDroppable({ id: node.item.id });
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
function handlePointerDown(e: PointerEvent<HTMLButtonElement>) {
|
||||
const handleByTree = e.metaKey || e.ctrlKey || e.shiftKey;
|
||||
if (!handleByTree) {
|
||||
listeners?.onPointerDown?.(e);
|
||||
}
|
||||
},
|
||||
[listeners],
|
||||
);
|
||||
|
||||
const handleSetDraggableRef = useCallback(
|
||||
(node: HTMLButtonElement | null) => {
|
||||
draggableRef.current = node;
|
||||
setDraggableRef(node);
|
||||
setDroppableRef(node);
|
||||
},
|
||||
[setDraggableRef, setDroppableRef],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
onContextMenu={handleContextMenu}
|
||||
className={classNames(
|
||||
className,
|
||||
'tree-item',
|
||||
isSelected && 'selected',
|
||||
'text-text-subtle',
|
||||
'h-sm grid grid-cols-[auto_minmax(0,1fr)] items-center rounded-md px-1.5',
|
||||
editing && 'ring-1 focus-within:ring-focus',
|
||||
isDropHover && 'relative z-10 ring-2 ring-primary animate-blinkRing',
|
||||
)}
|
||||
>
|
||||
{showContextMenu && (
|
||||
<ContextMenu
|
||||
items={showContextMenu.items}
|
||||
triggerPosition={showContextMenu}
|
||||
onClose={handleCloseContextMenu}
|
||||
/>
|
||||
)}
|
||||
{node.children != null ? (
|
||||
<button
|
||||
tabIndex={-1}
|
||||
className="h-full w-[2.8rem] pr-[0.5rem] -ml-[1rem]"
|
||||
onClick={toggleCollapsed}
|
||||
>
|
||||
<Icon
|
||||
icon="chevron_right"
|
||||
className={classNames(
|
||||
'transition-transform text-text-subtlest',
|
||||
'ml-auto !h-[1rem] !w-[1rem]',
|
||||
node.children.length == 0 && 'opacity-0',
|
||||
!isCollapsed && 'rotate-90',
|
||||
isHoveredAsParent && '!text-text',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<button
|
||||
ref={handleSetDraggableRef}
|
||||
onPointerDown={handlePointerDown}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
disabled={editing}
|
||||
className="focus:outline-none flex items-center gap-2 h-full whitespace-nowrap"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
tabIndex={isLastSelected ? 0 : -1}
|
||||
>
|
||||
{ItemLeftSlot != null && <ItemLeftSlot treeId={treeId} item={node.item} />}
|
||||
{getEditOptions != null && editing ? (
|
||||
(() => {
|
||||
const { defaultValue, placeholder } = getEditOptions(node.item);
|
||||
return (
|
||||
<input
|
||||
ref={handleEditFocus}
|
||||
defaultValue={defaultValue}
|
||||
placeholder={placeholder}
|
||||
className="bg-transparent outline-none w-full cursor-text"
|
||||
onBlur={handleEditBlur}
|
||||
onKeyDown={handleEditKeyDown}
|
||||
/>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<ItemInner treeId={treeId} item={node.item} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
131
src-web/components/core/tree/TreeItemList.tsx
Normal file
131
src-web/components/core/tree/TreeItemList.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import classNames from 'classnames';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { Fragment, memo } from 'react';
|
||||
import { DropMarker } from '../../DropMarker';
|
||||
import { isCollapsedFamily, isItemHoveredFamily, isParentHoveredFamily } from './atoms';
|
||||
import type { TreeNode } from './common';
|
||||
import { equalSubtree } from './common';
|
||||
import type { TreeProps } from './Tree';
|
||||
import type { TreeItemProps } from './TreeItem';
|
||||
import { TreeItem } from './TreeItem';
|
||||
|
||||
export type TreeItemListProps<T extends { id: string }> = Pick<
|
||||
TreeProps<T>,
|
||||
'ItemInner' | 'ItemLeftSlot' | 'treeId' | 'getItemKey' | 'getEditOptions'
|
||||
> &
|
||||
Pick<TreeItemProps<T>, 'onClick' | 'getContextMenu'> & {
|
||||
node: TreeNode<T>;
|
||||
depth: number;
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function TreeItemList_<T extends { id: string }>({
|
||||
className,
|
||||
depth,
|
||||
getContextMenu,
|
||||
getEditOptions,
|
||||
getItemKey,
|
||||
node,
|
||||
onClick,
|
||||
ItemInner,
|
||||
ItemLeftSlot,
|
||||
style,
|
||||
treeId,
|
||||
}: TreeItemListProps<T>) {
|
||||
const isHovered = useAtomValue(isParentHoveredFamily({ treeId, parentId: node.item.id }));
|
||||
const isCollapsed = useAtomValue(isCollapsedFamily({ treeId, itemId: node.item.id }));
|
||||
const childList = !isCollapsed && node.children != null && (
|
||||
<ul
|
||||
style={style}
|
||||
className={classNames(
|
||||
className,
|
||||
depth > 0 && 'ml-[calc(1.2rem+0.5px)] pl-[0.7rem] border-l',
|
||||
isHovered ? 'border-l-text-subtle' : 'border-l-border-subtle',
|
||||
)}
|
||||
>
|
||||
{node.children.map(function mapChild(child, i) {
|
||||
return (
|
||||
<Fragment key={getItemKey(child.item)}>
|
||||
<TreeDropMarker treeId={treeId} parent={node} index={i} />
|
||||
<TreeItemList
|
||||
treeId={treeId}
|
||||
node={child}
|
||||
ItemInner={ItemInner}
|
||||
ItemLeftSlot={ItemLeftSlot}
|
||||
onClick={onClick}
|
||||
getEditOptions={getEditOptions}
|
||||
depth={depth + 1}
|
||||
getItemKey={getItemKey}
|
||||
getContextMenu={getContextMenu}
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
<TreeDropMarker treeId={treeId} parent={node ?? null} index={node.children?.length ?? 0} />
|
||||
</ul>
|
||||
);
|
||||
|
||||
if (depth === 0) {
|
||||
return childList;
|
||||
}
|
||||
|
||||
return (
|
||||
<li>
|
||||
<TreeItem
|
||||
treeId={treeId}
|
||||
node={node}
|
||||
getContextMenu={getContextMenu}
|
||||
ItemInner={ItemInner}
|
||||
ItemLeftSlot={ItemLeftSlot}
|
||||
onClick={onClick}
|
||||
getEditOptions={getEditOptions}
|
||||
/>
|
||||
{childList}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export const TreeItemList = memo(
|
||||
TreeItemList_,
|
||||
({ node: prevNode, ...prevProps }, { node: nextNode, ...nextProps }) => {
|
||||
const nonEqualKeys = [];
|
||||
for (const key of Object.keys(prevProps)) {
|
||||
if (prevProps[key as keyof typeof prevProps] !== nextProps[key as keyof typeof nextProps]) {
|
||||
nonEqualKeys.push(key);
|
||||
}
|
||||
}
|
||||
if (nonEqualKeys.length > 0) {
|
||||
// console.log('TreeItemList: ', nonEqualKeys);
|
||||
return false;
|
||||
}
|
||||
return equalSubtree(prevNode, nextNode, nextProps.getItemKey);
|
||||
},
|
||||
) as typeof TreeItemList_;
|
||||
|
||||
const TreeDropMarker = memo(function TreeDropMarker<T extends { id: string }>({
|
||||
className,
|
||||
treeId,
|
||||
parent,
|
||||
index,
|
||||
}: {
|
||||
treeId: string;
|
||||
parent: TreeNode<T> | null;
|
||||
index: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const isHovered = useAtomValue(isItemHoveredFamily({ treeId, parentId: parent?.item.id, index }));
|
||||
const isLastItem = parent?.children?.length === index;
|
||||
const isLastItemHovered = useAtomValue(
|
||||
isItemHoveredFamily({
|
||||
treeId,
|
||||
parentId: parent?.item.id,
|
||||
index: parent?.children?.length ?? 0,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!isHovered && !(isLastItem && isLastItemHovered)) return null;
|
||||
|
||||
return <DropMarker className={classNames(className)} />;
|
||||
});
|
||||
89
src-web/components/core/tree/atoms.ts
Normal file
89
src-web/components/core/tree/atoms.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { atom } from 'jotai';
|
||||
import { atomFamily, selectAtom } from 'jotai/utils';
|
||||
import { atomWithKVStorage } from '../../../lib/atoms/atomWithKVStorage';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const selectedIdsFamily = atomFamily((_treeId: string) => {
|
||||
return atom<string[]>([]);
|
||||
});
|
||||
|
||||
export const isSelectedFamily = atomFamily(
|
||||
({ treeId, itemId }: { treeId: string; itemId: string }) => {
|
||||
return selectAtom(selectedIdsFamily(treeId), (ids) => ids.includes(itemId), Object.is);
|
||||
},
|
||||
(a, b) => a.treeId === b.treeId && a.itemId === b.itemId,
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const focusIdsFamily = atomFamily((_treeId: string) => {
|
||||
return atom<{ lastId: string | null; anchorId: string | null }>({ lastId: null, anchorId: null });
|
||||
});
|
||||
|
||||
export const isLastFocusedFamily = atomFamily(
|
||||
({ treeId, itemId }: { treeId: string; itemId: string }) =>
|
||||
selectAtom(focusIdsFamily(treeId), (v) => v.lastId == itemId, Object.is),
|
||||
(a, b) => a.treeId === b.treeId && a.itemId === b.itemId,
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const draggingIdsFamily = atomFamily((_treeId: string) => {
|
||||
return atom<string[]>([]);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const hoveredParentFamily = atomFamily((_treeId: string) => {
|
||||
return atom<{ index: number | null; parentId: string | null }>({ index: null, parentId: null });
|
||||
});
|
||||
|
||||
export const isParentHoveredFamily = atomFamily(
|
||||
({ treeId, parentId }: { treeId: string; parentId: string | null | undefined }) =>
|
||||
selectAtom(hoveredParentFamily(treeId), (v) => v.parentId === parentId, Object.is),
|
||||
(a, b) => a.treeId === b.treeId && a.parentId === b.parentId,
|
||||
);
|
||||
|
||||
export const isItemHoveredFamily = atomFamily(
|
||||
({
|
||||
treeId,
|
||||
parentId,
|
||||
index,
|
||||
}: {
|
||||
treeId: string;
|
||||
parentId: string | null | undefined;
|
||||
index: number | null;
|
||||
}) =>
|
||||
selectAtom(
|
||||
hoveredParentFamily(treeId),
|
||||
(v) => v.parentId === parentId && v.index === index,
|
||||
Object.is,
|
||||
),
|
||||
(a, b) => a.treeId === b.treeId && a.parentId === b.parentId && a.index === b.index,
|
||||
);
|
||||
|
||||
function kvKey(workspaceId: string | null) {
|
||||
return ['sidebar_collapsed', workspaceId ?? 'n/a'];
|
||||
}
|
||||
|
||||
export const collapsedFamily = atomFamily((workspaceId: string) => {
|
||||
return atomWithKVStorage<Record<string, boolean>>(kvKey(workspaceId), {});
|
||||
});
|
||||
|
||||
export const isCollapsedFamily = atomFamily(
|
||||
({ treeId, itemId }: { treeId: string; itemId: string }) =>
|
||||
atom(
|
||||
// --- getter ---
|
||||
(get) => !!get(collapsedFamily(treeId))[itemId],
|
||||
|
||||
// --- setter ---
|
||||
(get, set, next: boolean | ((prev: boolean) => boolean)) => {
|
||||
const a = collapsedFamily(treeId);
|
||||
const prevMap = get(a);
|
||||
const prevValue = !!prevMap[itemId];
|
||||
const value = typeof next === 'function' ? next(prevValue) : next;
|
||||
|
||||
if (value === prevValue) return; // no-op
|
||||
|
||||
set(a, { ...prevMap, [itemId]: value });
|
||||
},
|
||||
),
|
||||
(a, b) => a.treeId === b.treeId && a.itemId === b.itemId,
|
||||
);
|
||||
70
src-web/components/core/tree/common.ts
Normal file
70
src-web/components/core/tree/common.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type { DragMoveEvent } from '@dnd-kit/core';
|
||||
import { jotaiStore } from '../../../lib/jotai';
|
||||
import { selectedIdsFamily } from './atoms';
|
||||
|
||||
export interface TreeNode<T extends { id: string }> {
|
||||
children?: TreeNode<T>[];
|
||||
item: T;
|
||||
parent: TreeNode<T> | null;
|
||||
}
|
||||
|
||||
export interface SelectableTreeNode<T extends { id: string }> {
|
||||
node: TreeNode<T>;
|
||||
depth: number;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export function getSelectedItems<T extends { id: string }>(
|
||||
treeId: string,
|
||||
selectableItems: SelectableTreeNode<T>[],
|
||||
) {
|
||||
const selectedItemIds = jotaiStore.get(selectedIdsFamily(treeId));
|
||||
return selectableItems
|
||||
.filter((i) => selectedItemIds.includes(i.node.item.id))
|
||||
.map((i) => i.node.item);
|
||||
}
|
||||
|
||||
export function equalSubtree<T extends { id: string }>(
|
||||
a: TreeNode<T>,
|
||||
b: TreeNode<T>,
|
||||
getKey: (t: T) => string,
|
||||
): boolean {
|
||||
if (getKey(a.item) !== getKey(b.item)) return false;
|
||||
const ak = a.children ?? [];
|
||||
const bk = b.children ?? [];
|
||||
if (ak.length !== bk.length) return false;
|
||||
for (let i = 0; i < ak.length; i++) {
|
||||
if (!equalSubtree(ak[i]!, bk[i]!, getKey)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function hasAncestor<T extends { id: string }>(node: TreeNode<T>, ancestorId: string) {
|
||||
// Check parents recursively
|
||||
if (node.parent == null) return false;
|
||||
if (node.parent.item.id === ancestorId) return true;
|
||||
return hasAncestor(node.parent, ancestorId);
|
||||
}
|
||||
|
||||
export function computeSideForDragMove<T extends { id: string }>(
|
||||
node: TreeNode<T>,
|
||||
e: DragMoveEvent,
|
||||
): 'above' | 'below' | null {
|
||||
if (e.over == null || e.over.id !== node.item.id) {
|
||||
return null;
|
||||
}
|
||||
if (e.active.rect.current.initial == null) return null;
|
||||
|
||||
const overRect = e.over.rect;
|
||||
const activeTop =
|
||||
e.active.rect.current.translated?.top ?? e.active.rect.current.initial.top + e.delta.y;
|
||||
const pointerY = activeTop + e.active.rect.current.initial.height / 2;
|
||||
|
||||
const hoverTop = overRect.top;
|
||||
const hoverBottom = overRect.bottom;
|
||||
const hoverMiddleY = (hoverBottom - hoverTop) / 2;
|
||||
const hoverClientY = pointerY - hoverTop;
|
||||
|
||||
return hoverClientY < hoverMiddleY ? 'above' : 'below';
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
export enum ItemTypes {
|
||||
REQUEST = 'request',
|
||||
SIDEBAR = 'sidebar',
|
||||
TREE_ITEM = 'tree.item',
|
||||
TREE = 'tree',
|
||||
}
|
||||
|
||||
export type DragItem = {
|
||||
id: string;
|
||||
itemName: string;
|
||||
};
|
||||
@@ -210,7 +210,7 @@ function GraphQLExplorerHeader({
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-auto flex gap-1 [&>*]:text-text-subtle">
|
||||
<IconButton icon="x" size="sm" title="Close documenation explorer" onClick={onClose} />
|
||||
<IconButton icon="x" size="sm" title="Close documentation explorer" onClick={onClose} />
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
|
||||
@@ -25,7 +25,7 @@ export function WebPageViewer({ response }: Props) {
|
||||
srcDoc={contentForIframe}
|
||||
sandbox="allow-scripts allow-forms"
|
||||
referrerPolicy="no-referrer"
|
||||
className="h-full w-full rounded border border-border-subtle"
|
||||
className="h-full w-full rounded-lg border border-border-subtle"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,369 +0,0 @@
|
||||
import type {
|
||||
Folder,
|
||||
GrpcRequest,
|
||||
HttpRequest,
|
||||
WebsocketRequest,
|
||||
Workspace,
|
||||
} from '@yaakapp-internal/models';
|
||||
import { getAnyModel, patchModelById } from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { useDrop } from 'react-dnd';
|
||||
import { useKey, useKeyPressEvent } from 'react-use';
|
||||
import { activeRequestIdAtom } from '../../hooks/useActiveRequestId';
|
||||
import { activeWorkspaceAtom } from '../../hooks/useActiveWorkspace';
|
||||
import { useCreateDropdownItems } from '../../hooks/useCreateDropdownItems';
|
||||
import { useHotKey } from '../../hooks/useHotKey';
|
||||
import { useSidebarHidden } from '../../hooks/useSidebarHidden';
|
||||
import { getSidebarCollapsedMap } from '../../hooks/useSidebarItemCollapsed';
|
||||
import { deleteModelWithConfirm } from '../../lib/deleteModelWithConfirm';
|
||||
import { jotaiStore } from '../../lib/jotai';
|
||||
import { router } from '../../lib/router';
|
||||
import { setWorkspaceSearchParams } from '../../lib/setWorkspaceSearchParams';
|
||||
import { ContextMenu } from '../core/Dropdown';
|
||||
import { GitDropdown } from '../GitDropdown';
|
||||
import type { DragItem } from './dnd';
|
||||
import { ItemTypes } from './dnd';
|
||||
import { sidebarSelectedIdAtom, sidebarTreeAtom } from './SidebarAtoms';
|
||||
import type { SidebarItemProps } from './SidebarItem';
|
||||
import { SidebarItems } from './SidebarItems';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export type SidebarModel = Folder | GrpcRequest | HttpRequest | WebsocketRequest | Workspace;
|
||||
|
||||
export interface SidebarTreeNode {
|
||||
id: string;
|
||||
name: string;
|
||||
model: SidebarModel['model'];
|
||||
sortPriority?: number;
|
||||
workspaceId?: string;
|
||||
folderId?: string | null;
|
||||
children: SidebarTreeNode[];
|
||||
depth: number;
|
||||
}
|
||||
|
||||
export function Sidebar({ className }: Props) {
|
||||
const [hidden, setHidden] = useSidebarHidden();
|
||||
const sidebarRef = useRef<HTMLElement>(null);
|
||||
const activeWorkspace = useAtomValue(activeWorkspaceAtom);
|
||||
const [hasFocus, setHasFocus] = useState<boolean>(false);
|
||||
const [selectedId, setSelectedId] = useAtom(sidebarSelectedIdAtom);
|
||||
const [selectedTree, setSelectedTree] = useState<SidebarTreeNode | null>(null);
|
||||
const [draggingId, setDraggingId] = useState<string | null>(null);
|
||||
const [hoveredTree, setHoveredTree] = useState<SidebarTreeNode | null>(null);
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
|
||||
const { tree, treeParentMap, selectableRequests } = useAtomValue(sidebarTreeAtom);
|
||||
|
||||
const focusActiveRequest = useCallback(
|
||||
(
|
||||
args: {
|
||||
forced?: {
|
||||
id: string;
|
||||
tree: SidebarTreeNode;
|
||||
};
|
||||
noFocusSidebar?: boolean;
|
||||
} = {},
|
||||
) => {
|
||||
const activeRequestId = jotaiStore.get(activeRequestIdAtom);
|
||||
const { forced, noFocusSidebar } = args;
|
||||
const tree = forced?.tree ?? treeParentMap[activeRequestId ?? 'n/a'] ?? null;
|
||||
const children = tree?.children ?? [];
|
||||
const id = forced?.id ?? children.find((m) => m.id === activeRequestId)?.id ?? null;
|
||||
|
||||
setHasFocus(true);
|
||||
setSelectedId(id);
|
||||
setSelectedTree(tree);
|
||||
|
||||
if (id == null) {
|
||||
return;
|
||||
}
|
||||
if (!noFocusSidebar) {
|
||||
sidebarRef.current?.focus();
|
||||
}
|
||||
},
|
||||
[setHasFocus, setSelectedId, treeParentMap],
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (id: string) => {
|
||||
const tree = treeParentMap[id ?? 'n/a'] ?? null;
|
||||
const children = tree?.children ?? [];
|
||||
const node = children.find((m) => m.id === id) ?? null;
|
||||
if (node == null || tree == null || node.model === 'workspace') {
|
||||
return;
|
||||
}
|
||||
|
||||
// NOTE: I'm not sure why, but TS thinks workspaceId is (string | undefined) here
|
||||
if (node.model !== 'folder' && node.workspaceId) {
|
||||
const workspaceId = node.workspaceId;
|
||||
await router.navigate({
|
||||
to: '/workspaces/$workspaceId',
|
||||
params: { workspaceId },
|
||||
search: (prev) => ({ ...prev, request_id: node.id }),
|
||||
});
|
||||
|
||||
setHasFocus(true);
|
||||
setSelectedId(id);
|
||||
setSelectedTree(tree);
|
||||
}
|
||||
},
|
||||
[treeParentMap, setSelectedId],
|
||||
);
|
||||
|
||||
const handleClearSelected = useCallback(() => {
|
||||
setSelectedId(null);
|
||||
setSelectedTree(null);
|
||||
}, [setSelectedId]);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
if (hasFocus) return;
|
||||
focusActiveRequest({ noFocusSidebar: true });
|
||||
}, [focusActiveRequest, hasFocus]);
|
||||
|
||||
const handleBlur = useCallback(() => setHasFocus(false), [setHasFocus]);
|
||||
|
||||
useHotKey(
|
||||
'sidebar.delete_selected_item',
|
||||
async () => {
|
||||
const request = getAnyModel(selectedId ?? 'n/a');
|
||||
if (request != null) {
|
||||
await deleteModelWithConfirm(request);
|
||||
}
|
||||
},
|
||||
{ enable: hasFocus },
|
||||
);
|
||||
|
||||
useHotKey('sidebar.focus', async () => {
|
||||
// Hide the sidebar if it's already focused
|
||||
if (!hidden && hasFocus) {
|
||||
await setHidden(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show the sidebar if it's hidden
|
||||
if (hidden) {
|
||||
await setHidden(false);
|
||||
}
|
||||
|
||||
// Select 0th index on focus if none selected
|
||||
focusActiveRequest(
|
||||
selectedTree != null && selectedId != null
|
||||
? { forced: { id: selectedId, tree: selectedTree } }
|
||||
: undefined,
|
||||
);
|
||||
});
|
||||
|
||||
useKeyPressEvent('Enter', async (e) => {
|
||||
if (!hasFocus) return;
|
||||
const selected = selectableRequests.find((r) => r.id === selectedId);
|
||||
if (!selected || activeWorkspace == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
setWorkspaceSearchParams({ request_id: selected.id });
|
||||
});
|
||||
|
||||
useKey(
|
||||
'ArrowUp',
|
||||
(e) => {
|
||||
if (!hasFocus) return;
|
||||
e.preventDefault();
|
||||
const i = selectableRequests.findIndex((r) => r.id === selectedId);
|
||||
const newI = i <= 0 ? selectableRequests.length - 1 : i - 1;
|
||||
const newSelectable = selectableRequests[newI];
|
||||
if (newSelectable == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedId(newSelectable.id);
|
||||
setSelectedTree(newSelectable.tree);
|
||||
},
|
||||
undefined,
|
||||
[hasFocus, selectableRequests, selectedId, setSelectedId, setSelectedTree],
|
||||
);
|
||||
|
||||
useKey(
|
||||
'ArrowDown',
|
||||
(e) => {
|
||||
if (!hasFocus) return;
|
||||
e.preventDefault();
|
||||
const i = selectableRequests.findIndex((r) => r.id === selectedId);
|
||||
const newI = i >= selectableRequests.length - 1 ? 0 : i + 1;
|
||||
const newSelectable = selectableRequests[newI];
|
||||
if (newSelectable == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedId(newSelectable.id);
|
||||
setSelectedTree(newSelectable.tree);
|
||||
},
|
||||
undefined,
|
||||
[hasFocus, selectableRequests, selectedId, setSelectedId, setSelectedTree],
|
||||
);
|
||||
|
||||
const handleMoveToSidebarEnd = useCallback(() => {
|
||||
setHoveredTree(tree);
|
||||
// Put at the end of the top tree
|
||||
setHoveredIndex(tree?.children?.length ?? 0);
|
||||
}, [tree]);
|
||||
|
||||
const handleMove = useCallback<SidebarItemProps['onMove']>(
|
||||
(id, side) => {
|
||||
let hoveredTree = treeParentMap[id] ?? null;
|
||||
const dragIndex = hoveredTree?.children.findIndex((n) => n.id === id) ?? -99;
|
||||
const hoveredItem = hoveredTree?.children[dragIndex] ?? null;
|
||||
let hoveredIndex = dragIndex + (side === 'above' ? 0 : 1);
|
||||
|
||||
const collapsedMap = getSidebarCollapsedMap();
|
||||
const isHoveredItemCollapsed = hoveredItem != null ? collapsedMap[hoveredItem.id] : false;
|
||||
|
||||
if (hoveredItem?.model === 'folder' && side === 'below' && !isHoveredItemCollapsed) {
|
||||
// Move into the folder if it's open and we're moving below it
|
||||
hoveredTree = hoveredTree?.children.find((n) => n.id === id) ?? null;
|
||||
hoveredIndex = 0;
|
||||
}
|
||||
|
||||
setHoveredTree(hoveredTree);
|
||||
setHoveredIndex(hoveredIndex);
|
||||
},
|
||||
[treeParentMap],
|
||||
);
|
||||
|
||||
const handleDragStart = useCallback<SidebarItemProps['onDragStart']>((id: string) => {
|
||||
setDraggingId(id);
|
||||
}, []);
|
||||
|
||||
const handleEnd = useCallback<SidebarItemProps['onEnd']>(
|
||||
async (itemId) => {
|
||||
setHoveredTree(null);
|
||||
setDraggingId(null);
|
||||
handleClearSelected();
|
||||
|
||||
if (hoveredTree == null || hoveredIndex == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Block dragging folder into itself
|
||||
if (hoveredTree.id === itemId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parentTree = treeParentMap[itemId] ?? null;
|
||||
const index = parentTree?.children.findIndex((n) => n.id === itemId) ?? -1;
|
||||
const child = parentTree?.children[index ?? -1];
|
||||
if (child == null || parentTree == null) return;
|
||||
|
||||
const movedToDifferentTree = hoveredTree.id !== parentTree.id;
|
||||
const movedUpInSameTree = !movedToDifferentTree && hoveredIndex < index;
|
||||
|
||||
const newChildren = hoveredTree.children.filter((c) => c.id !== itemId);
|
||||
if (movedToDifferentTree || movedUpInSameTree) {
|
||||
// Moving up or into a new tree is simply inserting before the hovered item
|
||||
newChildren.splice(hoveredIndex, 0, child);
|
||||
} else {
|
||||
// Moving down has to account for the fact that the original item will be removed
|
||||
newChildren.splice(hoveredIndex - 1, 0, child);
|
||||
}
|
||||
|
||||
const insertedIndex = newChildren.findIndex((c) => c.id === child.id);
|
||||
const prev = newChildren[insertedIndex - 1];
|
||||
const next = newChildren[insertedIndex + 1];
|
||||
const beforePriority = prev?.sortPriority ?? 0;
|
||||
const afterPriority = next?.sortPriority ?? 0;
|
||||
|
||||
const folderId = hoveredTree.model === 'folder' ? hoveredTree.id : null;
|
||||
const shouldUpdateAll = afterPriority - beforePriority < 1;
|
||||
|
||||
if (shouldUpdateAll) {
|
||||
await Promise.all(
|
||||
newChildren.map((child, i) => {
|
||||
const sortPriority = i * 1000;
|
||||
return patchModelById(child.model, child.id, { sortPriority, folderId });
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
const sortPriority = afterPriority - (afterPriority - beforePriority) / 2;
|
||||
await patchModelById(child.model, child.id, { sortPriority, folderId });
|
||||
}
|
||||
},
|
||||
[handleClearSelected, hoveredTree, hoveredIndex, treeParentMap],
|
||||
);
|
||||
|
||||
const [showMainContextMenu, setShowMainContextMenu] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
|
||||
const handleMainContextMenu = useCallback((e: React.MouseEvent<HTMLElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setShowMainContextMenu({ x: e.clientX, y: e.clientY });
|
||||
}, []);
|
||||
|
||||
const mainContextMenuItems = useCreateDropdownItems({ folderId: null });
|
||||
|
||||
const [, connectDrop] = useDrop<DragItem, void>(
|
||||
{
|
||||
accept: ItemTypes.REQUEST,
|
||||
hover: (_, monitor) => {
|
||||
if (sidebarRef.current == null) return;
|
||||
if (!monitor.isOver({ shallow: true })) return;
|
||||
handleMoveToSidebarEnd();
|
||||
},
|
||||
},
|
||||
[handleMoveToSidebarEnd],
|
||||
);
|
||||
|
||||
connectDrop(sidebarRef);
|
||||
|
||||
// Not ready to render yet
|
||||
if (tree == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
aria-hidden={hidden ?? undefined}
|
||||
ref={sidebarRef}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
tabIndex={hidden ? -1 : 0}
|
||||
onContextMenu={handleMainContextMenu}
|
||||
data-focused={hasFocus}
|
||||
className={classNames(
|
||||
className,
|
||||
// Style item selection color here, because it's very hard to do in an efficient
|
||||
// way in the item itself (selection ID makes it hard)
|
||||
hasFocus && '[&_[data-selected=true]]:bg-surface-active',
|
||||
'h-full grid grid-rows-[minmax(0,1fr)_auto]',
|
||||
)}
|
||||
>
|
||||
<div className="pb-3 overflow-x-visible overflow-y-scroll pt-2 pr-0.5">
|
||||
<ContextMenu
|
||||
triggerPosition={showMainContextMenu}
|
||||
items={mainContextMenuItems}
|
||||
onClose={() => setShowMainContextMenu(null)}
|
||||
/>
|
||||
<SidebarItems
|
||||
treeParentMap={treeParentMap}
|
||||
selectedTree={selectedTree}
|
||||
tree={tree}
|
||||
draggingId={draggingId}
|
||||
onSelect={handleSelect}
|
||||
hoveredIndex={hoveredIndex}
|
||||
hoveredTree={hoveredTree}
|
||||
handleMove={handleMove}
|
||||
handleEnd={handleEnd}
|
||||
handleDragStart={handleDragStart}
|
||||
/>
|
||||
</div>
|
||||
<GitDropdown />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
import {
|
||||
type Folder,
|
||||
foldersAtom,
|
||||
type GrpcRequest,
|
||||
type HttpRequest,
|
||||
type WebsocketRequest,
|
||||
} from '@yaakapp-internal/models';
|
||||
|
||||
// This is an atom, so we can use it in the child items to avoid re-rendering the entire list
|
||||
import { atom } from 'jotai';
|
||||
import { activeWorkspaceAtom } from '../../hooks/useActiveWorkspace';
|
||||
import { allRequestsAtom } from '../../hooks/useAllRequests';
|
||||
import { deepEqualAtom } from '../../lib/atoms';
|
||||
import { resolvedModelName } from '../../lib/resolvedModelName';
|
||||
import type { SidebarTreeNode } from './Sidebar';
|
||||
|
||||
export const sidebarSelectedIdAtom = atom<string | null>(null);
|
||||
|
||||
const allPotentialChildrenAtom = atom((get) => {
|
||||
const requests = get(allRequestsAtom);
|
||||
const folders = get(foldersAtom);
|
||||
return [...requests, ...folders].map((v) => ({
|
||||
id: v.id,
|
||||
model: v.model,
|
||||
folderId: v.folderId,
|
||||
name: resolvedModelName(v),
|
||||
workspaceId: v.workspaceId,
|
||||
sortPriority: v.sortPriority,
|
||||
}));
|
||||
});
|
||||
|
||||
const memoAllPotentialChildrenAtom = deepEqualAtom(allPotentialChildrenAtom);
|
||||
|
||||
export const sidebarTreeAtom = atom<{
|
||||
tree: SidebarTreeNode | null;
|
||||
treeParentMap: Record<string, SidebarTreeNode>;
|
||||
selectableRequests: {
|
||||
id: string;
|
||||
index: number;
|
||||
tree: SidebarTreeNode;
|
||||
}[];
|
||||
}>((get) => {
|
||||
const allModels = get(memoAllPotentialChildrenAtom);
|
||||
const activeWorkspace = get(activeWorkspaceAtom);
|
||||
|
||||
const childrenMap: Record<string, typeof allModels> = {};
|
||||
for (const item of allModels) {
|
||||
if ('folderId' in item && item.folderId == null) {
|
||||
childrenMap[item.workspaceId] = childrenMap[item.workspaceId] ?? [];
|
||||
childrenMap[item.workspaceId]!.push(item);
|
||||
} else if ('folderId' in item && item.folderId != null) {
|
||||
childrenMap[item.folderId] = childrenMap[item.folderId] ?? [];
|
||||
childrenMap[item.folderId]!.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
const treeParentMap: Record<string, SidebarTreeNode> = {};
|
||||
const selectableRequests: {
|
||||
id: string;
|
||||
index: number;
|
||||
tree: SidebarTreeNode;
|
||||
}[] = [];
|
||||
|
||||
if (activeWorkspace == null) {
|
||||
return { tree: null, treeParentMap, selectableRequests };
|
||||
}
|
||||
|
||||
const selectedRequest: HttpRequest | GrpcRequest | WebsocketRequest | null = null;
|
||||
let selectableRequestIndex = 0;
|
||||
|
||||
// Put requests and folders into a tree structure
|
||||
const next = (node: SidebarTreeNode): SidebarTreeNode => {
|
||||
const childItems = childrenMap[node.id] ?? [];
|
||||
|
||||
// Recurse to children
|
||||
const depth = node.depth + 1;
|
||||
childItems.sort((a, b) => a.sortPriority - b.sortPriority);
|
||||
for (const childItem of childItems) {
|
||||
treeParentMap[childItem.id] = node;
|
||||
// Add to children
|
||||
node.children.push(next(itemFromModel(childItem, depth)));
|
||||
// Add to selectable requests
|
||||
if (childItem.model !== 'folder') {
|
||||
selectableRequests.push({
|
||||
id: childItem.id,
|
||||
index: selectableRequestIndex++,
|
||||
tree: node,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return node;
|
||||
};
|
||||
|
||||
const tree = next({
|
||||
id: activeWorkspace.id,
|
||||
name: activeWorkspace.name,
|
||||
model: activeWorkspace.model,
|
||||
children: [],
|
||||
depth: 0,
|
||||
});
|
||||
|
||||
return { tree, treeParentMap, selectableRequests, selectedRequest };
|
||||
});
|
||||
|
||||
function itemFromModel(
|
||||
item: Pick<
|
||||
Folder | HttpRequest | GrpcRequest | WebsocketRequest,
|
||||
'folderId' | 'model' | 'workspaceId' | 'id' | 'name' | 'sortPriority'
|
||||
>,
|
||||
depth = 0,
|
||||
): SidebarTreeNode {
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
model: item.model,
|
||||
sortPriority: 'sortPriority' in item ? item.sortPriority : -1,
|
||||
workspaceId: item.workspaceId,
|
||||
folderId: item.folderId,
|
||||
depth,
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
import type {
|
||||
AnyModel,
|
||||
GrpcConnection,
|
||||
HttpResponse,
|
||||
WebsocketConnection,
|
||||
} from '@yaakapp-internal/models';
|
||||
import { foldersAtom, patchModelById } from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
import { atom, useAtomValue } from 'jotai';
|
||||
import type { ReactElement } from 'react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { XYCoord } from 'react-dnd';
|
||||
import { useDrag, useDrop } from 'react-dnd';
|
||||
import { activeRequestAtom } from '../../hooks/useActiveRequest';
|
||||
import { allRequestsAtom } from '../../hooks/useAllRequests';
|
||||
import { useScrollIntoView } from '../../hooks/useScrollIntoView';
|
||||
import { useSidebarItemCollapsed } from '../../hooks/useSidebarItemCollapsed';
|
||||
import { jotaiStore } from '../../lib/jotai';
|
||||
import { HttpMethodTag } from '../core/HttpMethodTag';
|
||||
import { HttpStatusTag } from '../core/HttpStatusTag';
|
||||
import { Icon } from '../core/Icon';
|
||||
import { LoadingIcon } from '../core/LoadingIcon';
|
||||
import type { DragItem} from './dnd';
|
||||
import { ItemTypes } from './dnd';
|
||||
import type { SidebarTreeNode } from './Sidebar';
|
||||
import { sidebarSelectedIdAtom } from './SidebarAtoms';
|
||||
import { SidebarItemContextMenu } from './SidebarItemContextMenu';
|
||||
import type { SidebarItemsProps } from './SidebarItems';
|
||||
|
||||
export type SidebarItemProps = {
|
||||
className?: string;
|
||||
itemId: string;
|
||||
itemName: string;
|
||||
itemModel: AnyModel['model'];
|
||||
onMove: (id: string, side: 'above' | 'below') => void;
|
||||
onEnd: (id: string) => void;
|
||||
onDragStart: (id: string) => void;
|
||||
children: ReactElement<typeof SidebarItem> | null;
|
||||
child: SidebarTreeNode;
|
||||
latestHttpResponse: HttpResponse | null;
|
||||
latestGrpcConnection: GrpcConnection | null;
|
||||
latestWebsocketConnection: WebsocketConnection | null;
|
||||
} & Pick<SidebarItemsProps, 'onSelect'>;
|
||||
|
||||
export const SidebarItem = memo(function SidebarItem({
|
||||
itemName,
|
||||
itemId,
|
||||
itemModel,
|
||||
child,
|
||||
onMove,
|
||||
onEnd,
|
||||
onDragStart,
|
||||
onSelect,
|
||||
className,
|
||||
latestHttpResponse,
|
||||
latestGrpcConnection,
|
||||
latestWebsocketConnection,
|
||||
children,
|
||||
}: SidebarItemProps) {
|
||||
const ref = useRef<HTMLLIElement>(null);
|
||||
const [collapsed, toggleCollapsed] = useSidebarItemCollapsed(itemId);
|
||||
|
||||
const [, connectDrop] = useDrop<DragItem, void>(
|
||||
{
|
||||
accept: [ItemTypes.REQUEST, ItemTypes.SIDEBAR],
|
||||
hover: (_, monitor) => {
|
||||
if (!ref.current) return;
|
||||
if (!monitor.isOver()) return;
|
||||
const hoverBoundingRect = ref.current?.getBoundingClientRect();
|
||||
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
|
||||
const clientOffset = monitor.getClientOffset();
|
||||
const hoverClientY = (clientOffset as XYCoord).y - hoverBoundingRect.top;
|
||||
onMove(itemId, hoverClientY < hoverMiddleY ? 'above' : 'below');
|
||||
},
|
||||
},
|
||||
[onMove],
|
||||
);
|
||||
|
||||
const [, connectDrag] = useDrag<
|
||||
DragItem,
|
||||
unknown,
|
||||
{
|
||||
isDragging: boolean;
|
||||
}
|
||||
>(
|
||||
() => ({
|
||||
type: ItemTypes.REQUEST,
|
||||
item: () => {
|
||||
// Cancel drag when editing
|
||||
if (editing) return null;
|
||||
onDragStart(itemId);
|
||||
return { id: itemId, itemName };
|
||||
},
|
||||
collect: (m) => ({ isDragging: m.isDragging() }),
|
||||
options: { dropEffect: 'move' },
|
||||
end: () => onEnd(itemId),
|
||||
}),
|
||||
[onEnd],
|
||||
);
|
||||
|
||||
connectDrag(connectDrop(ref));
|
||||
|
||||
const [editing, setEditing] = useState<boolean>(false);
|
||||
|
||||
const [selected, setSelected] = useState<boolean>(
|
||||
jotaiStore.get(sidebarSelectedIdAtom) == itemId,
|
||||
);
|
||||
useEffect(() => {
|
||||
return jotaiStore.sub(sidebarSelectedIdAtom, () => {
|
||||
const value = jotaiStore.get(sidebarSelectedIdAtom);
|
||||
setSelected(value === itemId);
|
||||
});
|
||||
}, [itemId]);
|
||||
|
||||
const [active, setActive] = useState<boolean>(jotaiStore.get(activeRequestAtom)?.id === itemId);
|
||||
useEffect(
|
||||
() =>
|
||||
jotaiStore.sub(activeRequestAtom, () =>
|
||||
setActive(jotaiStore.get(activeRequestAtom)?.id === itemId),
|
||||
),
|
||||
[itemId],
|
||||
);
|
||||
|
||||
useScrollIntoView(ref.current, active);
|
||||
|
||||
const handleSubmitNameEdit = useCallback(
|
||||
async (el: HTMLInputElement) => {
|
||||
await patchModelById(itemModel, itemId, { name: el.value });
|
||||
|
||||
// Slight delay for the model to propagate to the local store
|
||||
setTimeout(() => setEditing(false));
|
||||
},
|
||||
[itemId, itemModel],
|
||||
);
|
||||
|
||||
const handleFocus = useCallback((el: HTMLInputElement | null) => {
|
||||
el?.focus();
|
||||
el?.select();
|
||||
}, []);
|
||||
|
||||
const handleInputKeyDown = useCallback(
|
||||
async (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
e.stopPropagation();
|
||||
switch (e.key) {
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
await handleSubmitNameEdit(e.currentTarget);
|
||||
break;
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
setEditing(false);
|
||||
break;
|
||||
}
|
||||
},
|
||||
[handleSubmitNameEdit],
|
||||
);
|
||||
|
||||
const handleStartEditing = useCallback(() => {
|
||||
if (
|
||||
itemModel !== 'http_request' &&
|
||||
itemModel !== 'grpc_request' &&
|
||||
itemModel !== 'websocket_request'
|
||||
)
|
||||
return;
|
||||
setEditing(true);
|
||||
}, [setEditing, itemModel]);
|
||||
|
||||
const handleBlur = useCallback(
|
||||
async (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
await handleSubmitNameEdit(e.currentTarget);
|
||||
},
|
||||
[handleSubmitNameEdit],
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(async () => {
|
||||
if (itemModel === 'folder') {
|
||||
toggleCollapsed();
|
||||
} else {
|
||||
onSelect(itemId);
|
||||
}
|
||||
}, [itemModel, toggleCollapsed, onSelect, itemId]);
|
||||
const [showContextMenu, setShowContextMenu] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent<HTMLElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setShowContextMenu({ x: e.clientX, y: e.clientY });
|
||||
}, []);
|
||||
|
||||
const handleCloseContextMenu = useCallback(() => setShowContextMenu(null), []);
|
||||
|
||||
const itemAtom = useMemo(() => {
|
||||
return atom((get) => {
|
||||
if (itemModel === 'folder') {
|
||||
return get(foldersAtom).find((v) => v.id === itemId);
|
||||
} else {
|
||||
return get(allRequestsAtom).find((v) => v.id === itemId);
|
||||
}
|
||||
});
|
||||
}, [itemId, itemModel]);
|
||||
|
||||
const item = useAtomValue(itemAtom);
|
||||
|
||||
if (item == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const opacitySubtle = 'opacity-80';
|
||||
|
||||
const itemPrefix = item.model !== 'folder' && (
|
||||
<HttpMethodTag
|
||||
short
|
||||
request={item}
|
||||
className={classNames('text-xs', !(active || selected) && opacitySubtle)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<li ref={ref} draggable>
|
||||
<div className={classNames(className, 'block relative group/item pl-2 pb-0.5')}>
|
||||
{showContextMenu && (
|
||||
<SidebarItemContextMenu
|
||||
child={child}
|
||||
show={showContextMenu}
|
||||
close={handleCloseContextMenu}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
// tabIndex={-1} // Will prevent drag-n-drop
|
||||
disabled={editing}
|
||||
onClick={handleSelect}
|
||||
onDoubleClick={handleStartEditing}
|
||||
onContextMenu={handleContextMenu}
|
||||
data-active={active}
|
||||
data-selected={selected}
|
||||
className={classNames(
|
||||
'w-full flex gap-1.5 items-center h-xs px-1.5 rounded-md focus-visible:ring focus-visible:ring-border-focus outline-0',
|
||||
editing && 'ring-1 focus-within:ring-focus',
|
||||
'hover:bg-surface-highlight',
|
||||
active && 'bg-surface-highlight text-text',
|
||||
!active && 'text-text-subtle',
|
||||
showContextMenu && '!text-text', // Show as "active" when the context menu is open
|
||||
)}
|
||||
>
|
||||
{itemModel === 'folder' && (
|
||||
<Icon
|
||||
size="sm"
|
||||
icon="chevron_right"
|
||||
color="secondary"
|
||||
className={classNames('transition-transform', !collapsed && 'transform rotate-90')}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{itemPrefix}
|
||||
{editing ? (
|
||||
<input
|
||||
ref={handleFocus}
|
||||
defaultValue={itemName}
|
||||
className="bg-transparent outline-none w-full cursor-text"
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
/>
|
||||
) : (
|
||||
<div className="truncate w-full">{itemName}</div>
|
||||
)}
|
||||
</div>
|
||||
{latestGrpcConnection ? (
|
||||
<div className="ml-auto">
|
||||
{latestGrpcConnection.state !== 'closed' && (
|
||||
<LoadingIcon size="sm" className="text-text-subtlest" />
|
||||
)}
|
||||
</div>
|
||||
) : latestWebsocketConnection ? (
|
||||
<div className="ml-auto">
|
||||
{latestWebsocketConnection.state !== 'closed' && (
|
||||
<LoadingIcon size="sm" className="text-text-subtlest" />
|
||||
)}
|
||||
</div>
|
||||
) : latestHttpResponse ? (
|
||||
<div className="ml-auto">
|
||||
{latestHttpResponse.state !== 'closed' ? (
|
||||
<LoadingIcon size="sm" className="text-text-subtlest" />
|
||||
) : (
|
||||
<HttpStatusTag
|
||||
short
|
||||
className={classNames('text-xs', !(active || selected) && opacitySubtle)}
|
||||
response={latestHttpResponse}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
</div>
|
||||
{collapsed ? null : children}
|
||||
</li>
|
||||
);
|
||||
});
|
||||
@@ -1,157 +0,0 @@
|
||||
import { duplicateModelById, getModel, workspacesAtom } from '@yaakapp-internal/models';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import React, { useMemo } from 'react';
|
||||
import { openFolderSettings } from '../../commands/openFolderSettings';
|
||||
import { useCreateDropdownItems } from '../../hooks/useCreateDropdownItems';
|
||||
import { useGrpcRequestActions } from '../../hooks/useGrpcRequestActions';
|
||||
import { useHttpRequestActions } from '../../hooks/useHttpRequestActions';
|
||||
import { useMoveToWorkspace } from '../../hooks/useMoveToWorkspace';
|
||||
import { useSendAnyHttpRequest } from '../../hooks/useSendAnyHttpRequest';
|
||||
import { useSendManyRequests } from '../../hooks/useSendManyRequests';
|
||||
import { deleteModelWithConfirm } from '../../lib/deleteModelWithConfirm';
|
||||
|
||||
import { duplicateRequestAndNavigate } from '../../lib/duplicateRequestAndNavigate';
|
||||
import { renameModelWithPrompt } from '../../lib/renameModelWithPrompt';
|
||||
import type { DropdownItem } from '../core/Dropdown';
|
||||
import { ContextMenu } from '../core/Dropdown';
|
||||
import { Icon } from '../core/Icon';
|
||||
import type { SidebarTreeNode } from './Sidebar';
|
||||
|
||||
interface Props {
|
||||
child: SidebarTreeNode;
|
||||
show: { x: number; y: number } | null;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export function SidebarItemContextMenu({ child, show, close }: Props) {
|
||||
const sendManyRequests = useSendManyRequests();
|
||||
const httpRequestActions = useHttpRequestActions();
|
||||
const grpcRequestActions = useGrpcRequestActions();
|
||||
const sendRequest = useSendAnyHttpRequest();
|
||||
const workspaces = useAtomValue(workspacesAtom);
|
||||
const moveToWorkspace = useMoveToWorkspace(child.id);
|
||||
const createDropdownItems = useCreateDropdownItems({
|
||||
folderId: child.model === 'folder' ? child.id : null,
|
||||
});
|
||||
|
||||
const items = useMemo((): DropdownItem[] => {
|
||||
if (child.model === 'folder') {
|
||||
return [
|
||||
{
|
||||
label: 'Settings',
|
||||
leftSlot: <Icon icon="settings" />,
|
||||
onSelect: () => openFolderSettings(child.id),
|
||||
},
|
||||
{
|
||||
label: 'Duplicate',
|
||||
leftSlot: <Icon icon="copy" />,
|
||||
onSelect: () => duplicateModelById(child.model, child.id),
|
||||
},
|
||||
{
|
||||
label: 'Send All',
|
||||
leftSlot: <Icon icon="send_horizontal" />,
|
||||
onSelect: () => sendManyRequests.mutate(child.children.map((c) => c.id)),
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
color: 'danger',
|
||||
leftSlot: <Icon icon="trash" />,
|
||||
onSelect: async () => {
|
||||
await deleteModelWithConfirm(getModel(child.model, child.id));
|
||||
},
|
||||
},
|
||||
{ type: 'separator' },
|
||||
...createDropdownItems,
|
||||
];
|
||||
} else {
|
||||
const requestItems: DropdownItem[] =
|
||||
child.model === 'http_request'
|
||||
? [
|
||||
{
|
||||
label: 'Send',
|
||||
hotKeyAction: 'http_request.send',
|
||||
hotKeyLabelOnly: true, // Already bound in URL bar
|
||||
leftSlot: <Icon icon="send_horizontal" />,
|
||||
onSelect: () => sendRequest.mutate(child.id),
|
||||
},
|
||||
...httpRequestActions.map((a) => ({
|
||||
label: a.label,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
leftSlot: <Icon icon={(a.icon as any) ?? 'empty'} />,
|
||||
onSelect: async () => {
|
||||
const request = getModel('http_request', child.id);
|
||||
if (request != null) await a.call(request);
|
||||
},
|
||||
})),
|
||||
{ type: 'separator' },
|
||||
]
|
||||
: child.model === 'grpc_request'
|
||||
? grpcRequestActions.map((a) => ({
|
||||
label: a.label,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
leftSlot: <Icon icon={(a.icon as any) ?? 'empty'} />,
|
||||
onSelect: async () => {
|
||||
const request = getModel('grpc_request', child.id);
|
||||
if (request != null) await a.call(request);
|
||||
},
|
||||
}))
|
||||
: [];
|
||||
return [
|
||||
...requestItems,
|
||||
{
|
||||
label: 'Rename',
|
||||
leftSlot: <Icon icon="pencil" />,
|
||||
onSelect: async () => {
|
||||
const request = getModel(
|
||||
['http_request', 'grpc_request', 'websocket_request'],
|
||||
child.id,
|
||||
);
|
||||
await renameModelWithPrompt(request);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Duplicate',
|
||||
hotKeyAction: 'http_request.duplicate',
|
||||
hotKeyLabelOnly: true, // Would trigger for every request (bad)
|
||||
leftSlot: <Icon icon="copy" />,
|
||||
onSelect: async () => {
|
||||
const request = getModel(
|
||||
['http_request', 'grpc_request', 'websocket_request'],
|
||||
child.id,
|
||||
);
|
||||
await duplicateRequestAndNavigate(request);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Move',
|
||||
leftSlot: <Icon icon="arrow_right_circle" />,
|
||||
hidden: workspaces.length <= 1,
|
||||
onSelect: moveToWorkspace.mutate,
|
||||
},
|
||||
{
|
||||
color: 'danger',
|
||||
label: 'Delete',
|
||||
hotKeyAction: 'sidebar.delete_selected_item',
|
||||
hotKeyLabelOnly: true,
|
||||
leftSlot: <Icon icon="trash" />,
|
||||
onSelect: async () => {
|
||||
await deleteModelWithConfirm(getModel(child.model, child.id));
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
}, [
|
||||
child.children,
|
||||
child.id,
|
||||
child.model,
|
||||
createDropdownItems,
|
||||
httpRequestActions,
|
||||
grpcRequestActions,
|
||||
moveToWorkspace.mutate,
|
||||
sendManyRequests,
|
||||
sendRequest,
|
||||
workspaces.length,
|
||||
]);
|
||||
|
||||
return <ContextMenu triggerPosition={show} items={items} onClose={close} />;
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import {
|
||||
grpcConnectionsAtom,
|
||||
httpResponsesAtom,
|
||||
websocketConnectionsAtom,
|
||||
} from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import React, { Fragment, memo } from 'react';
|
||||
import { VStack } from '../core/Stacks';
|
||||
import { DropMarker } from '../DropMarker';
|
||||
import type { SidebarTreeNode } from './Sidebar';
|
||||
import { SidebarItem } from './SidebarItem';
|
||||
|
||||
export interface SidebarItemsProps {
|
||||
tree: SidebarTreeNode;
|
||||
draggingId: string | null;
|
||||
selectedTree: SidebarTreeNode | null;
|
||||
treeParentMap: Record<string, SidebarTreeNode>;
|
||||
hoveredTree: SidebarTreeNode | null;
|
||||
hoveredIndex: number | null;
|
||||
handleMove: (id: string, side: 'above' | 'below') => void;
|
||||
handleEnd: (id: string) => void;
|
||||
handleDragStart: (id: string) => void;
|
||||
onSelect: (requestId: string) => void;
|
||||
}
|
||||
|
||||
export const SidebarItems = memo(function SidebarItems({
|
||||
tree,
|
||||
selectedTree,
|
||||
draggingId,
|
||||
onSelect,
|
||||
treeParentMap,
|
||||
hoveredTree,
|
||||
hoveredIndex,
|
||||
handleEnd,
|
||||
handleMove,
|
||||
handleDragStart,
|
||||
}: SidebarItemsProps) {
|
||||
const httpResponses = useAtomValue(httpResponsesAtom);
|
||||
const grpcConnections = useAtomValue(grpcConnectionsAtom);
|
||||
const websocketConnections = useAtomValue(websocketConnectionsAtom);
|
||||
|
||||
return (
|
||||
<VStack
|
||||
as="ul"
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
dir="ltr"
|
||||
className={classNames(
|
||||
tree.depth > 0 && 'border-l border-border',
|
||||
tree.depth === 0 && 'ml-0',
|
||||
tree.depth >= 1 && 'ml-[1.2rem]',
|
||||
)}
|
||||
>
|
||||
{tree.children.map((child, i) => {
|
||||
return (
|
||||
<Fragment key={child.id}>
|
||||
{hoveredIndex === i && hoveredTree?.id === tree.id && <DropMarker />}
|
||||
<SidebarItem
|
||||
itemId={child.id}
|
||||
itemName={child.name}
|
||||
itemModel={child.model}
|
||||
latestHttpResponse={httpResponses.find((r) => r.requestId === child.id) ?? null}
|
||||
latestGrpcConnection={grpcConnections.find((c) => c.requestId === child.id) ?? null}
|
||||
latestWebsocketConnection={
|
||||
websocketConnections.find((c) => c.requestId === child.id) ?? null
|
||||
}
|
||||
onMove={handleMove}
|
||||
onEnd={handleEnd}
|
||||
onSelect={onSelect}
|
||||
onDragStart={handleDragStart}
|
||||
child={child}
|
||||
>
|
||||
{child.model === 'folder' && draggingId !== child.id ? (
|
||||
<SidebarItems
|
||||
draggingId={draggingId}
|
||||
handleDragStart={handleDragStart}
|
||||
handleEnd={handleEnd}
|
||||
handleMove={handleMove}
|
||||
hoveredIndex={hoveredIndex}
|
||||
hoveredTree={hoveredTree}
|
||||
onSelect={onSelect}
|
||||
selectedTree={selectedTree}
|
||||
tree={child}
|
||||
treeParentMap={treeParentMap}
|
||||
/>
|
||||
) : null}
|
||||
</SidebarItem>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{hoveredIndex === tree.children.length && hoveredTree?.id === tree.id && <DropMarker />}
|
||||
</VStack>
|
||||
);
|
||||
});
|
||||
9
src-web/hooks/useActiveFolder.ts
Normal file
9
src-web/hooks/useActiveFolder.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { foldersAtom } from '@yaakapp-internal/models';
|
||||
import { atom } from 'jotai';
|
||||
import { activeFolderIdAtom } from './useActiveFolderId';
|
||||
|
||||
export const activeFolderAtom = atom((get) => {
|
||||
const activeFolderId = get(activeFolderIdAtom);
|
||||
const folders = get(foldersAtom);
|
||||
return folders.find((r) => r.id === activeFolderId) ?? null;
|
||||
});
|
||||
11
src-web/hooks/useActiveFolderId.ts
Normal file
11
src-web/hooks/useActiveFolderId.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { useSearch } from '@tanstack/react-router';
|
||||
import { atom } from 'jotai';
|
||||
import { useEffect } from 'react';
|
||||
import { jotaiStore } from '../lib/jotai';
|
||||
|
||||
export const activeFolderIdAtom = atom<string | null>(null);
|
||||
|
||||
export function useSubscribeActiveFolderId() {
|
||||
const { folder_id } = useSearch({ strict: false });
|
||||
useEffect(() => jotaiStore.set(activeFolderIdAtom, folder_id ?? null), [folder_id]);
|
||||
}
|
||||
@@ -1,14 +1,10 @@
|
||||
import { useSearch } from '@tanstack/react-router';
|
||||
import { atom, useAtomValue } from 'jotai';
|
||||
import { atom } from 'jotai';
|
||||
import { useEffect } from 'react';
|
||||
import { jotaiStore } from '../lib/jotai';
|
||||
|
||||
export const activeRequestIdAtom = atom<string | null>(null);
|
||||
|
||||
export function useActiveRequestId(): string | null {
|
||||
return useAtomValue(activeRequestIdAtom);
|
||||
}
|
||||
|
||||
export function useSubscribeActiveRequestId() {
|
||||
const { request_id } = useSearch({ strict: false });
|
||||
useEffect(() => jotaiStore.set(activeRequestIdAtom, request_id ?? null), [request_id]);
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
import type { Folder } from '@yaakapp-internal/models';
|
||||
import { patchModel } from '@yaakapp-internal/models';
|
||||
import { useMemo } from 'react';
|
||||
import { openFolderSettings } from '../commands/openFolderSettings';
|
||||
import { openWorkspaceSettings } from '../commands/openWorkspaceSettings';
|
||||
import { Icon } from '../components/core/Icon';
|
||||
import { IconTooltip } from '../components/core/IconTooltip';
|
||||
import { InlineCode } from '../components/core/InlineCode';
|
||||
import { HStack } from '../components/core/Stacks';
|
||||
import type { TabItem } from '../components/core/Tabs/Tabs';
|
||||
import { capitalize } from '../lib/capitalize';
|
||||
import { showConfirm } from '../lib/confirm';
|
||||
import { resolvedModelName } from '../lib/resolvedModelName';
|
||||
import { useHttpAuthenticationSummaries } from './useHttpAuthentication';
|
||||
import type { AuthenticatedModel} from './useInheritedAuthentication';
|
||||
import type { AuthenticatedModel } from './useInheritedAuthentication';
|
||||
import { useInheritedAuthentication } from './useInheritedAuthentication';
|
||||
import { useModelAncestors } from './useModelAncestors';
|
||||
|
||||
export function useAuthTab<T extends string>(tabValue: T, model: AuthenticatedModel | null) {
|
||||
const authentication = useHttpAuthenticationSummaries();
|
||||
const inheritedAuth = useInheritedAuthentication(model);
|
||||
const ancestors = useModelAncestors(model);
|
||||
const parentModel = ancestors[0] ?? null;
|
||||
|
||||
return useMemo<TabItem[]>(() => {
|
||||
if (model == null) return [];
|
||||
@@ -47,6 +57,49 @@ export function useAuthTab<T extends string>(tabValue: T, model: AuthenticatedMo
|
||||
},
|
||||
{ label: 'No Auth', shortLabel: 'No Auth', value: 'none' },
|
||||
],
|
||||
itemsAfter:
|
||||
parentModel &&
|
||||
model.authenticationType &&
|
||||
model.authenticationType !== 'none' &&
|
||||
(parentModel.authenticationType == null || parentModel.authenticationType === 'none')
|
||||
? [
|
||||
{ type: 'separator', label: 'Actions' },
|
||||
{
|
||||
label: `Promote to ${capitalize(parentModel.model)}`,
|
||||
leftSlot: (
|
||||
<Icon
|
||||
icon={parentModel.model === 'workspace' ? 'corner_right_up' : 'folder_up'}
|
||||
/>
|
||||
),
|
||||
onSelect: async () => {
|
||||
const confirmed = await showConfirm({
|
||||
id: 'promote-auth-confirm',
|
||||
title: 'Promote Authentication',
|
||||
confirmText: 'Promote',
|
||||
description: (
|
||||
<>
|
||||
Move authentication config to{' '}
|
||||
<InlineCode>{resolvedModelName(parentModel)}</InlineCode>?
|
||||
</>
|
||||
),
|
||||
});
|
||||
if (confirmed) {
|
||||
await patchModel(model, { authentication: {}, authenticationType: null });
|
||||
await patchModel(parentModel, {
|
||||
authentication: model.authentication,
|
||||
authenticationType: model.authenticationType,
|
||||
});
|
||||
|
||||
if (parentModel.model === 'folder') {
|
||||
openFolderSettings(parentModel.id, 'auth');
|
||||
} else {
|
||||
openWorkspaceSettings('auth');
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
onChange: async (authenticationType) => {
|
||||
let authentication: Folder['authentication'] = model.authentication;
|
||||
if (model.authenticationType !== authenticationType) {
|
||||
@@ -60,5 +113,5 @@ export function useAuthTab<T extends string>(tabValue: T, model: AuthenticatedMo
|
||||
};
|
||||
|
||||
return [tab];
|
||||
}, [authentication, inheritedAuth, model, tabValue]);
|
||||
}, [authentication, inheritedAuth, model, parentModel, tabValue]);
|
||||
}
|
||||
|
||||
@@ -20,25 +20,7 @@ export function useGrpcRequestActions() {
|
||||
const actionsResult = useQuery<CallableGrpcRequestAction[]>({
|
||||
queryKey: ['grpc_request_actions', pluginsKey],
|
||||
queryFn: async () => {
|
||||
const responses = await invokeCmd<GetGrpcRequestActionsResponse[]>(
|
||||
'cmd_grpc_request_actions',
|
||||
);
|
||||
|
||||
return responses.flatMap((r) =>
|
||||
r.actions.map((a, i) => ({
|
||||
label: a.label,
|
||||
icon: a.icon,
|
||||
call: async (grpcRequest: GrpcRequest) => {
|
||||
const protoFiles = await getGrpcProtoFiles(grpcRequest.id);
|
||||
const payload: CallGrpcRequestActionRequest = {
|
||||
index: i,
|
||||
pluginRefId: r.pluginRefId,
|
||||
args: { grpcRequest, protoFiles },
|
||||
};
|
||||
await invokeCmd('cmd_call_grpc_request_action', { req: payload });
|
||||
},
|
||||
})),
|
||||
);
|
||||
return getGrpcRequestActions();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -49,3 +31,23 @@ export function useGrpcRequestActions() {
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
export async function getGrpcRequestActions() {
|
||||
const responses = await invokeCmd<GetGrpcRequestActionsResponse[]>('cmd_grpc_request_actions');
|
||||
|
||||
return responses.flatMap((r) =>
|
||||
r.actions.map((a, i) => ({
|
||||
label: a.label,
|
||||
icon: a.icon,
|
||||
call: async (grpcRequest: GrpcRequest) => {
|
||||
const protoFiles = await getGrpcProtoFiles(grpcRequest.id);
|
||||
const payload: CallGrpcRequestActionRequest = {
|
||||
index: i,
|
||||
pluginRefId: r.pluginRefId,
|
||||
args: { grpcRequest, protoFiles },
|
||||
};
|
||||
await invokeCmd('cmd_call_grpc_request_action', { req: payload });
|
||||
},
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { type } from '@tauri-apps/plugin-os';
|
||||
import { debounce } from '@yaakapp-internal/lib';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { atom } from 'jotai';
|
||||
import { useEffect } from 'react';
|
||||
import { capitalize } from '../lib/capitalize';
|
||||
import { jotaiStore } from '../lib/jotai';
|
||||
|
||||
const HOLD_KEYS = ['Shift', 'Control', 'Command', 'Alt', 'Meta'];
|
||||
|
||||
@@ -11,11 +13,10 @@ export type HotkeyAction =
|
||||
| 'app.zoom_reset'
|
||||
| 'command_palette.toggle'
|
||||
| 'environmentEditor.toggle'
|
||||
| 'grpc_request.send'
|
||||
| 'hotkeys.showHelp'
|
||||
| 'http_request.create'
|
||||
| 'http_request.duplicate'
|
||||
| 'http_request.send'
|
||||
| 'model.create'
|
||||
| 'model.duplicate'
|
||||
| 'request.send'
|
||||
| 'request_switcher.next'
|
||||
| 'request_switcher.prev'
|
||||
| 'request_switcher.toggle'
|
||||
@@ -31,11 +32,10 @@ const hotkeys: Record<HotkeyAction, string[]> = {
|
||||
'app.zoom_reset': ['CmdCtrl+0'],
|
||||
'command_palette.toggle': ['CmdCtrl+k'],
|
||||
'environmentEditor.toggle': ['CmdCtrl+Shift+E', 'CmdCtrl+Shift+e'],
|
||||
'grpc_request.send': ['CmdCtrl+Enter', 'CmdCtrl+r'],
|
||||
'request.send': ['CmdCtrl+Enter', 'CmdCtrl+r'],
|
||||
'hotkeys.showHelp': ['CmdCtrl+Shift+/', 'CmdCtrl+Shift+?'], // when shift is pressed, it might be a question mark
|
||||
'http_request.create': ['CmdCtrl+n'],
|
||||
'http_request.duplicate': ['CmdCtrl+d'],
|
||||
'http_request.send': ['CmdCtrl+Enter', 'CmdCtrl+r'],
|
||||
'model.create': ['CmdCtrl+n'],
|
||||
'model.duplicate': ['CmdCtrl+d'],
|
||||
'request_switcher.next': ['Control+Shift+Tab'],
|
||||
'request_switcher.prev': ['Control+Tab'],
|
||||
'request_switcher.toggle': ['CmdCtrl+p'],
|
||||
@@ -52,11 +52,10 @@ const hotkeyLabels: Record<HotkeyAction, string> = {
|
||||
'app.zoom_reset': 'Zoom to Actual Size',
|
||||
'command_palette.toggle': 'Toggle Command Palette',
|
||||
'environmentEditor.toggle': 'Edit Environments',
|
||||
'grpc_request.send': 'Send Message',
|
||||
'hotkeys.showHelp': 'Show Keyboard Shortcuts',
|
||||
'http_request.create': 'New Request',
|
||||
'http_request.duplicate': 'Duplicate Request',
|
||||
'http_request.send': 'Send Request',
|
||||
'model.create': 'New Request',
|
||||
'model.duplicate': 'Duplicate Request',
|
||||
'request.send': 'Send',
|
||||
'request_switcher.next': 'Go To Previous Request',
|
||||
'request_switcher.prev': 'Go To Next Request',
|
||||
'request_switcher.toggle': 'Toggle Request Switcher',
|
||||
@@ -71,108 +70,139 @@ const layoutInsensitiveKeys = ['Equal', 'Minus', 'BracketLeft', 'BracketRight',
|
||||
|
||||
export const hotkeyActions: HotkeyAction[] = Object.keys(hotkeys) as (keyof typeof hotkeys)[];
|
||||
|
||||
interface Options {
|
||||
enable?: boolean;
|
||||
export type HotKeyOptions = {
|
||||
enable?: boolean | (() => boolean);
|
||||
priority?: number;
|
||||
};
|
||||
|
||||
interface Callback {
|
||||
action: HotkeyAction;
|
||||
callback: (e: KeyboardEvent) => void;
|
||||
options: HotKeyOptions;
|
||||
}
|
||||
|
||||
const callbacksAtom = atom<Callback[]>([]);
|
||||
const currentKeysAtom = atom<Set<string>>(new Set([]));
|
||||
export const sortedCallbacksAtom = atom((get) =>
|
||||
[...get(callbacksAtom)].sort((a, b) => (b.options.priority ?? 0) - (a.options.priority ?? 0)),
|
||||
);
|
||||
|
||||
const clearCurrentKeysDebounced = debounce(() => {
|
||||
jotaiStore.set(currentKeysAtom, new Set([]));
|
||||
}, 5000);
|
||||
|
||||
export function useHotKey(
|
||||
action: HotkeyAction | null,
|
||||
callback: (e: KeyboardEvent) => void,
|
||||
options: Options = {},
|
||||
options: HotKeyOptions = {},
|
||||
) {
|
||||
const currentKeys = useRef<Set<string>>(new Set());
|
||||
const callbackRef = useRef(callback);
|
||||
|
||||
useEffect(() => {
|
||||
callbackRef.current = callback;
|
||||
}, [callback]);
|
||||
|
||||
useEffect(() => {
|
||||
// Sometimes the keyup event doesn't fire (eg, cmd+Tab), so we clear the keys after a timeout
|
||||
const clearCurrentKeys = debounce(() => currentKeys.current.clear(), 5000);
|
||||
|
||||
const down = (e: KeyboardEvent) => {
|
||||
if (options.enable === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't add key if not holding modifier
|
||||
const isValidKeymapKey =
|
||||
e.altKey || e.ctrlKey || e.metaKey || e.shiftKey || e.key === 'Backspace' || e.key === 'Delete';
|
||||
if (!isValidKeymapKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't add hold keys
|
||||
if (HOLD_KEYS.includes(e.key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const keyToAdd = layoutInsensitiveKeys.includes(e.code) ? e.code : e.key;
|
||||
currentKeys.current.add(keyToAdd);
|
||||
|
||||
const currentKeysWithModifiers = new Set(currentKeys.current);
|
||||
if (e.altKey) currentKeysWithModifiers.add('Alt');
|
||||
if (e.ctrlKey) currentKeysWithModifiers.add('Control');
|
||||
if (e.metaKey) currentKeysWithModifiers.add('Meta');
|
||||
if (e.shiftKey) currentKeysWithModifiers.add('Shift');
|
||||
|
||||
for (const [hkAction, hkKeys] of Object.entries(hotkeys) as [HotkeyAction, string[]][]) {
|
||||
if (
|
||||
(e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) &&
|
||||
currentKeysWithModifiers.size === 1 &&
|
||||
currentKeysWithModifiers.has('Backspace')
|
||||
) {
|
||||
// Don't support Backspace-only modifiers within input fields. This is fairly brittle, so maybe there's a
|
||||
// better way to do stuff like this in the future.
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const hkKey of hkKeys) {
|
||||
if (hkAction !== action) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const keys = hkKey.split('+').map(resolveHotkeyKey);
|
||||
if (
|
||||
keys.length === currentKeysWithModifiers.size &&
|
||||
keys.every((key) => currentKeysWithModifiers.has(key))
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
callbackRef.current(e);
|
||||
currentKeys.current.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clearCurrentKeys();
|
||||
};
|
||||
|
||||
const up = (e: KeyboardEvent) => {
|
||||
if (options.enable === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
const keyToRemove = layoutInsensitiveKeys.includes(e.code) ? e.code : e.key;
|
||||
currentKeys.current.delete(keyToRemove);
|
||||
|
||||
// Clear all keys if no longer holding modifier
|
||||
// HACK: This is to get around the case of DOWN SHIFT -> DOWN : -> UP SHIFT -> UP ;
|
||||
// As you see, the ":" is not removed because it turned into ";" when shift was released
|
||||
const isHoldingModifier = e.altKey || e.ctrlKey || e.metaKey || e.shiftKey;
|
||||
if (!isHoldingModifier) {
|
||||
currentKeys.current.clear();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keyup', up, { capture: true });
|
||||
document.addEventListener('keydown', down, { capture: true });
|
||||
if (action == null) return;
|
||||
jotaiStore.set(callbacksAtom, (prev) => {
|
||||
const without = prev.filter((cb) => {
|
||||
const isTheSame = cb.action === action && cb.options.priority === options.priority;
|
||||
return !isTheSame;
|
||||
});
|
||||
const newCb: Callback = { action, callback, options };
|
||||
return [...without, newCb];
|
||||
});
|
||||
return () => {
|
||||
document.removeEventListener('keydown', down, { capture: true });
|
||||
document.removeEventListener('keyup', up, { capture: true });
|
||||
jotaiStore.set(callbacksAtom, (prev) => prev.filter((cb) => cb.action !== action));
|
||||
};
|
||||
}, [action, options.enable]);
|
||||
}, [action, callback, options]);
|
||||
}
|
||||
|
||||
export function useSubscribeHotKeys() {
|
||||
useEffect(() => {
|
||||
document.addEventListener('keyup', handleKeyUp, { capture: true });
|
||||
document.addEventListener('keydown', handleKeyDown, { capture: true });
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown, { capture: true });
|
||||
document.removeEventListener('keyup', handleKeyUp, { capture: true });
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
function handleKeyUp(e: KeyboardEvent) {
|
||||
const keyToRemove = layoutInsensitiveKeys.includes(e.code) ? e.code : e.key;
|
||||
const currentKeys = new Set(jotaiStore.get(currentKeysAtom));
|
||||
currentKeys.delete(keyToRemove);
|
||||
|
||||
// Clear all keys if no longer holding modifier
|
||||
// HACK: This is to get around the case of DOWN SHIFT -> DOWN : -> UP SHIFT -> UP ;
|
||||
// As you see, the ":" is not removed because it turned into ";" when shift was released
|
||||
const isHoldingModifier = e.altKey || e.ctrlKey || e.metaKey || e.shiftKey;
|
||||
if (!isHoldingModifier) {
|
||||
currentKeys.clear();
|
||||
}
|
||||
|
||||
jotaiStore.set(currentKeysAtom, currentKeys);
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
// Don't add key if not holding modifier
|
||||
const isValidKeymapKey =
|
||||
e.altKey || e.ctrlKey || e.metaKey || e.shiftKey || e.key === 'Backspace' || e.key === 'Delete';
|
||||
if (!isValidKeymapKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't add hold keys
|
||||
if (HOLD_KEYS.includes(e.key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const keyToAdd = layoutInsensitiveKeys.includes(e.code) ? e.code : e.key;
|
||||
const currentKeys = new Set(jotaiStore.get(currentKeysAtom));
|
||||
currentKeys.add(keyToAdd);
|
||||
|
||||
const currentKeysWithModifiers = new Set(currentKeys);
|
||||
if (e.altKey) currentKeysWithModifiers.add('Alt');
|
||||
if (e.ctrlKey) currentKeysWithModifiers.add('Control');
|
||||
if (e.metaKey) currentKeysWithModifiers.add('Meta');
|
||||
if (e.shiftKey) currentKeysWithModifiers.add('Shift');
|
||||
|
||||
for (const [hkAction, hkKeys] of Object.entries(hotkeys) as [HotkeyAction, string[]][]) {
|
||||
if (
|
||||
(e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) &&
|
||||
currentKeysWithModifiers.size === 1 &&
|
||||
currentKeysWithModifiers.has('Backspace')
|
||||
) {
|
||||
// Don't support Backspace-only modifiers within input fields. This is fairly brittle, so maybe there's a
|
||||
// better way to do stuff like this in the future.
|
||||
continue;
|
||||
}
|
||||
|
||||
const executed: string[] = [];
|
||||
for (const { action, callback, options } of jotaiStore.get(sortedCallbacksAtom)) {
|
||||
const enable = typeof options.enable === 'function' ? options.enable() : options.enable;
|
||||
if (enable === false) {
|
||||
continue;
|
||||
}
|
||||
if (hkAction !== action) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const hkKey of hkKeys) {
|
||||
const keys = hkKey.split('+').map(resolveHotkeyKey);
|
||||
if (
|
||||
keys.length === currentKeysWithModifiers.size &&
|
||||
keys.every((key) => currentKeysWithModifiers.has(key))
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
callback(e);
|
||||
executed.push(`${action} ${options.priority ?? 0}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (executed.length > 0) {
|
||||
console.log('Executed hotkey', executed.join(', '));
|
||||
jotaiStore.set(currentKeysAtom, new Set([]));
|
||||
}
|
||||
}
|
||||
|
||||
clearCurrentKeysDebounced();
|
||||
}
|
||||
|
||||
export function useHotKeyLabel(action: HotkeyAction): string {
|
||||
|
||||
@@ -18,26 +18,7 @@ export function useHttpRequestActions() {
|
||||
|
||||
const actionsResult = useQuery<CallableHttpRequestAction[]>({
|
||||
queryKey: ['http_request_actions', pluginsKey],
|
||||
queryFn: async () => {
|
||||
const responses = await invokeCmd<GetHttpRequestActionsResponse[]>(
|
||||
'cmd_http_request_actions',
|
||||
);
|
||||
|
||||
return responses.flatMap((r) =>
|
||||
r.actions.map((a, i) => ({
|
||||
label: a.label,
|
||||
icon: a.icon,
|
||||
call: async (httpRequest: HttpRequest) => {
|
||||
const payload: CallHttpRequestActionRequest = {
|
||||
index: i,
|
||||
pluginRefId: r.pluginRefId,
|
||||
args: { httpRequest },
|
||||
};
|
||||
await invokeCmd('cmd_call_http_request_action', { req: payload });
|
||||
},
|
||||
})),
|
||||
);
|
||||
},
|
||||
queryFn: () => getHttpRequestActions(),
|
||||
});
|
||||
|
||||
const actions = useMemo(() => {
|
||||
@@ -47,3 +28,23 @@ export function useHttpRequestActions() {
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
export async function getHttpRequestActions() {
|
||||
const responses = await invokeCmd<GetHttpRequestActionsResponse[]>('cmd_http_request_actions');
|
||||
const actions = responses.flatMap((r) =>
|
||||
r.actions.map((a, i) => ({
|
||||
label: a.label,
|
||||
icon: a.icon,
|
||||
call: async (httpRequest: HttpRequest) => {
|
||||
const payload: CallHttpRequestActionRequest = {
|
||||
index: i,
|
||||
pluginRefId: r.pluginRefId,
|
||||
args: { httpRequest },
|
||||
};
|
||||
await invokeCmd('cmd_call_http_request_action', { req: payload });
|
||||
},
|
||||
})),
|
||||
);
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
41
src-web/hooks/useModelAncestors.ts
Normal file
41
src-web/hooks/useModelAncestors.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { AnyModel, Folder, Workspace } from '@yaakapp-internal/models';
|
||||
import { foldersAtom, workspacesAtom } from '@yaakapp-internal/models';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
type ModelAncestor = Folder | Workspace;
|
||||
|
||||
export function useModelAncestors(m: AnyModel | null) {
|
||||
const folders = useAtomValue(foldersAtom);
|
||||
const workspaces = useAtomValue(workspacesAtom);
|
||||
|
||||
return useMemo(() => getParents(folders, workspaces, m), [folders, workspaces, m]);
|
||||
}
|
||||
|
||||
function getParents(
|
||||
folders: Folder[],
|
||||
workspaces: Workspace[],
|
||||
currentModel: AnyModel | null,
|
||||
): ModelAncestor[] {
|
||||
if (currentModel == null) return [];
|
||||
|
||||
const parentFolder =
|
||||
'folderId' in currentModel && currentModel.folderId
|
||||
? folders.find((f) => f.id === currentModel.folderId)
|
||||
: null;
|
||||
|
||||
if (parentFolder != null) {
|
||||
return [parentFolder, ...getParents(folders, workspaces, parentFolder)];
|
||||
}
|
||||
|
||||
const parentWorkspace =
|
||||
'workspaceId' in currentModel && currentModel.workspaceId
|
||||
? workspaces.find((w) => w.id === currentModel.workspaceId)
|
||||
: null;
|
||||
|
||||
if (parentWorkspace != null) {
|
||||
return [parentWorkspace, ...getParents(folders, workspaces, parentWorkspace)];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import React from 'react';
|
||||
import { MoveToWorkspaceDialog } from '../components/MoveToWorkspaceDialog';
|
||||
import { showDialog } from '../lib/dialog';
|
||||
import { jotaiStore } from '../lib/jotai';
|
||||
import { activeWorkspaceIdAtom } from './useActiveWorkspace';
|
||||
import { useFastMutation } from './useFastMutation';
|
||||
import { allRequestsAtom } from './useAllRequests';
|
||||
|
||||
export function useMoveToWorkspace(id: string) {
|
||||
return useFastMutation<void, unknown>({
|
||||
mutationKey: ['move_workspace', id],
|
||||
mutationFn: async () => {
|
||||
const activeWorkspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
||||
if (activeWorkspaceId == null) return;
|
||||
|
||||
const request = jotaiStore.get(allRequestsAtom).find((r) => r.id === id);
|
||||
if (request == null) return;
|
||||
|
||||
showDialog({
|
||||
id: 'change-workspace',
|
||||
title: 'Move Workspace',
|
||||
size: 'sm',
|
||||
render: ({ hide }) => (
|
||||
<MoveToWorkspaceDialog
|
||||
onDone={hide}
|
||||
request={request}
|
||||
activeWorkspaceId={activeWorkspaceId}
|
||||
/>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { getModel } from '@yaakapp-internal/models';
|
||||
import { invokeCmd } from '../lib/tauri';
|
||||
import { getActiveCookieJar } from './useActiveCookieJar';
|
||||
import { getActiveEnvironment } from './useActiveEnvironment';
|
||||
import { useFastMutation } from './useFastMutation';
|
||||
import { createFastMutation, useFastMutation } from './useFastMutation';
|
||||
|
||||
export function useSendAnyHttpRequest() {
|
||||
return useFastMutation<HttpResponse | null, string, string | null>({
|
||||
@@ -22,3 +22,19 @@ export function useSendAnyHttpRequest() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const sendAnyHttpRequest = createFastMutation<HttpResponse | null, string, string | null>({
|
||||
mutationKey: ['send_any_request'],
|
||||
mutationFn: async (id) => {
|
||||
const request = getModel('http_request', id ?? 'n/a');
|
||||
if (request == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return invokeCmd('cmd_send_http_request', {
|
||||
request,
|
||||
environmentId: getActiveEnvironment()?.id,
|
||||
cookieJarId: getActiveCookieJar()?.id,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,43 +1,29 @@
|
||||
import { keyValuesAtom } from '@yaakapp-internal/models';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { atom, useAtomValue } from 'jotai';
|
||||
import { useCallback } from 'react';
|
||||
import { atomWithKVStorage } from '../lib/atoms/atomWithKVStorage';
|
||||
import { jotaiStore } from '../lib/jotai';
|
||||
import { setKeyValue } from '../lib/keyValueStore';
|
||||
import { activeWorkspaceIdAtom } from './useActiveWorkspace';
|
||||
import { getKeyValue } from './useKeyValue';
|
||||
|
||||
function kvKey(workspaceId: string | null) {
|
||||
return ['sidebar_collapsed', workspaceId ?? 'n/a'];
|
||||
}
|
||||
|
||||
export function useSidebarItemCollapsed(itemId: string) {
|
||||
const [isCollapsed, setIsCollapsed] = useState<boolean>(
|
||||
getSidebarCollapsedMap()[itemId] === true,
|
||||
);
|
||||
useEffect(
|
||||
() =>
|
||||
jotaiStore.sub(keyValuesAtom, () => {
|
||||
setIsCollapsed(getSidebarCollapsedMap()[itemId] === true);
|
||||
}),
|
||||
[itemId],
|
||||
);
|
||||
export const sidebarCollapsedAtom = atom((get) => {
|
||||
const workspaceId = get(activeWorkspaceIdAtom);
|
||||
return atomWithKVStorage<Record<string, boolean>>(kvKey(workspaceId), {});
|
||||
});
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setKeyValue({
|
||||
key: kvKey(jotaiStore.get(activeWorkspaceIdAtom)),
|
||||
namespace: 'no_sync',
|
||||
value: { ...getSidebarCollapsedMap(), [itemId]: !isCollapsed },
|
||||
}).catch(console.error);
|
||||
}, [isCollapsed, itemId]);
|
||||
export function useSidebarItemCollapsed(itemId: string) {
|
||||
const map = useAtomValue(useAtomValue(sidebarCollapsedAtom));
|
||||
const isCollapsed = map[itemId] === true;
|
||||
|
||||
const toggle = useCallback(() => toggleSidebarItemCollapsed(itemId), [itemId]);
|
||||
|
||||
return [isCollapsed, toggle] as const;
|
||||
}
|
||||
|
||||
export function getSidebarCollapsedMap() {
|
||||
const value = getKeyValue<Record<string, boolean>>({
|
||||
key: kvKey(jotaiStore.get(activeWorkspaceIdAtom)),
|
||||
fallback: {},
|
||||
namespace: 'no_sync',
|
||||
export function toggleSidebarItemCollapsed(itemId: string) {
|
||||
jotaiStore.set(jotaiStore.get(sidebarCollapsedAtom), (prev) => {
|
||||
return { ...prev, [itemId]: !prev[itemId] };
|
||||
});
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { atom } from 'jotai';
|
||||
import { getKeyValue, setKeyValue } from '../keyValueStore';
|
||||
|
||||
export function atomWithKVStorage<T extends object | boolean | number | string | null>(
|
||||
key: string,
|
||||
key: string | string[],
|
||||
fallback: T,
|
||||
namespace = 'global',
|
||||
) {
|
||||
|
||||
@@ -21,6 +21,8 @@ export function languageFromContentType(
|
||||
} else if (justContentType.includes('javascript')) {
|
||||
// Sometimes `application/javascript` returns JSON, so try detecting that
|
||||
return detectFromContent(content, 'javascript');
|
||||
} else if (justContentType.includes('markdown')) {
|
||||
return 'markdown';
|
||||
}
|
||||
|
||||
return detectFromContent(content, 'text');
|
||||
|
||||
@@ -1,25 +1,49 @@
|
||||
import type { AnyModel } from '@yaakapp-internal/models';
|
||||
import { deleteModel, modelTypeLabel } from '@yaakapp-internal/models';
|
||||
import { InlineCode } from '../components/core/InlineCode';
|
||||
import { Prose } from '../components/Prose';
|
||||
import { showConfirmDelete } from './confirm';
|
||||
import { pluralizeCount } from './pluralize';
|
||||
import { resolvedModelName } from './resolvedModelName';
|
||||
|
||||
export async function deleteModelWithConfirm(
|
||||
model: AnyModel | null,
|
||||
model: AnyModel | AnyModel[] | null,
|
||||
options: { confirmName?: string } = {},
|
||||
): Promise<boolean> {
|
||||
if (model == null) {
|
||||
console.warn('Tried to delete null model');
|
||||
return false;
|
||||
}
|
||||
|
||||
const models = Array.isArray(model) ? model : [model];
|
||||
const descriptor =
|
||||
models.length === 1 ? modelTypeLabel(models[0]!) : pluralizeCount('Item', models.length);
|
||||
const confirmed = await showConfirmDelete({
|
||||
id: 'delete-model-' + model.id,
|
||||
title: 'Delete ' + modelTypeLabel(model),
|
||||
id: 'delete-model-' + models.map((m) => m.id).join(','),
|
||||
title: `Delete ${descriptor}`,
|
||||
requireTyping: options.confirmName,
|
||||
description: (
|
||||
<>
|
||||
Permanently delete <InlineCode>{resolvedModelName(model)}</InlineCode>?
|
||||
Permanently delete{' '}
|
||||
{models.length === 1 ? (
|
||||
<>
|
||||
<InlineCode>{resolvedModelName(models[0]!)}</InlineCode>?
|
||||
</>
|
||||
) : models.length < 10 ? (
|
||||
<>
|
||||
the following?
|
||||
<Prose className="mt-2">
|
||||
<ul>
|
||||
{models.map((m) => (
|
||||
<li key={m.id}>
|
||||
<InlineCode>{resolvedModelName(m)}</InlineCode>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Prose>
|
||||
</>
|
||||
) : (
|
||||
`all ${pluralizeCount('item', models.length)}?`
|
||||
)}
|
||||
</>
|
||||
),
|
||||
});
|
||||
@@ -28,6 +52,6 @@ export async function deleteModelWithConfirm(
|
||||
return false;
|
||||
}
|
||||
|
||||
await deleteModel(model);
|
||||
await Promise.allSettled(models.map((m) => deleteModel(m)));
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { GrpcRequest, HttpRequest, WebsocketRequest } from '@yaakapp-internal/models';
|
||||
import { duplicateModel } from '@yaakapp-internal/models';
|
||||
import { activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace';
|
||||
import { jotaiStore } from './jotai';
|
||||
import { router } from './router';
|
||||
|
||||
export async function duplicateRequestAndNavigate(
|
||||
model: HttpRequest | GrpcRequest | WebsocketRequest | null,
|
||||
) {
|
||||
if (model == null) {
|
||||
throw new Error('Cannot duplicate null request');
|
||||
}
|
||||
|
||||
const newId = await duplicateModel(model);
|
||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
||||
if (workspaceId == null) return;
|
||||
|
||||
await router.navigate({
|
||||
to: '/workspaces/$workspaceId',
|
||||
params: { workspaceId },
|
||||
search: (prev) => ({ ...prev, request_id: newId }),
|
||||
});
|
||||
}
|
||||
19
src-web/lib/duplicateRequestOrFolderAndNavigate.tsx
Normal file
19
src-web/lib/duplicateRequestOrFolderAndNavigate.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { Folder, GrpcRequest, HttpRequest, WebsocketRequest } from '@yaakapp-internal/models';
|
||||
import { duplicateModel } from '@yaakapp-internal/models';
|
||||
import { activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace';
|
||||
import { jotaiStore } from './jotai';
|
||||
import { navigateToRequestOrFolderOrWorkspace } from './setWorkspaceSearchParams';
|
||||
|
||||
export async function duplicateRequestOrFolderAndNavigate(
|
||||
model: Folder | HttpRequest | GrpcRequest | WebsocketRequest | null,
|
||||
) {
|
||||
if (model == null) {
|
||||
throw new Error('Cannot duplicate null item');
|
||||
}
|
||||
|
||||
const newId = await duplicateModel(model);
|
||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
||||
if (workspaceId == null) return;
|
||||
|
||||
navigateToRequestOrFolderOrWorkspace(newId, model.model);
|
||||
}
|
||||
4
src-web/lib/scopes.ts
Normal file
4
src-web/lib/scopes.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
|
||||
export function isSidebarFocused() {
|
||||
return document.activeElement?.closest('.x-theme-sidebar') != null;
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Folder, GrpcRequest, WebsocketRequest, Workspace } from '@yaakapp-internal/models';
|
||||
import type { HttpRequest } from '@yaakapp-internal/sync';
|
||||
import { router } from './router.js';
|
||||
|
||||
/**
|
||||
@@ -10,11 +12,28 @@ export function setWorkspaceSearchParams(
|
||||
cookie_jar_id: string | null;
|
||||
environment_id: string | null;
|
||||
request_id: string | null;
|
||||
folder_id: string | null;
|
||||
}>,
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(router as any).navigate({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
search: (prev: any) => ({ ...prev, ...search }),
|
||||
search: (prev: any) => {
|
||||
console.log('Navigating to', { prev, search });
|
||||
return { ...prev, ...search };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function navigateToRequestOrFolderOrWorkspace(
|
||||
id: string,
|
||||
model: (Workspace | Folder | HttpRequest | GrpcRequest | WebsocketRequest)['model'],
|
||||
) {
|
||||
if (model === 'workspace') {
|
||||
setWorkspaceSearchParams({ request_id: null, folder_id: null });
|
||||
} else if (model === 'folder') {
|
||||
setWorkspaceSearchParams({ request_id: null, folder_id: id });
|
||||
} else {
|
||||
setWorkspaceSearchParams({ request_id: id, folder_id: null });
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user