Compare commits

..

12 Commits

Author SHA1 Message Date
Gregory Schier
d46479cd22 Remove debug console log from TreeDragOverlay component 2025-10-15 14:08:21 -07:00
Gregory Schier
19cae33382 Fix crash when delete after drag 2025-10-15 14:07:55 -07:00
Gregory Schier
267cd079ad New sidebar and folder view (#263) 2025-10-15 13:46:57 -07:00
Gregory Schier
19c1efc73e Resolve 2025-10-11 08:28:07 -07:00
Gregory Schier
dfa9a22861 Merge remote-tracking branch 'origin/main' 2025-10-11 06:29:17 -07:00
Gregory Schier
533f9bacc4 Add AWS authentication 2025-10-11 06:29:06 -07:00
Zhizhen He
0358748729 Fix icon paths in package.json (#265) 2025-10-09 04:24:44 -07:00
Zhizhen He
1540d0a5a5 Fix typo (#264) 2025-10-08 19:54:18 -07:00
Gregory Schier
d177e164f1 Fix log 2025-10-08 04:25:06 -07:00
Gregory Schier
f1355c9d15 Fix non-release build 2025-10-08 04:25:00 -07:00
Gregory Schier
485a9ea47c Show toast on plugin event handling errors instead of crashing
Also set folder context on template render and fix timestamp function
2025-10-06 06:53:45 -07:00
Gregory Schier
dbc606fb53 Update README 2025-10-04 08:22:39 -07:00
105 changed files with 3432 additions and 1591 deletions

View File

@@ -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'

View File

@@ -42,7 +42,7 @@ Built with [Tauri](https://tauri.app), Rust, and React, its 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, its 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
View File

@@ -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",

View File

@@ -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",

View File

@@ -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,
/**

View File

@@ -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, };

View File

@@ -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,
});
},

View 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).
![Screenshot of AWS SigV4 UI](screenshot.png)
## 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

View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 790 KiB

View 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 || '') })),
};
},
},
};

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.json"
}

View File

@@ -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 }] };
},
},
};

View File

@@ -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;
}

View File

@@ -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 });
}

View File

@@ -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),

View File

@@ -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> {

View File

@@ -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),
}
}

View File

@@ -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 = (

View File

@@ -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())
}
}

View File

@@ -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 });
}

View 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)?))
}
}
}

View File

@@ -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)
}
}

View File

@@ -1,3 +1,4 @@
pub mod any_request;
mod batch;
mod cookie_jars;
mod environments;

View File

@@ -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),

View File

@@ -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 {

View File

@@ -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;
}

View 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);
}
}

View File

@@ -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::*;

View File

@@ -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 ]}");

View File

@@ -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())
}

View 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}
/>
),
});
},
});

View File

@@ -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,

View File

@@ -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} />;
}

View File

@@ -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>

View 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>&bull;</span>
<HttpResponseDurationTag response={latestResponse} />
<span>&bull;</span>
<SizeTag contentLength={latestResponse.contentLength ?? 0} />
</HStack>
</button>
) : (
<div>No Responses</div>
)}
</div>
);
}

View File

@@ -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}>

View File

@@ -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;
}

View File

@@ -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>
)

View File

@@ -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={

View File

@@ -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">

View File

@@ -67,7 +67,7 @@ export function HttpAuthenticationEditor({ model }: Props) {
</EmptyStateText>
);
} else {
return <EmptyStateText>Authentication not configured</EmptyStateText>;
return <EmptyStateText>No authentication</EmptyStateText>;
}
}

View File

@@ -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;
}

View File

@@ -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">

View File

@@ -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);
},
});

View 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;
}

View File

@@ -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 }}

View File

@@ -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]);

View File

@@ -25,9 +25,8 @@ export function ResizeHandle({
return (
<div
aria-hidden
draggable
style={style}
onDragStart={onResizeStart}
onPointerDown={onResizeStart}
onDoubleClick={onReset}
className={classNames(
className,

View File

@@ -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}

View File

@@ -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>

View File

@@ -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();

View File

@@ -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">

View File

@@ -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)),
);
}

View File

@@ -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 {

View File

@@ -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'];

View File

@@ -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) => {

View File

@@ -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}

View File

@@ -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);

View File

@@ -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>
);

View File

@@ -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 {

View File

@@ -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]);

View File

@@ -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"

View File

@@ -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],
);

View File

@@ -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],
);

View File

@@ -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}
>

View 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 theres 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;
}

View 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}
/>
))}
</>
);
}

View 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>
);
}

View 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>
);
}

View 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)} />;
});

View 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,
);

View 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';
}

View File

@@ -1,9 +1,8 @@
export enum ItemTypes {
REQUEST = 'request',
SIDEBAR = 'sidebar',
TREE_ITEM = 'tree.item',
TREE = 'tree',
}
export type DragItem = {
id: string;
itemName: string;
};

View File

@@ -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>
);

View File

@@ -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>
);

View File

@@ -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>
);
}

View File

@@ -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: [],
};
}

View File

@@ -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>
);
});

View File

@@ -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} />;
}

View File

@@ -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>
);
});

View 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;
});

View 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]);
}

View File

@@ -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]);

View File

@@ -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]);
}

View File

@@ -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 });
},
})),
);
}

View File

@@ -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 {

View File

@@ -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;
}

View 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 [];
}

View File

@@ -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}
/>
),
});
},
});
}

View File

@@ -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,
});
},
});

View File

@@ -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;
}

View File

@@ -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',
) {

View File

@@ -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');

View File

@@ -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;
}

View File

@@ -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 }),
});
}

View 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
View File

@@ -0,0 +1,4 @@
export function isSidebarFocused() {
return document.activeElement?.closest('.x-theme-sidebar') != null;
}

View File

@@ -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