Compare commits

..

39 Commits

Author SHA1 Message Date
Gregory Schier
2bcf67aaa6 Fallback to jsonpath for response filter 2025-10-31 09:45:29 -07:00
Gregory Schier
c01b8ce4ca Fix sort priority 2025-10-31 09:40:37 -07:00
Gregory Schier
f7bb649b16 Fix ref type 2025-10-31 09:25:04 -07:00
Gregory Schier
e3e67c8df7 Use TRee component for Environment dialog (#288) 2025-10-31 09:16:29 -07:00
gschier
c9698c0f23 Deploying to main from @ mountain-loop/yaak@2cdd1d8136 🚀 2025-10-31 15:36:52 +00:00
Gregory Schier
2cdd1d8136 Tree fixes and sidebar filter DSL 2025-10-31 05:59:46 -07:00
gschier
8d8e5c0317 Deploying to main from @ mountain-loop/yaak@4e66a73677 🚀 2025-10-30 00:20:16 +00:00
Gregory Schier
4e66a73677 npm i 2025-10-29 15:37:46 -07:00
Gregory Schier
08f1bc4e65 Disable sidebar filtering for now 2025-10-29 15:30:18 -07:00
Gregory Schier
c6d9cb9c9e Narrow vim keys selector 2025-10-29 14:59:33 -07:00
Gregory Schier
efbb90dd60 Prevent vim hotkeys from activating tree in sidebar filter 2025-10-29 14:59:13 -07:00
Gregory Schier
7a7940d365 Change response history dropdown icon 2025-10-29 14:58:56 -07:00
Börge Kiss
8a6f80a181 Fix dismissable banner action button title (#273) 2025-10-29 08:16:33 -07:00
Quentin Ross
e8e0097e2d Fix websocket url parameters not parsing variables (#281) 2025-10-29 08:16:07 -07:00
Zhizhen He
f475b05c51 Allow specifying time for unix / unix millis / iso 8601 format (#283)
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
2025-10-29 08:15:19 -07:00
Madeleaan
7e5f9004e2 Fix text on plugin installation button (#284) 2025-10-29 08:14:40 -07:00
Gregory Schier
660771b48c Add random.range() template function 2025-10-29 08:02:12 -07:00
Gregory Schier
030e8b837e Fix incorrect Postman AWS auth key mapping and update test fixtures 2025-10-29 07:08:02 -07:00
Gregory Schier
a42cba567c Support all possible Postman auth types 2025-10-29 07:06:10 -07:00
Gregory Schier
484b5b2fd8 Switch to vkbeautify for XML
https://feedback.yaak.app/p/xml-pretty-formatter-not-rendering-correctly
2025-10-28 14:03:49 -07:00
Gregory Schier
a71fb8ed6c Don't trigger hotkeys within sidebar edit input 2025-10-28 13:03:37 -07:00
Gregory Schier
5b8114f6f3 Add context menu support and Vim keybindings in Sidebar and Tree components 2025-10-28 08:45:36 -07:00
Gregory Schier
68637d24c7 Don't throw on empty variable values
https://feedback.yaak.app/p/variable-with-empty-value-in-request-will-cause-error
2025-10-28 07:20:41 -07:00
Gregory Schier
c097afe657 Skip disabled headers and URL parameters during rendering 2025-10-28 07:11:37 -07:00
Gregory Schier
78bc7d7909 Update label for "trialing" state to "Commercial Trial" in LicenseBadge 2025-10-28 07:11:17 -07:00
Gregory Schier
b68ce44d52 Colorize HTTP methods in dropdown
https://feedback.yaak.app/p/colorized-methods-on-dropdown-select
2025-10-28 07:11:03 -07:00
Gregory Schier
632344d166 Adjust LicenseBadge color for "trialing" state to secondary 2025-10-28 07:04:16 -07:00
Gregory Schier
f3814b7d2b Show cursor in response view 2025-10-28 07:03:19 -07:00
Gregory Schier
618a544dbd Adjust default font sizes for editor and interface settings 2025-10-28 07:03:06 -07:00
Gregory Schier
9a55426236 Fix incorrect Sidebar hidden state logic 2025-10-28 06:58:31 -07:00
Gregory Schier
b7ad490c9b Add setting to disable checking for notifications 2025-10-28 06:55:56 -07:00
Gregory Schier
2095cb88c2 Fix entering encryption key
https://feedback.yaak.app/p/encryption-feature-error
2025-10-28 06:55:03 -07:00
Gregory Schier
a9e05ae988 Copy on "type to confirm" dialog 2025-10-28 06:15:44 -07:00
Gregory Schier
99a6c38632 Sidebar filtering and improvements (#285) 2025-10-27 14:10:28 -07:00
Gregory Schier
b2766509e3 Hotkey for creating environment when dialog open 2025-10-26 12:10:41 -07:00
Gregory Schier
3f5b5a397c Better environment color picker (#282) 2025-10-26 12:05:03 -07:00
Gregory Schier
923b1ac830 Fix indent guide on drag and drop after expand folder
https://feedback.yaak.app/p/displace-moving-caret-on-spring-loaded-folder
2025-10-25 09:41:06 -07:00
Gregory Schier
17dbe7c9a7 API key auth to copy-as-grpcurl 2025-10-25 08:43:50 -07:00
Gregory Schier
df80cdfe33 Copy as curl AWS auth, and handle disabled auth 2025-10-25 08:33:27 -07:00
111 changed files with 4593 additions and 1814 deletions

View File

@@ -22,7 +22,7 @@
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https:&#x2F;&#x2F;github.com&#x2F;MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a>&nbsp;&nbsp;<a href="https://github.com/dharsanb"><img src="https:&#x2F;&#x2F;github.com&#x2F;dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a>&nbsp;&nbsp;<a href="https://github.com/railwayapp"><img src="https:&#x2F;&#x2F;github.com&#x2F;railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a>&nbsp;&nbsp;<a href="https://github.com/caseyamcl"><img src="https:&#x2F;&#x2F;github.com&#x2F;caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a>&nbsp;&nbsp;<a href="https://github.com/andriyor"><img src="https:&#x2F;&#x2F;github.com&#x2F;andriyor.png" width="80px" alt="User avatar: andriyor" /></a>&nbsp;&nbsp;<a href="https://github.com/"><img src="https:&#x2F;&#x2F;raw.githubusercontent.com&#x2F;JamesIves&#x2F;github-sponsors-readme-action&#x2F;dev&#x2F;.github&#x2F;assets&#x2F;placeholder.png" width="80px" alt="User avatar: " /></a>&nbsp;&nbsp;<!-- sponsors-premium -->
</p>
<p align="center">
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https:&#x2F;&#x2F;github.com&#x2F;seanwash.png" width="50px" alt="User avatar: seanwash" /></a>&nbsp;&nbsp;<a href="https://github.com/jerath"><img src="https:&#x2F;&#x2F;github.com&#x2F;jerath.png" width="50px" alt="User avatar: jerath" /></a>&nbsp;&nbsp;<a href="https://github.com/itsa-sh"><img src="https:&#x2F;&#x2F;github.com&#x2F;itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a>&nbsp;&nbsp;<a href="https://github.com/dmmulroy"><img src="https:&#x2F;&#x2F;github.com&#x2F;dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a>&nbsp;&nbsp;<a href="https://github.com/timcole"><img src="https:&#x2F;&#x2F;github.com&#x2F;timcole.png" width="50px" alt="User avatar: timcole" /></a>&nbsp;&nbsp;<a href="https://github.com/VLZH"><img src="https:&#x2F;&#x2F;github.com&#x2F;VLZH.png" width="50px" alt="User avatar: VLZH" /></a>&nbsp;&nbsp;<a href="https://github.com/terasaka2k"><img src="https:&#x2F;&#x2F;github.com&#x2F;terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a>&nbsp;&nbsp;<a href="https://github.com/majudhu"><img src="https:&#x2F;&#x2F;github.com&#x2F;majudhu.png" width="50px" alt="User avatar: majudhu" /></a>&nbsp;&nbsp;<a href="https://github.com/axelrindle"><img src="https:&#x2F;&#x2F;github.com&#x2F;axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a>&nbsp;&nbsp;<!-- sponsors-base -->
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https:&#x2F;&#x2F;github.com&#x2F;seanwash.png" width="50px" alt="User avatar: seanwash" /></a>&nbsp;&nbsp;<a href="https://github.com/jerath"><img src="https:&#x2F;&#x2F;github.com&#x2F;jerath.png" width="50px" alt="User avatar: jerath" /></a>&nbsp;&nbsp;<a href="https://github.com/itsa-sh"><img src="https:&#x2F;&#x2F;github.com&#x2F;itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a>&nbsp;&nbsp;<a href="https://github.com/dmmulroy"><img src="https:&#x2F;&#x2F;github.com&#x2F;dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a>&nbsp;&nbsp;<a href="https://github.com/timcole"><img src="https:&#x2F;&#x2F;github.com&#x2F;timcole.png" width="50px" alt="User avatar: timcole" /></a>&nbsp;&nbsp;<a href="https://github.com/VLZH"><img src="https:&#x2F;&#x2F;github.com&#x2F;VLZH.png" width="50px" alt="User avatar: VLZH" /></a>&nbsp;&nbsp;<a href="https://github.com/terasaka2k"><img src="https:&#x2F;&#x2F;github.com&#x2F;terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a>&nbsp;&nbsp;<a href="https://github.com/majudhu"><img src="https:&#x2F;&#x2F;github.com&#x2F;majudhu.png" width="50px" alt="User avatar: majudhu" /></a>&nbsp;&nbsp;<a href="https://github.com/axelrindle"><img src="https:&#x2F;&#x2F;github.com&#x2F;axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a>&nbsp;&nbsp;<a href="https://github.com/jirizverina"><img src="https:&#x2F;&#x2F;github.com&#x2F;jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a>&nbsp;&nbsp;<a href="https://github.com/chip-well"><img src="https:&#x2F;&#x2F;github.com&#x2F;chip-well.png" width="50px" alt="User avatar: chip-well" /></a>&nbsp;&nbsp;<!-- sponsors-base -->
</p>
![Yaak API Client](https://yaak.app/static/screenshot.png)

23
package-lock.json generated
View File

@@ -33,6 +33,7 @@
"plugins/template-function-hash",
"plugins/template-function-json",
"plugins/template-function-prompt",
"plugins/template-function-random",
"plugins/template-function-regex",
"plugins/template-function-request",
"plugins/template-function-response",
@@ -4222,6 +4223,10 @@
"resolved": "plugins/template-function-prompt",
"link": true
},
"node_modules/@yaak/template-function-random": {
"resolved": "plugins/template-function-random",
"link": true
},
"node_modules/@yaak/template-function-regex": {
"resolved": "plugins/template-function-regex",
"link": true
@@ -18131,6 +18136,12 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/vkbeautify": {
"version": "0.99.3",
"resolved": "https://registry.npmjs.org/vkbeautify/-/vkbeautify-0.99.3.tgz",
"integrity": "sha512-2ozZEFfmVvQcHWoHLNuiKlUfDKlhh4KGsy54U0UrlLMR1SO+XKAIDqBxtBwHgNrekurlJwE8A9K6L49T78ZQ9Q==",
"license": "MIT"
},
"node_modules/vscode-languageserver-types": {
"version": "3.17.5",
"resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
@@ -18525,12 +18536,6 @@
}
}
},
"node_modules/xml-beautify": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/xml-beautify/-/xml-beautify-1.2.3.tgz",
"integrity": "sha512-VsYpkqoVawIP84pi00XukPsgQHqOhgrpwTHlXqqRMAgYZ1u+Yw3KHIUhO1Igf19d5CQ5h6ExJT1hFCJRLmzADg==",
"license": "MIT"
},
"node_modules/xpath": {
"version": "0.0.34",
"resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.34.tgz",
@@ -18917,6 +18922,10 @@
"name": "@yaak/template-function-prompt",
"version": "0.1.0"
},
"plugins/template-function-random": {
"name": "@yaak/template-function-random",
"version": "0.1.0"
},
"plugins/template-function-regex": {
"name": "@yaak/template-function-regex",
"version": "0.1.0"
@@ -19088,8 +19097,8 @@
"remark-gfm": "^4.0.1",
"slugify": "^1.6.6",
"uuid": "^11.1.0",
"vkbeautify": "^0.99.3",
"whatwg-mimetype": "^4.0.0",
"xml-beautify": "^1.2.3",
"yaml": "^2.6.1"
},
"devDependencies": {

View File

@@ -32,6 +32,7 @@
"plugins/template-function-hash",
"plugins/template-function-json",
"plugins/template-function-prompt",
"plugins/template-function-random",
"plugins/template-function-regex",
"plugins/template-function-request",
"plugins/template-function-response",

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, parentModel: string, parentId: string | null, variables: Array<EnvironmentVariable>, color: 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, sortPriority: number, };
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };

View File

@@ -241,12 +241,10 @@ export class PluginInstance {
}
if (payload.type === 'get_http_authentication_summary_request' && this.#mod?.authentication) {
const { name, shortLabel, label } = this.#mod.authentication;
const replyPayload: InternalEventPayload = {
type: 'get_http_authentication_summary_response',
name,
label,
shortLabel,
...this.#mod.authentication,
};
this.#sendPayload(windowContext, replyPayload, replyId);

View File

@@ -82,21 +82,49 @@ export async function convertToCurl(request: Partial<HttpRequest>) {
}
// Add basic/digest authentication
if (request.authenticationType === 'basic' || request.authenticationType === 'digest') {
if (request.authenticationType === 'digest') xs.push('--digest');
xs.push(
'--user',
quote(`${request.authentication?.username ?? ''}:${request.authentication?.password ?? ''}`),
);
xs.push(NEWLINE);
}
if (request.authentication?.disabled !== true) {
if (request.authenticationType === 'basic' || request.authenticationType === 'digest') {
if (request.authenticationType === 'digest') xs.push('--digest');
xs.push(
'--user',
quote(
`${request.authentication?.username ?? ''}:${request.authentication?.password ?? ''}`,
),
);
xs.push(NEWLINE);
}
// Add bearer authentication
if (request.authenticationType === 'bearer') {
const value =
`${request.authentication?.prefix ?? 'Bearer'} ${request.authentication?.token ?? ''}`.trim();
xs.push('--header', quote(`Authorization: ${value}`));
xs.push(NEWLINE);
// Add bearer authentication
if (request.authenticationType === 'bearer') {
const value =
`${request.authentication?.prefix ?? 'Bearer'} ${request.authentication?.token ?? ''}`.trim();
xs.push('--header', quote(`Authorization: ${value}`));
xs.push(NEWLINE);
}
if (request.authenticationType === 'auth-aws-sig-v4') {
xs.push(
'--aws-sigv4',
[
'aws',
'amz',
request.authentication?.region ?? '',
request.authentication?.service ?? '',
].join(':'),
);
xs.push(NEWLINE);
xs.push(
'--user',
quote(
`${request.authentication?.accessKeyId ?? ''}:${request.authentication?.secretAccessKey ?? ''}`,
),
);
if (request.authentication?.sessionToken) {
xs.push(NEWLINE);
xs.push('--header', quote(`X-Amz-Security-Token: ${request.authentication.sessionToken}`));
}
xs.push(NEWLINE);
}
}
// Remove trailing newline

View File

@@ -170,6 +170,20 @@ describe('exporter-curl', () => {
).toEqual([`curl 'https://yaak.app'`, `--user 'user:pass'`].join(` \\\n `));
});
test('Basic auth disabled', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
authenticationType: 'basic',
authentication: {
disabled: true,
username: 'user',
password: 'pass',
},
}),
).toEqual([`curl 'https://yaak.app'`].join(` \\\n `));
});
test('Broken basic auth', async () => {
expect(
await convertToCurl({
@@ -246,6 +260,51 @@ describe('exporter-curl', () => {
).toEqual([`curl 'https://yaak.app'`, `--header 'Authorization: Bearer'`].join(` \\\n `));
});
test('AWS v4 auth', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
authenticationType: 'auth-aws-sig-v4',
authentication: {
accessKeyId: 'ak',
secretAccessKey: 'sk',
sessionToken: '',
region: 'us-east-1',
service: 's3',
},
}),
).toEqual(
[
`curl 'https://yaak.app'`,
`--aws-sigv4 aws:amz:us-east-1:s3`,
`--user 'ak:sk'`,
].join(` \\\n `),
);
});
test('AWS v4 auth with session', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
authenticationType: 'auth-aws-sig-v4',
authentication: {
accessKeyId: 'ak',
secretAccessKey: 'sk',
sessionToken: 'st',
region: 'us-east-1',
service: 's3',
},
}),
).toEqual(
[
`curl 'https://yaak.app'`,
`--aws-sigv4 aws:amz:us-east-1:s3`,
`--user 'ak:sk'`,
`--header 'X-Amz-Security-Token: st'`,
].join(` \\\n `),
);
});
test('Stale body data', async () => {
expect(
await convertToCurl({

View File

@@ -68,16 +68,37 @@ export async function convert(request: Partial<GrpcRequest>, allProtoFiles: stri
}
// Add basic authentication
if (request.authenticationType === 'basic') {
const user = request.authentication?.username ?? '';
const pass = request.authentication?.password ?? '';
const encoded = btoa(`${user}:${pass}`);
xs.push('-H', quote(`Authorization: Basic ${encoded}`));
xs.push(NEWLINE);
} else if (request.authenticationType === 'bearer') {
// Add bearer authentication
xs.push('-H', quote(`Authorization: Bearer ${request.authentication?.token ?? ''}`));
xs.push(NEWLINE);
if (request.authentication?.disabled !== true) {
if (request.authenticationType === 'basic') {
const user = request.authentication?.username ?? '';
const pass = request.authentication?.password ?? '';
const encoded = btoa(`${user}:${pass}`);
xs.push('-H', quote(`Authorization: Basic ${encoded}`));
xs.push(NEWLINE);
} else if (request.authenticationType === 'bearer') {
// Add bearer authentication
xs.push('-H', quote(`Authorization: Bearer ${request.authentication?.token ?? ''}`));
xs.push(NEWLINE);
} else if (request.authenticationType === 'apikey') {
if (request.authentication?.location === 'query') {
const sep = request.url?.includes('?') ? '&' : '?';
request.url = [
request.url,
sep,
encodeURIComponent(request.authentication?.key ?? 'token'),
'=',
encodeURIComponent(request.authentication?.value ?? ''),
].join('');
} else {
xs.push(
'-H',
quote(
`${request.authentication?.key ?? 'X-Api-Key'}: ${request.authentication?.value ?? ''}`,
),
);
}
xs.push(NEWLINE);
}
}
// Add form params

View File

@@ -27,6 +27,55 @@ describe('exporter-curl', () => {
),
).toEqual([`grpcurl -H 'aaa: AAA'`, `-H 'bbb: BBB'`, `yaak.app`].join(` \\\n `));
});
test('Basic auth', async () => {
expect(
await convert(
{
url: 'https://yaak.app',
authenticationType: 'basic',
authentication: {
username: 'user',
password: 'pass',
},
},
[],
),
).toEqual([`grpcurl -H 'Authorization: Basic dXNlcjpwYXNz'`, `yaak.app`].join(` \\\n `));
});
test('API key auth', async () => {
expect(
await convert(
{
url: 'https://yaak.app',
authenticationType: 'apikey',
authentication: {
key: 'X-Token',
value: 'tok',
},
},
[],
),
).toEqual([`grpcurl -H 'X-Token: tok'`, `yaak.app`].join(` \\\n `));
});
test('API key auth', async () => {
expect(
await convert(
{
url: 'https://yaak.app',
authenticationType: 'apikey',
authentication: {
location: 'query',
key: 'token',
value: 'tok 1',
},
},
[],
),
).toEqual([`grpcurl`, `yaak.app?token=tok%201`].join(` \\\n `));
});
test('Single proto file', async () => {
expect(await convert({ url: 'https://yaak.app' }, ['/foo/bar/baz.proto'])).toEqual(
[

View File

@@ -6,7 +6,7 @@ import { URL } from 'node:url';
export const plugin: PluginDefinition = {
authentication: {
name: 'auth-aws-sig-v4',
name: 'awsv4',
label: 'AWS Signature',
shortLabel: 'AWS v4',
args: [

View File

@@ -46,6 +46,50 @@ export const plugin: PluginDefinition = {
name: 'secretBase64',
label: 'Secret is base64 encoded',
},
{
type: 'select',
name: 'location',
label: 'Behavior',
defaultValue: 'header',
options: [
{ label: 'Insert Header', value: 'header' },
{ label: 'Append Query Parameter', value: 'query' },
],
},
{
type: 'text',
name: 'name',
label: 'Header Name',
defaultValue: 'Authorization',
optional: true,
dynamic(_ctx, args) {
if (args.values.location === 'query') {
return {
label: 'Parameter Name',
description: 'The name of the query parameter to add to the request',
};
} else {
return {
label: 'Header Name',
description: 'The name of the header to add to the request',
};
}
},
},
{
type: 'text',
name: 'headerPrefix',
label: 'Header Prefix',
optional: true,
defaultValue: 'Bearer',
dynamic(_ctx, args) {
if (args.values.location === 'query') {
return {
hidden: true,
};
}
},
},
{
type: 'editor',
name: 'payload',
@@ -61,8 +105,17 @@ export const plugin: PluginDefinition = {
const token = jwt.sign(`${payload}`, secret, {
algorithm: algorithm as (typeof algorithms)[number],
});
const value = `Bearer ${token}`;
return { setHeaders: [{ name: 'Authorization', value }] };
if (values.location === 'query') {
const paramName = String(values.name || 'token');
const paramValue = String(values.value || '');
return { setQueryParameters: [{ name: paramName, value: paramValue }] };
} else {
const headerPrefix = values.headerPrefix != null ? values.headerPrefix : 'Bearer';
const headerName = String(values.name || 'Authorization');
const headerValue = `${headerPrefix} ${token}`.trim();
return { setHeaders: [{ name: headerName, value: headerValue }] };
}
},
},
};

View File

@@ -190,9 +190,7 @@ function importEnvironment(
createdAt: e.created ? new Date(e.created).toISOString().replace('Z', '') : undefined,
updatedAt: e.modified ? new Date(e.modified).toISOString().replace('Z', '') : undefined,
workspaceId: convertId(workspaceId),
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
sortPriority: e.metaSortKey, // Will be added to Yaak later
sortPriority: e.metaSortKey,
parentModel: isParent ? 'workspace' : 'environment',
parentId: null,
model: 'environment',

View File

@@ -295,9 +295,7 @@ function importEnvironment(
updatedAt: updated ? new Date(updated).toISOString().replace('Z', '') : undefined,
workspaceId: convertId(workspaceId),
public: !e.isPrivate,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
sortPriority: sortKey, // Will be added to Yaak later
sortPriority: sortKey,
parentModel: isParent ? 'workspace' : 'environment',
parentId: null,
model: 'environment',

View File

@@ -68,6 +68,7 @@ export function convertPostman(contents: string): ImportPluginResponse | undefin
id: generateId('workspace'),
name: info.name ? String(info.name) : 'Postman Import',
description,
...globalAuth,
};
exportResources.workspaces.push(workspace);
@@ -105,8 +106,7 @@ export function convertPostman(contents: string): ImportPluginResponse | undefin
} else if (typeof v.name === 'string' && 'request' in v) {
const r = toRecord(v.request);
const bodyPatch = importBody(r.body);
const requestAuthPath = importAuth(r.auth);
const authPatch = requestAuthPath.authenticationType == null ? globalAuth : requestAuthPath;
const requestAuth = importAuth(r.auth);
const headers: HttpRequestHeader[] = toArray<{
key: string;
@@ -145,10 +145,9 @@ export function convertPostman(contents: string): ImportPluginResponse | undefin
urlParameters,
body: bodyPatch.body,
bodyType: bodyPatch.bodyType,
authentication: authPatch.authentication,
authenticationType: authPatch.authenticationType,
sortPriority: sortPriorityIndex++,
headers,
...requestAuth,
};
exportResources.httpRequests.push(request);
} else {
@@ -223,25 +222,159 @@ function convertUrl(rawUrl: string | unknown): Pick<HttpRequest, 'url' | 'urlPar
}
function importAuth(rawAuth: unknown): Pick<HttpRequest, 'authentication' | 'authenticationType'> {
const auth = toRecord<{ username?: string; password?: string; token?: string }>(rawAuth);
if ('basic' in auth) {
const auth = toRecord<Record<string, string>>(rawAuth);
// Helper: Postman stores auth params as an array of { key, value, ... }
const pmArrayToObj = (v: unknown): Record<string, unknown> => {
if (!Array.isArray(v)) return toRecord(v);
const o: Record<string, unknown> = {};
for (const i of v) {
const ii = toRecord(i);
if (typeof ii.key === 'string') {
o[ii.key] = ii.value;
}
}
return o;
};
const authType: string | undefined = auth.type ? String(auth.type) : undefined;
if (authType === 'noauth') {
return {
authenticationType: 'none',
authentication: {},
};
}
if ('basic' in auth && authType === 'basic') {
const b = pmArrayToObj(auth.basic);
return {
authenticationType: 'basic',
authentication: {
username: auth.basic.username || '',
password: auth.basic.password || '',
username: String(b.username ?? ''),
password: String(b.password ?? ''),
},
};
} else if ('bearer' in auth) {
}
if ('bearer' in auth && authType === 'bearer') {
const b = pmArrayToObj(auth.bearer);
// Postman uses key "token"
return {
authenticationType: 'bearer',
authentication: {
token: auth.bearer.token || '',
token: String(b.token ?? ''),
},
};
} else {
return { authenticationType: null, authentication: {} };
}
if ('awsv4' in auth && authType === 'awsv4') {
const a = pmArrayToObj(auth.awsv4);
return {
authenticationType: 'awsv4',
authentication: {
accessKeyId: a.accessKey != null ? String(a.accessKey) : undefined,
secretAccessKey: a.secretKey != null ? String(a.secretKey) : undefined,
sessionToken: a.sessionToken != null ? String(a.sessionToken) : undefined,
region: a.region != null ? String(a.region) : undefined,
service: a.service != null ? String(a.service) : undefined,
},
};
}
if ('apikey' in auth && authType === 'apikey') {
const a = pmArrayToObj(auth.apikey);
return {
authenticationType: 'apikey',
authentication: {
location: a.in === 'query' ? 'query' : 'header',
key: a.value != null ? String(a.value) : undefined,
value: a.key != null ? String(a.key) : undefined,
},
};
}
if ('jwt' in auth && authType === 'jwt') {
const a = pmArrayToObj(auth.jwt);
return {
authenticationType: 'jwt',
authentication: {
algorithm: a.algorithm != null ? String(a.algorithm).toUpperCase() : undefined,
secret: a.secret != null ? String(a.secret) : undefined,
secretBase64: !!a.isSecretBase64Encoded,
payload: a.payload != null ? String(a.payload) : undefined,
headerPrefix: a.headerPrefix != null ? String(a.headerPrefix) : undefined,
location: a.addTokenTo === 'header' ? 'header' : 'query',
},
};
}
if ('oauth2' in auth && authType === 'oauth2') {
const o = pmArrayToObj(auth.oauth2);
let grantType = o.grant_type ? String(o.grant_type) : 'authorization_code';
let pkcePatch: Record<string, unknown> = {};
if (grantType === 'authorization_code_with_pkce') {
grantType = 'authorization_code';
pkcePatch =
o.grant_type === 'authorization_code_with_pkce'
? {
usePkce: true,
pkceChallengeMethod: o.challengeAlgorithm ?? undefined,
pkceCodeVerifier: o.code_verifier != null ? String(o.code_verifier) : undefined,
}
: {};
} else if (grantType === 'password_credentials') {
grantType = 'password';
}
const accessTokenUrl = o.accessTokenUrl != null ? String(o.accessTokenUrl) : undefined;
const audience = o.audience != null ? String(o.audience) : undefined;
const authorizationUrl = o.authUrl != null ? String(o.authUrl) : undefined;
const clientId = o.clientId != null ? String(o.clientId) : undefined;
const clientSecret = o.clientSecret != null ? String(o.clientSecret) : undefined;
const credentials = o.client_authentication === 'body' ? 'body' : undefined;
const headerPrefix = o.headerPrefix ?? 'Bearer';
const password = o.password != null ? String(o.password) : undefined;
const redirectUri = o.redirect_uri != null ? String(o.redirect_uri) : undefined;
const scope = o.scope != null ? String(o.scope) : undefined;
const state = o.state != null ? String(o.state) : undefined;
const username = o.username != null ? String(o.username) : undefined;
let grantPatch: Record<string, unknown> = {};
if (grantType === 'authorization_code') {
grantPatch = {
clientSecret,
authorizationUrl,
accessTokenUrl,
redirectUri,
state,
...pkcePatch,
};
} else if (grantType === 'implicit') {
grantPatch = { authorizationUrl, redirectUri, state };
} else if (grantType === 'password') {
grantPatch = { clientSecret, accessTokenUrl, username, password };
} else if (grantType === 'client_credentials') {
grantPatch = { clientSecret, accessTokenUrl };
}
const authentication = {
name: 'oauth2',
grantType,
audience,
clientId,
credentials,
headerPrefix,
scope,
...grantPatch,
} as Record<string, unknown>;
return { authenticationType: 'oauth2', authentication };
}
return { authenticationType: null, authentication: {} };
}
function importBody(rawBody: unknown): Pick<HttpRequest, 'body' | 'bodyType' | 'headers'> {
@@ -376,7 +509,10 @@ function toArray<T>(value: unknown): T[] {
/** Recursively render all nested object properties */
function convertTemplateSyntax<T>(obj: T): T {
if (typeof obj === 'string') {
return obj.replace(/{{\s*(_\.)?([^}]*)\s*}}/g, (_m, _dot, expr) => '${[' + expr.trim() + ']}') as T;
return obj.replace(
/{{\s*(_\.)?([^}]*)\s*}}/g,
(_m, _dot, expr) => '${[' + expr.trim().replace(/^vault:/, '') + ']}',
) as T;
} else if (Array.isArray(obj) && obj != null) {
return obj.map(convertTemplateSyntax) as T;
} else if (typeof obj === 'object' && obj != null) {

View File

@@ -0,0 +1,828 @@
{
"info": {
"_postman_id": "9e6dfada-256c-49ea-a38f-7d1b05b7ca2d",
"name": "Authentication",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
"_exporter_id": "18798"
},
"item": [
{
"name": "No Auth",
"request": {
"auth": {
"type": "noauth"
},
"method": "GET",
"header": [],
"url": {
"raw": "https://yaak.app/x/echo",
"protocol": "https",
"host": [
"yaak",
"app"
],
"path": [
"x",
"echo"
]
}
},
"response": []
},
{
"name": "Inherit",
"request": {
"method": "GET",
"header": [],
"url": {
"raw": "https://yaak.app/x/echo",
"protocol": "https",
"host": [
"yaak",
"app"
],
"path": [
"x",
"echo"
]
}
},
"response": []
},
{
"name": "OAuth 2 Auth Code",
"protocolProfileBehavior": {
"disableBodyPruning": true
},
"request": {
"auth": {
"type": "oauth2",
"oauth2": [
{
"key": "grant_type",
"value": "authorization_code",
"type": "string"
},
{
"key": "headerPrefix",
"value": "Bearer",
"type": "string"
},
{
"key": "client_authentication",
"value": "header",
"type": "string"
},
{
"key": "challengeAlgorithm",
"value": "S256",
"type": "string"
},
{
"key": "refreshTokenUrl",
"value": "https://github.com/login/oauth/access_token",
"type": "string"
},
{
"key": "state",
"value": "state",
"type": "string"
},
{
"key": "scope",
"value": "scope",
"type": "string"
},
{
"key": "code_verifier",
"value": "verifier",
"type": "string"
},
{
"key": "clientSecret",
"value": "clientsecet",
"type": "string"
},
{
"key": "clientId",
"value": "cliend id",
"type": "string"
},
{
"key": "authUrl",
"value": "https://github.com/login/oauth/authorize",
"type": "string"
},
{
"key": "accessTokenUrl",
"value": "https://github.com/login/oauth/access_token",
"type": "string"
},
{
"key": "useBrowser",
"value": true,
"type": "boolean"
},
{
"key": "redirect_uri",
"value": "https://callback",
"type": "string"
},
{
"key": "tokenName",
"value": "name",
"type": "string"
},
{
"key": "addTokenTo",
"value": "header",
"type": "string"
}
]
},
"method": "GET",
"header": [],
"body": {
"mode": "raw",
"raw": "{\n \"hello\": \"world\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{vault:hello}}",
"host": [
"{{vault:hello}}"
]
}
},
"response": []
},
{
"name": "OAuth 2 Auth Code (PKCE)",
"protocolProfileBehavior": {
"disableBodyPruning": true
},
"request": {
"auth": {
"type": "oauth2",
"oauth2": [
{
"key": "headerPrefix",
"value": "Bearer",
"type": "string"
},
{
"key": "client_authentication",
"value": "header",
"type": "string"
},
{
"key": "challengeAlgorithm",
"value": "S256",
"type": "string"
},
{
"key": "refreshTokenUrl",
"value": "https://github.com/login/oauth/access_token",
"type": "string"
},
{
"key": "state",
"value": "state",
"type": "string"
},
{
"key": "scope",
"value": "scope",
"type": "string"
},
{
"key": "code_verifier",
"value": "verifier",
"type": "string"
},
{
"key": "grant_type",
"value": "authorization_code_with_pkce",
"type": "string"
},
{
"key": "clientSecret",
"value": "clientsecet",
"type": "string"
},
{
"key": "clientId",
"value": "cliend id",
"type": "string"
},
{
"key": "authUrl",
"value": "https://github.com/login/oauth/authorize",
"type": "string"
},
{
"key": "accessTokenUrl",
"value": "https://github.com/login/oauth/access_token",
"type": "string"
},
{
"key": "useBrowser",
"value": true,
"type": "boolean"
},
{
"key": "redirect_uri",
"value": "https://callback",
"type": "string"
},
{
"key": "tokenName",
"value": "name",
"type": "string"
},
{
"key": "addTokenTo",
"value": "header",
"type": "string"
}
]
},
"method": "GET",
"header": [],
"body": {
"mode": "raw",
"raw": "{\n \"hello\": \"world\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{vault:hello}}",
"host": [
"{{vault:hello}}"
]
}
},
"response": []
},
{
"name": "OAuth 2 Implicit",
"protocolProfileBehavior": {
"disableBodyPruning": true
},
"request": {
"auth": {
"type": "oauth2",
"oauth2": [
{
"key": "client_authentication",
"value": "header",
"type": "string"
},
{
"key": "redirect_uri",
"value": "https://yaak.app/x/echo",
"type": "string"
},
{
"key": "useBrowser",
"value": false,
"type": "boolean"
},
{
"key": "grant_type",
"value": "implicit",
"type": "string"
},
{
"key": "headerPrefix",
"value": "Bearer",
"type": "string"
},
{
"key": "challengeAlgorithm",
"value": "S256",
"type": "string"
},
{
"key": "refreshTokenUrl",
"value": "https://github.com/login/oauth/access_token",
"type": "string"
},
{
"key": "state",
"value": "state",
"type": "string"
},
{
"key": "scope",
"value": "scope",
"type": "string"
},
{
"key": "code_verifier",
"value": "verifier",
"type": "string"
},
{
"key": "clientSecret",
"value": "clientsecet",
"type": "string"
},
{
"key": "clientId",
"value": "cliend id",
"type": "string"
},
{
"key": "authUrl",
"value": "https://github.com/login/oauth/authorize",
"type": "string"
},
{
"key": "accessTokenUrl",
"value": "https://github.com/login/oauth/access_token",
"type": "string"
},
{
"key": "tokenName",
"value": "name",
"type": "string"
},
{
"key": "addTokenTo",
"value": "header",
"type": "string"
}
]
},
"method": "GET",
"header": [],
"body": {
"mode": "raw",
"raw": "{\n \"hello\": \"world\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{vault:hello}}",
"host": [
"{{vault:hello}}"
]
}
},
"response": []
},
{
"name": "OAuth 2 Password",
"protocolProfileBehavior": {
"disableBodyPruning": true
},
"request": {
"auth": {
"type": "oauth2",
"oauth2": [
{
"key": "password",
"value": "password",
"type": "string"
},
{
"key": "username",
"value": "username",
"type": "string"
},
{
"key": "clientSecret",
"value": "clientsecret",
"type": "string"
},
{
"key": "clientId",
"value": "clientid",
"type": "string"
},
{
"key": "grant_type",
"value": "password_credentials",
"type": "string"
},
{
"key": "client_authentication",
"value": "header",
"type": "string"
},
{
"key": "redirect_uri",
"value": "https://yaak.app/x/echo",
"type": "string"
},
{
"key": "useBrowser",
"value": false,
"type": "boolean"
},
{
"key": "headerPrefix",
"value": "Bearer",
"type": "string"
},
{
"key": "challengeAlgorithm",
"value": "S256",
"type": "string"
},
{
"key": "refreshTokenUrl",
"value": "https://github.com/login/oauth/access_token",
"type": "string"
},
{
"key": "state",
"value": "state",
"type": "string"
},
{
"key": "scope",
"value": "scope",
"type": "string"
},
{
"key": "code_verifier",
"value": "verifier",
"type": "string"
},
{
"key": "authUrl",
"value": "https://github.com/login/oauth/authorize",
"type": "string"
},
{
"key": "accessTokenUrl",
"value": "https://github.com/login/oauth/access_token",
"type": "string"
},
{
"key": "tokenName",
"value": "name",
"type": "string"
},
{
"key": "addTokenTo",
"value": "header",
"type": "string"
}
]
},
"method": "GET",
"header": [],
"body": {
"mode": "raw",
"raw": "{\n \"hello\": \"world\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{vault:hello}}",
"host": [
"{{vault:hello}}"
]
}
},
"response": []
},
{
"name": "OAuth 2 Client Credentials",
"protocolProfileBehavior": {
"disableBodyPruning": true
},
"request": {
"auth": {
"type": "oauth2",
"oauth2": [
{
"key": "grant_type",
"value": "client_credentials",
"type": "string"
},
{
"key": "password",
"value": "password",
"type": "string"
},
{
"key": "username",
"value": "username",
"type": "string"
},
{
"key": "clientSecret",
"value": "clientsecret",
"type": "string"
},
{
"key": "clientId",
"value": "clientid",
"type": "string"
},
{
"key": "client_authentication",
"value": "header",
"type": "string"
},
{
"key": "redirect_uri",
"value": "https://yaak.app/x/echo",
"type": "string"
},
{
"key": "useBrowser",
"value": false,
"type": "boolean"
},
{
"key": "headerPrefix",
"value": "Bearer",
"type": "string"
},
{
"key": "challengeAlgorithm",
"value": "S256",
"type": "string"
},
{
"key": "refreshTokenUrl",
"value": "https://github.com/login/oauth/access_token",
"type": "string"
},
{
"key": "state",
"value": "state",
"type": "string"
},
{
"key": "scope",
"value": "scope",
"type": "string"
},
{
"key": "code_verifier",
"value": "verifier",
"type": "string"
},
{
"key": "authUrl",
"value": "https://github.com/login/oauth/authorize",
"type": "string"
},
{
"key": "accessTokenUrl",
"value": "https://github.com/login/oauth/access_token",
"type": "string"
},
{
"key": "tokenName",
"value": "name",
"type": "string"
},
{
"key": "addTokenTo",
"value": "header",
"type": "string"
}
]
},
"method": "GET",
"header": [],
"body": {
"mode": "raw",
"raw": "{\n \"hello\": \"world\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{vault:hello}}",
"host": [
"{{vault:hello}}"
]
}
},
"response": []
},
{
"name": "AWS V4",
"request": {
"auth": {
"type": "awsv4",
"awsv4": [
{
"key": "sessionToken",
"value": "session",
"type": "string"
},
{
"key": "service",
"value": "s3",
"type": "string"
},
{
"key": "region",
"value": "us-west-1",
"type": "string"
},
{
"key": "secretKey",
"value": "secret",
"type": "string"
},
{
"key": "accessKey",
"value": "access",
"type": "string"
}
]
},
"method": "GET",
"header": [],
"url": {
"raw": "https://yaak.app/x/echo",
"protocol": "https",
"host": [
"yaak",
"app"
],
"path": [
"x",
"echo"
]
}
},
"response": []
},
{
"name": "API Key",
"request": {
"auth": {
"type": "apikey",
"apikey": [
{
"key": "in",
"value": "query",
"type": "string"
},
{
"key": "value",
"value": "value",
"type": "string"
},
{
"key": "key",
"value": "key",
"type": "string"
}
]
},
"method": "GET",
"header": [],
"url": {
"raw": "https://yaak.app/x/echo",
"protocol": "https",
"host": [
"yaak",
"app"
],
"path": [
"x",
"echo"
]
}
},
"response": []
},
{
"name": "JWT",
"request": {
"auth": {
"type": "jwt",
"jwt": [
{
"key": "header",
"value": "{\n \"header\": \"foo\"\n}",
"type": "string"
},
{
"key": "headerPrefix",
"value": "Bearer",
"type": "string"
},
{
"key": "payload",
"value": "{\n \"my\": \"payload\"\n}",
"type": "string"
},
{
"key": "isSecretBase64Encoded",
"value": false,
"type": "boolean"
},
{
"key": "secret",
"value": "mysecret",
"type": "string"
},
{
"key": "algorithm",
"value": "HS384",
"type": "string"
},
{
"key": "addTokenTo",
"value": "header",
"type": "string"
},
{
"key": "queryParamKey",
"value": "token",
"type": "string"
}
]
},
"method": "GET",
"header": [],
"url": {
"raw": "https://yaak.app/x/echo",
"protocol": "https",
"host": [
"yaak",
"app"
],
"path": [
"x",
"echo"
]
}
},
"response": []
}
],
"auth": {
"type": "basic",
"basic": [
{
"key": "password",
"value": "workspace_secret",
"type": "string"
},
{
"key": "username",
"value": "workspace",
"type": "string"
}
]
},
"event": [
{
"listen": "prerequest",
"script": {
"type": "text/javascript",
"packages": {},
"requests": {},
"exec": [
""
]
}
},
{
"listen": "test",
"script": {
"type": "text/javascript",
"packages": {},
"requests": {},
"exec": [
""
]
}
}
],
"variable": [
{
"key": "COLLECTION VARIABLE",
"value": "collection variable"
}
]
}

View File

@@ -0,0 +1,304 @@
{
"resources": {
"workspaces": [
{
"model": "workspace",
"id": "GENERATE_ID::WORKSPACE_0",
"name": "Authentication",
"authenticationType": "basic",
"authentication": {
"username": "workspace",
"password": "workspace_secret"
}
}
],
"environments": [
{
"model": "environment",
"id": "GENERATE_ID::ENVIRONMENT_0",
"name": "Global Variables",
"workspaceId": "GENERATE_ID::WORKSPACE_0",
"parentModel": "workspace",
"parentId": null,
"variables": [
{
"name": "COLLECTION VARIABLE",
"value": "collection variable"
}
]
}
],
"httpRequests": [
{
"model": "http_request",
"id": "GENERATE_ID::HTTP_REQUEST_0",
"workspaceId": "GENERATE_ID::WORKSPACE_0",
"folderId": null,
"name": "No Auth",
"method": "GET",
"url": "https://yaak.app/x/echo",
"urlParameters": [],
"body": {},
"bodyType": null,
"sortPriority": 0,
"headers": [],
"authenticationType": "none",
"authentication": {}
},
{
"model": "http_request",
"id": "GENERATE_ID::HTTP_REQUEST_1",
"workspaceId": "GENERATE_ID::WORKSPACE_0",
"folderId": null,
"name": "Inherit",
"method": "GET",
"url": "https://yaak.app/x/echo",
"urlParameters": [],
"body": {},
"bodyType": null,
"sortPriority": 1,
"headers": [],
"authenticationType": null,
"authentication": {}
},
{
"model": "http_request",
"id": "GENERATE_ID::HTTP_REQUEST_2",
"workspaceId": "GENERATE_ID::WORKSPACE_0",
"folderId": null,
"name": "OAuth 2 Auth Code",
"method": "GET",
"url": "${[hello]}",
"urlParameters": [],
"body": {
"text": "{\n \"hello\": \"world\"\n}"
},
"bodyType": "application/json",
"sortPriority": 2,
"headers": [
{
"name": "Content-Type",
"value": "application/json",
"enabled": true
}
],
"authenticationType": "oauth2",
"authentication": {
"name": "oauth2",
"grantType": "authorization_code",
"clientId": "cliend id",
"headerPrefix": "Bearer",
"scope": "scope",
"clientSecret": "clientsecet",
"authorizationUrl": "https://github.com/login/oauth/authorize",
"accessTokenUrl": "https://github.com/login/oauth/access_token",
"redirectUri": "https://callback",
"state": "state"
}
},
{
"model": "http_request",
"id": "GENERATE_ID::HTTP_REQUEST_3",
"workspaceId": "GENERATE_ID::WORKSPACE_0",
"folderId": null,
"name": "OAuth 2 Auth Code (PKCE)",
"method": "GET",
"url": "${[hello]}",
"urlParameters": [],
"body": {
"text": "{\n \"hello\": \"world\"\n}"
},
"bodyType": "application/json",
"sortPriority": 3,
"headers": [
{
"name": "Content-Type",
"value": "application/json",
"enabled": true
}
],
"authenticationType": "oauth2",
"authentication": {
"name": "oauth2",
"grantType": "authorization_code",
"clientId": "cliend id",
"headerPrefix": "Bearer",
"scope": "scope",
"clientSecret": "clientsecet",
"authorizationUrl": "https://github.com/login/oauth/authorize",
"accessTokenUrl": "https://github.com/login/oauth/access_token",
"redirectUri": "https://callback",
"state": "state",
"usePkce": true,
"pkceChallengeMethod": "S256",
"pkceCodeVerifier": "verifier"
}
},
{
"model": "http_request",
"id": "GENERATE_ID::HTTP_REQUEST_4",
"workspaceId": "GENERATE_ID::WORKSPACE_0",
"folderId": null,
"name": "OAuth 2 Implicit",
"method": "GET",
"url": "${[hello]}",
"urlParameters": [],
"body": {
"text": "{\n \"hello\": \"world\"\n}"
},
"bodyType": "application/json",
"sortPriority": 4,
"headers": [
{
"name": "Content-Type",
"value": "application/json",
"enabled": true
}
],
"authenticationType": "oauth2",
"authentication": {
"name": "oauth2",
"grantType": "implicit",
"clientId": "cliend id",
"headerPrefix": "Bearer",
"scope": "scope",
"authorizationUrl": "https://github.com/login/oauth/authorize",
"redirectUri": "https://yaak.app/x/echo",
"state": "state"
}
},
{
"model": "http_request",
"id": "GENERATE_ID::HTTP_REQUEST_5",
"workspaceId": "GENERATE_ID::WORKSPACE_0",
"folderId": null,
"name": "OAuth 2 Password",
"method": "GET",
"url": "${[hello]}",
"urlParameters": [],
"body": {
"text": "{\n \"hello\": \"world\"\n}"
},
"bodyType": "application/json",
"sortPriority": 5,
"headers": [
{
"name": "Content-Type",
"value": "application/json",
"enabled": true
}
],
"authenticationType": "oauth2",
"authentication": {
"name": "oauth2",
"grantType": "password",
"clientId": "clientid",
"headerPrefix": "Bearer",
"scope": "scope",
"clientSecret": "clientsecret",
"accessTokenUrl": "https://github.com/login/oauth/access_token",
"username": "username",
"password": "password"
}
},
{
"model": "http_request",
"id": "GENERATE_ID::HTTP_REQUEST_6",
"workspaceId": "GENERATE_ID::WORKSPACE_0",
"folderId": null,
"name": "OAuth 2 Client Credentials",
"method": "GET",
"url": "${[hello]}",
"urlParameters": [],
"body": {
"text": "{\n \"hello\": \"world\"\n}"
},
"bodyType": "application/json",
"sortPriority": 6,
"headers": [
{
"name": "Content-Type",
"value": "application/json",
"enabled": true
}
],
"authenticationType": "oauth2",
"authentication": {
"name": "oauth2",
"grantType": "client_credentials",
"clientId": "clientid",
"headerPrefix": "Bearer",
"scope": "scope",
"clientSecret": "clientsecret",
"accessTokenUrl": "https://github.com/login/oauth/access_token"
}
},
{
"model": "http_request",
"id": "GENERATE_ID::HTTP_REQUEST_7",
"workspaceId": "GENERATE_ID::WORKSPACE_0",
"folderId": null,
"name": "AWS V4",
"method": "GET",
"url": "https://yaak.app/x/echo",
"urlParameters": [],
"body": {},
"bodyType": null,
"sortPriority": 7,
"headers": [],
"authenticationType": "awsv4",
"authentication": {
"accessKeyId": "access",
"secretAccessKey": "secret",
"sessionToken": "session",
"region": "us-west-1",
"service": "s3"
}
},
{
"model": "http_request",
"id": "GENERATE_ID::HTTP_REQUEST_8",
"workspaceId": "GENERATE_ID::WORKSPACE_0",
"folderId": null,
"name": "API Key",
"method": "GET",
"url": "https://yaak.app/x/echo",
"urlParameters": [],
"body": {},
"bodyType": null,
"sortPriority": 8,
"headers": [],
"authenticationType": "apikey",
"authentication": {
"location": "query",
"key": "value",
"value": "key"
}
},
{
"model": "http_request",
"id": "GENERATE_ID::HTTP_REQUEST_9",
"workspaceId": "GENERATE_ID::WORKSPACE_0",
"folderId": null,
"name": "JWT",
"method": "GET",
"url": "https://yaak.app/x/echo",
"urlParameters": [],
"body": {},
"bodyType": null,
"sortPriority": 9,
"headers": [],
"authenticationType": "jwt",
"authentication": {
"algorithm": "HS384",
"secret": "mysecret",
"secretBase64": false,
"payload": "{\n \"my\": \"payload\"\n}",
"headerPrefix": "Bearer",
"location": "header"
}
}
],
"folders": []
}
}

View File

@@ -3,86 +3,88 @@
"workspaces": [
{
"model": "workspace",
"id": "GENERATE_ID::WORKSPACE_0",
"name": "New Collection"
"id": "GENERATE_ID::WORKSPACE_1",
"name": "New Collection",
"authenticationType": null,
"authentication": {}
}
],
"environments": [
{
"id": "GENERATE_ID::ENVIRONMENT_0",
"model": "environment",
"id": "GENERATE_ID::ENVIRONMENT_1",
"name": "Global Variables",
"variables": [],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
"workspaceId": "GENERATE_ID::WORKSPACE_1",
"parentModel": "workspace",
"parentId": null,
"parentModel": "workspace"
"variables": []
}
],
"httpRequests": [
{
"model": "http_request",
"id": "GENERATE_ID::HTTP_REQUEST_0",
"workspaceId": "GENERATE_ID::WORKSPACE_0",
"id": "GENERATE_ID::HTTP_REQUEST_10",
"workspaceId": "GENERATE_ID::WORKSPACE_1",
"folderId": "GENERATE_ID::FOLDER_1",
"name": "Request 1",
"method": "GET",
"url": "",
"sortPriority": 2,
"urlParameters": [],
"body": {},
"bodyType": null,
"authentication": {},
"sortPriority": 2,
"headers": [],
"authenticationType": null,
"headers": []
"authentication": {}
},
{
"model": "http_request",
"id": "GENERATE_ID::HTTP_REQUEST_1",
"workspaceId": "GENERATE_ID::WORKSPACE_0",
"id": "GENERATE_ID::HTTP_REQUEST_11",
"workspaceId": "GENERATE_ID::WORKSPACE_1",
"folderId": "GENERATE_ID::FOLDER_0",
"name": "Request 2",
"method": "GET",
"sortPriority": 3,
"url": "",
"urlParameters": [],
"body": {},
"bodyType": null,
"authentication": {},
"sortPriority": 3,
"headers": [],
"authenticationType": null,
"headers": []
"authentication": {}
},
{
"model": "http_request",
"id": "GENERATE_ID::HTTP_REQUEST_2",
"workspaceId": "GENERATE_ID::WORKSPACE_0",
"id": "GENERATE_ID::HTTP_REQUEST_12",
"workspaceId": "GENERATE_ID::WORKSPACE_1",
"folderId": null,
"sortPriority": 4,
"name": "Request 3",
"method": "GET",
"url": "",
"urlParameters": [],
"body": {},
"bodyType": null,
"authentication": {},
"sortPriority": 4,
"headers": [],
"authenticationType": null,
"headers": []
"authentication": {}
}
],
"folders": [
{
"model": "folder",
"workspaceId": "GENERATE_ID::WORKSPACE_0",
"sortPriority": 0,
"workspaceId": "GENERATE_ID::WORKSPACE_1",
"id": "GENERATE_ID::FOLDER_0",
"name": "Top Folder",
"sortPriority": 0,
"folderId": null
},
{
"model": "folder",
"workspaceId": "GENERATE_ID::WORKSPACE_0",
"sortPriority": 1,
"workspaceId": "GENERATE_ID::WORKSPACE_1",
"id": "GENERATE_ID::FOLDER_1",
"name": "Nested Folder",
"sortPriority": 1,
"folderId": "GENERATE_ID::FOLDER_0"
}
]

View File

@@ -14,7 +14,7 @@
"bearer": [
{
"key": "token",
"value": "baeare",
"value": "my-token",
"type": "string"
}
]

View File

@@ -3,18 +3,23 @@
"workspaces": [
{
"model": "workspace",
"id": "GENERATE_ID::WORKSPACE_1",
"name": "New Collection"
"id": "GENERATE_ID::WORKSPACE_2",
"name": "New Collection",
"authenticationType": "basic",
"authentication": {
"username": "globaluser",
"password": "globalpass"
}
}
],
"environments": [
{
"id": "GENERATE_ID::ENVIRONMENT_1",
"workspaceId": "GENERATE_ID::WORKSPACE_1",
"model": "environment",
"id": "GENERATE_ID::ENVIRONMENT_2",
"name": "Global Variables",
"parentId": null,
"workspaceId": "GENERATE_ID::WORKSPACE_2",
"parentModel": "workspace",
"parentId": null,
"variables": [
{
"name": "COLLECTION VARIABLE",
@@ -26,11 +31,10 @@
"httpRequests": [
{
"model": "http_request",
"id": "GENERATE_ID::HTTP_REQUEST_3",
"workspaceId": "GENERATE_ID::WORKSPACE_1",
"id": "GENERATE_ID::HTTP_REQUEST_13",
"workspaceId": "GENERATE_ID::WORKSPACE_2",
"folderId": null,
"name": "Form URL",
"sortPriority": 0,
"method": "POST",
"url": "example.com/:foo/:bar",
"urlParameters": [
@@ -71,10 +75,7 @@
]
},
"bodyType": "multipart/form-data",
"authentication": {
"token": ""
},
"authenticationType": "bearer",
"sortPriority": 0,
"headers": [
{
"name": "X-foo",
@@ -91,7 +92,11 @@
"value": "multipart/form-data",
"enabled": true
}
]
],
"authenticationType": "bearer",
"authentication": {
"token": "my-token"
}
}
],
"folders": []

View File

@@ -17,7 +17,9 @@ describe('importer-postman', () => {
const expected = fs.readFileSync(path.join(p, fixture.replace('.input', '.output')), 'utf-8');
const result = convertPostman(contents);
// console.log(JSON.stringify(result, null, 2))
expect(result).toEqual(JSON.parse(expected));
expect(JSON.stringify(result, null, 2)).toEqual(
JSON.stringify(JSON.parse(expected), null, 2),
);
});
}
});

View File

@@ -0,0 +1,12 @@
{
"name": "@yaak/template-function-random",
"displayName": "Random Template Functions",
"description": "Template functions for generating random values",
"private": true,
"version": "0.1.0",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"lint":"tsc --noEmit && eslint . --ext .ts,.tsx"
}
}

View File

@@ -0,0 +1,43 @@
import type { CallTemplateFunctionArgs, Context, PluginDefinition } from '@yaakapp/api';
export const plugin: PluginDefinition = {
templateFunctions: [
{
name: 'random.range',
description: 'Generate a random number between two values',
args: [
{
type: 'text',
name: 'min',
label: 'Minimum',
defaultValue: '0',
},
{
type: 'text',
name: 'max',
label: 'Maximum',
defaultValue: '1',
},
{
type: 'text',
name: 'decimals',
optional: true,
label: 'Decimal Places',
},
],
async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
const min = args.values.min ? parseInt(String(args.values.min ?? '0')) : 0;
const max = args.values.max ? parseInt(String(args.values.max ?? '1')) : 1;
const decimals = args.values.decimals
? parseInt(String(args.values.decimals ?? '0'))
: null;
let value = Math.random() * (max - min) + min;
if (decimals !== null) {
value = Math.round(value * Math.pow(10, decimals)) / Math.pow(10, decimals);
}
return String(value);
},
},
],
};

View File

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

View File

@@ -52,21 +52,30 @@ export const plugin: PluginDefinition = {
templateFunctions: [
{
name: 'timestamp.unix',
description: 'Get the current timestamp in seconds',
args: [],
onRender: async () => String(Math.floor(Date.now() / 1000)),
description: 'Get the timestamp in seconds',
args: [dateArg],
onRender: async (_ctx, args) => {
const d = parseDateString(String(args.values.date ?? ''));
return String(Math.floor(d.getTime() / 1000));
},
},
{
name: 'timestamp.unixMillis',
description: 'Get the current timestamp in milliseconds',
args: [],
onRender: async () => String(Date.now()),
description: 'Get the timestamp in milliseconds',
args: [dateArg],
onRender: async (_ctx, args) => {
const d = parseDateString(String(args.values.date ?? ''));
return String(d.getTime());
},
},
{
name: 'timestamp.iso8601',
description: 'Get the current date in ISO8601 format',
args: [],
onRender: async () => new Date().toISOString(),
description: 'Get the date in ISO8601 format',
args: [dateArg],
onRender: async (_ctx, args) => {
const d = parseDateString(String(args.values.date ?? ''));
return d.toISOString();
},
},
{
name: 'timestamp.format',

View File

@@ -38,16 +38,23 @@ use yaak_models::models::{
};
use yaak_models::query_manager::QueryManagerExt;
use yaak_models::util::{BatchUpsertResult, UpdateSource, get_workspace_export_resources};
use yaak_plugins::events::{CallGrpcRequestActionArgs, CallGrpcRequestActionRequest, CallHttpRequestActionArgs, CallHttpRequestActionRequest, Color, FilterResponse, GetGrpcRequestActionsResponse, GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse, GetTemplateFunctionSummaryResponse, GetTemplateFunctionConfigResponse, InternalEvent, InternalEventPayload, JsonPrimitive, PluginWindowContext, RenderPurpose, ShowToastRequest};
use yaak_plugins::events::{
CallGrpcRequestActionArgs, CallGrpcRequestActionRequest, CallHttpRequestActionArgs,
CallHttpRequestActionRequest, Color, FilterResponse, GetGrpcRequestActionsResponse,
GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse,
GetHttpRequestActionsResponse, GetTemplateFunctionConfigResponse,
GetTemplateFunctionSummaryResponse, InternalEvent, InternalEventPayload, JsonPrimitive,
PluginWindowContext, RenderPurpose, ShowToastRequest,
};
use yaak_plugins::manager::PluginManager;
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::format_json::format_json;
use yaak_templates::{RenderErrorBehavior, RenderOptions, Tokens, transform_args};
use yaak_templates::format_xml::format_xml;
mod commands;
mod dns;
mod encoding;
mod error;
mod grpc;
@@ -61,7 +68,6 @@ mod updates;
mod uri_scheme;
mod window;
mod window_menu;
mod dns;
#[derive(serde::Serialize)]
#[serde(default, rename_all = "camelCase")]
@@ -740,11 +746,6 @@ async fn cmd_format_json(text: &str) -> YaakResult<String> {
Ok(format_json(text, " "))
}
#[tauri::command]
async fn cmd_format_xml(text: &str) -> YaakResult<String> {
Ok(format_xml(text, " "))
}
#[tauri::command]
async fn cmd_http_response_body<R: Runtime>(
window: WebviewWindow<R>,
@@ -852,12 +853,16 @@ async fn cmd_template_function_config<R: Runtime>(
AnyModel::Folder(m) => (m.workspace_id, m.folder_id),
AnyModel::Workspace(m) => (m.id, None),
m => {
return Err(GenericError(format!("Unsupported model to call template functions {m:?}")));
return Err(GenericError(format!(
"Unsupported model to call template functions {m:?}"
)));
}
};
let environment_chain =
window.db().resolve_environments(&workspace_id, folder_id.as_deref(), environment_id)?;
Ok(plugin_manager.get_template_function_config(&window, function_name, environment_chain, values, model.id()).await?)
Ok(plugin_manager
.get_template_function_config(&window, function_name, environment_chain, values, model.id())
.await?)
}
#[tauri::command]
@@ -1173,7 +1178,7 @@ async fn cmd_install_plugin<R: Runtime>(
async fn cmd_create_grpc_request<R: Runtime>(
workspace_id: &str,
name: &str,
sort_priority: f32,
sort_priority: f64,
folder_id: Option<&str>,
app_handle: AppHandle<R>,
window: WebviewWindow<R>,
@@ -1421,7 +1426,6 @@ pub fn run() {
cmd_export_data,
cmd_http_response_body,
cmd_format_json,
cmd_format_xml,
cmd_get_http_authentication_summaries,
cmd_get_http_authentication_config,
cmd_get_sse_events,

View File

@@ -3,7 +3,7 @@ use std::time::SystemTime;
use crate::error::Result;
use crate::history::get_or_upsert_launch_info;
use chrono::{DateTime, Utc};
use log::debug;
use log::{debug, info};
use reqwest::Method;
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewWindow};
@@ -77,6 +77,13 @@ impl YaakNotifier {
self.last_check = SystemTime::now();
if !app_handle.db().get_settings().check_notifications {
info!("Notifications are disabled. Skipping check.");
return Ok(());
}
debug!("Checking for notifications");
#[cfg(feature = "license")]
let license_check = {
use yaak_license::{check_license, LicenseCheckStatus};

View File

@@ -70,6 +70,9 @@ pub async fn render_http_request<T: TemplateCallback>(
let mut url_parameters = Vec::new();
for p in r.url_parameters.clone() {
if !p.enabled {
continue;
}
url_parameters.push(HttpUrlParameter {
enabled: p.enabled,
name: parse_and_render(p.name.as_str(), vars, cb, &opt).await?,
@@ -80,6 +83,9 @@ pub async fn render_http_request<T: TemplateCallback>(
let mut headers = Vec::new();
for p in r.headers.clone() {
if !p.enabled {
continue;
}
headers.push(HttpRequestHeader {
enabled: p.enabled,
name: parse_and_render(p.name.as_str(), vars, cb, &opt).await?,

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, parentModel: string, parentId: string | null, variables: Array<EnvironmentVariable>, color: 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, sortPriority: number, };
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };

View File

@@ -14,7 +14,7 @@ export type EditorKeymap = "default" | "vim" | "vscode" | "emacs";
export type EncryptedKey = { encryptedKey: string, };
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 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, sortPriority: number, };
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };
@@ -62,7 +62,7 @@ export type ProxySetting = { "type": "enabled", http: string, https: string, aut
export type ProxySettingAuth = { user: string, password: string, };
export type Settings = { model: "settings", id: string, createdAt: string, updatedAt: string, appearance: string, coloredMethods: boolean, editorFont: string | null, editorFontSize: number, editorKeymap: EditorKeymap, editorSoftWrap: boolean, hideWindowControls: boolean, interfaceFont: string | null, interfaceFontSize: number, interfaceScale: number, openWorkspaceNewWindow: boolean | null, proxy: ProxySetting | null, themeDark: string, themeLight: string, updateChannel: string, hideLicenseBadge: boolean, autoupdate: boolean, autoDownloadUpdates: boolean, };
export type Settings = { model: "settings", id: string, createdAt: string, updatedAt: string, appearance: string, coloredMethods: boolean, editorFont: string | null, editorFontSize: number, editorKeymap: EditorKeymap, editorSoftWrap: boolean, hideWindowControls: boolean, interfaceFont: string | null, interfaceFontSize: number, interfaceScale: number, openWorkspaceNewWindow: boolean | null, proxy: ProxySetting | null, themeDark: string, themeLight: string, updateChannel: string, hideLicenseBadge: boolean, autoupdate: boolean, autoDownloadUpdates: boolean, checkNotifications: boolean, };
export type SyncState = { model: "sync_state", id: string, workspaceId: string, createdAt: string, updatedAt: string, flushedAt: string, modelId: string, checksum: string, relPath: string, syncDir: string, };

View File

@@ -1,5 +1,6 @@
import { invoke } from '@tauri-apps/api/core';
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
import { resolvedModelName } from '@yaakapp/app/lib/resolvedModelName';
import { AnyModel, ModelPayload } from '../bindings/gen_models';
import { modelStoreDataAtom } from './atoms';
import { ExtractModel, JotaiStore, ModelStoreData } from './types';
@@ -69,15 +70,12 @@ export async function changeModelStoreWorkspace(workspaceId: string | null) {
_activeWorkspaceId = workspaceId;
}
export function getAnyModel(id: string): AnyModel | null {
export function listModels<M extends AnyModel['model'], T extends ExtractModel<AnyModel, M>>(
modelType: M | ReadonlyArray<M>,
): T[] {
let data = mustStore().get(modelStoreDataAtom);
for (const modelData of Object.values(data)) {
let model = modelData[id];
if (model != null) {
return model;
}
}
return null;
const types: ReadonlyArray<M> = Array.isArray(modelType) ? modelType : [modelType];
return types.flatMap((t) => Object.values(data[t]) as T[]);
}
export function getModel<M extends AnyModel['model'], T extends ExtractModel<AnyModel, M>>(
@@ -137,23 +135,43 @@ export async function deleteModel<M extends AnyModel['model'], T extends Extract
await invoke<string>('plugin:yaak-models|delete', { model });
}
export function duplicateModelById<
M extends AnyModel['model'],
T extends ExtractModel<AnyModel, M>,
>(modelType: M | ReadonlyArray<M>, id: string) {
let model = getModel<M, T>(modelType, id);
return duplicateModel(model);
}
export function duplicateModel<M extends AnyModel['model'], T extends ExtractModel<AnyModel, M>>(
model: T | null,
) {
if (model == null) {
throw new Error('Failed to delete null model');
throw new Error('Failed to duplicate null model');
}
if ('sortPriority' in model) model.sortPriority = model.sortPriority + 0.0001;
return invoke<string>('plugin:yaak-models|duplicate', { model });
// If the model has a name, try to duplicate it with a name that doesn't conflict
let name = 'name' in model ? resolvedModelName(model) : undefined;
if (name != null) {
const existingModels = listModels(model.model);
for (let i = 0; i < 100; i++) {
const hasConflict = existingModels.some((m) => {
if ('folderId' in m && 'folderId' in model && model.folderId !== m.folderId) {
return false;
} else if (resolvedModelName(m) !== name) {
return false;
}
return true;
});
if (!hasConflict) {
break;
}
// Name conflict. Try another one
const m: RegExpMatchArray | null = name.match(/ Copy( (?<n>\d+))?$/);
if (m != null && m.groups?.n == null) {
name = name.substring(0, m.index) + ' Copy 2';
} else if (m != null && m.groups?.n != null) {
name = name.substring(0, m.index) + ` Copy ${parseInt(m.groups.n) + 1}`;
} else {
name = `${name} Copy`;
}
}
}
return invoke<string>('plugin:yaak-models|duplicate', { model: { ...model, name } });
}
export async function createGlobalModel<T extends Exclude<AnyModel, { workspaceId: string }>>(

View File

@@ -0,0 +1 @@
ALTER TABLE settings ADD COLUMN check_notifications BOOLEAN DEFAULT true NOT NULL;

View File

@@ -0,0 +1,11 @@
UPDATE http_requests
SET authentication_type = 'awsv4'
WHERE authentication_type = 'auth-aws-sig-v4';
UPDATE folders
SET authentication_type = 'awsv4'
WHERE authentication_type = 'auth-aws-sig-v4';
UPDATE workspaces
SET authentication_type = 'awsv4'
WHERE authentication_type = 'auth-aws-sig-v4';

View File

@@ -0,0 +1,2 @@
ALTER TABLE environments
ADD COLUMN sort_priority REAL DEFAULT 0 NOT NULL;

View File

@@ -1,7 +1,7 @@
use crate::error::Error::MigrationError;
use crate::error::Result;
use include_dir::{Dir, DirEntry, include_dir};
use log::info;
use log::{debug, info};
use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager;
use rusqlite::{OptionalExtension, TransactionBehavior, params};
@@ -86,6 +86,7 @@ fn run_migration(migration_path: &DirEntry, tx: &mut rusqlite::Transaction) -> R
.optional()?;
if row.is_some() {
debug!("Skipping already run migration {description}");
return Ok(false); // Migration was already run
}

View File

@@ -123,6 +123,7 @@ pub struct Settings {
pub hide_license_badge: bool,
pub autoupdate: bool,
pub auto_download_updates: bool,
pub check_notifications: bool,
}
impl UpsertModelInfo for Settings {
@@ -175,6 +176,7 @@ impl UpsertModelInfo for Settings {
(Autoupdate, self.autoupdate.into()),
(AutoDownloadUpdates, self.auto_download_updates.into()),
(ColoredMethods, self.colored_methods.into()),
(CheckNotifications, self.check_notifications.into()),
(Proxy, proxy.into()),
])
}
@@ -200,6 +202,7 @@ impl UpsertModelInfo for Settings {
SettingsIden::Autoupdate,
SettingsIden::AutoDownloadUpdates,
SettingsIden::ColoredMethods,
SettingsIden::CheckNotifications,
]
}
@@ -232,6 +235,7 @@ impl UpsertModelInfo for Settings {
auto_download_updates: row.get("auto_download_updates")?,
hide_license_badge: row.get("hide_license_badge")?,
colored_methods: row.get("colored_methods")?,
check_notifications: row.get("check_notifications")?,
})
}
}
@@ -550,6 +554,7 @@ pub struct Environment {
pub parent_id: Option<String>,
pub variables: Vec<EnvironmentVariable>,
pub color: Option<String>,
pub sort_priority: f64,
}
impl UpsertModelInfo for Environment {
@@ -587,6 +592,7 @@ impl UpsertModelInfo for Environment {
(Color, self.color.into()),
(Name, self.name.trim().into()),
(Public, self.public.into()),
(SortPriority, self.sort_priority.into()),
(Variables, serde_json::to_string(&self.variables)?.into()),
])
}
@@ -600,6 +606,7 @@ impl UpsertModelInfo for Environment {
EnvironmentIden::Name,
EnvironmentIden::Public,
EnvironmentIden::Variables,
EnvironmentIden::SortPriority,
]
}
@@ -622,6 +629,7 @@ impl UpsertModelInfo for Environment {
name: row.get("name")?,
public: row.get("public")?,
variables: serde_json::from_str(variables.as_str()).unwrap_or_default(),
sort_priority: row.get("sort_priority")?,
// Deprecated field, but we need to keep it around for a couple of versions
// for compatibility because sync/export don't have a schema field
@@ -679,7 +687,7 @@ pub struct Folder {
pub description: String,
pub headers: Vec<HttpRequestHeader>,
pub name: String,
pub sort_priority: f32,
pub sort_priority: f64,
}
impl UpsertModelInfo for Folder {
@@ -1049,7 +1057,7 @@ pub struct WebsocketRequest {
pub headers: Vec<HttpRequestHeader>,
pub message: String,
pub name: String,
pub sort_priority: f32,
pub sort_priority: f64,
pub url: String,
pub url_parameters: Vec<HttpUrlParameter>,
}
@@ -1484,7 +1492,7 @@ pub struct GrpcRequest {
pub method: Option<String>,
pub name: String,
pub service: Option<String>,
pub sort_priority: f32,
pub sort_priority: f64,
pub url: String,
}

View File

@@ -18,11 +18,11 @@ impl<'a> DbContext<'a> {
updated_at: Default::default(),
appearance: "system".to_string(),
editor_font_size: 13,
editor_font_size: 12,
editor_font: None,
editor_keymap: EditorKeymap::Default,
editor_soft_wrap: true,
interface_font_size: 15,
interface_font_size: 14,
interface_scale: 1.0,
interface_font: None,
hide_window_controls: false,
@@ -35,6 +35,7 @@ impl<'a> DbContext<'a> {
colored_methods: false,
hide_license_badge: false,
auto_download_updates: true,
check_notifications: true,
};
self.upsert(&settings, &UpdateSource::Background).expect("Failed to upsert settings")
}

View File

@@ -17,7 +17,7 @@ fn add_variable_to_map(
) -> HashMap<String, String> {
let mut map = m.clone();
for variable in variables {
if !variable.enabled || variable.value.is_empty() {
if !variable.enabled {
continue;
}
let name = variable.name.as_str();

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, parentModel: string, parentId: string | null, variables: Array<EnvironmentVariable>, color: 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, sortPriority: number, };
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };

View File

@@ -858,10 +858,11 @@ impl PluginManager {
content: &str,
content_type: &str,
) -> Result<FilterResponse> {
let plugin_name = if content_type.to_lowercase().contains("json") {
"@yaak/filter-jsonpath"
} else {
let ct = content_type.to_lowercase();
let plugin_name = if ct.contains("xml") || ct.contains("html") {
"@yaak/filter-xpath"
} else {
"@yaak/filter-jsonpath"
};
let plugin = self

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, parentModel: string, parentId: string | null, variables: Array<EnvironmentVariable>, color: 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, sortPriority: number, };
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };

View File

@@ -143,7 +143,7 @@ pub fn format_json(text: &str, tab: &str) -> String {
#[cfg(test)]
mod tests {
use crate::format::format_json;
use crate::format_json::format_json;
#[test]
fn test_simple_object() {

View File

@@ -1,345 +0,0 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum XmlTok<'a> {
OpenTag { raw: &'a str, name: &'a str }, // "<tag ...>"
CloseTag { raw: &'a str, name: &'a str }, // "</tag>"
SelfCloseTag(&'a str), // "<tag .../>"
Comment(&'a str), // "<!-- ... -->"
CData(&'a str), // "<![CDATA[ ... ]]>"
ProcInst(&'a str), // "<?xml ...?>"
Doctype(&'a str), // "<!DOCTYPE ...>"
Text(&'a str), // "text between tags"
Template(&'a str), // "${[ ... ]}"
}
fn writeln_indented(out: &mut String, depth: usize, indent: &str, s: &str) {
for _ in 0..depth {
out.push_str(indent);
}
out.push_str(s);
out.push('\n');
}
pub fn format_xml(input: &str, indent: &str) -> String {
use XmlTok::*;
let tokens = tokenize_with_templates(input);
let mut out = String::new();
let mut depth = 0usize;
let mut i = 0usize;
while i < tokens.len() {
match tokens[i] {
OpenTag {
raw: open_raw,
name: open_name,
} => {
if i + 2 < tokens.len() {
if let Text(text_raw) = tokens[i + 1] {
let trimmed = text_raw.trim();
let no_newlines = !trimmed.contains('\n');
if no_newlines && !trimmed.is_empty() {
if let CloseTag {
raw: close_raw,
name: close_name,
} = tokens[i + 2]
{
if open_name == close_name {
for _ in 0..depth {
out.push_str(indent);
}
out.push_str(open_raw);
out.push_str(trimmed);
out.push_str(close_raw);
out.push('\n');
i += 3;
continue;
}
}
}
}
}
writeln_indented(&mut out, depth, indent, open_raw);
depth = depth.saturating_add(1);
i += 1;
}
CloseTag { raw, .. } => {
depth = depth.saturating_sub(1);
writeln_indented(&mut out, depth, indent, raw);
i += 1;
}
SelfCloseTag(raw) | Comment(raw) | ProcInst(raw) | Doctype(raw) | CData(raw)
| Template(raw) => {
writeln_indented(&mut out, depth, indent, raw);
i += 1;
}
Text(text_raw) => {
if text_raw.chars().any(|c| !c.is_whitespace()) {
let trimmed = text_raw.trim();
writeln_indented(&mut out, depth, indent, trimmed);
}
i += 1;
}
}
}
if out.ends_with('\n') {
out.pop();
}
out
}
fn tokenize_with_templates(input: &str) -> Vec<XmlTok<'_>> {
use XmlTok::*;
let bytes = input.as_bytes();
let mut i = 0usize;
let mut toks = Vec::<XmlTok>::new();
let starts_with =
|s: &[u8], i: usize, pat: &str| s.get(i..).map_or(false, |t| t.starts_with(pat.as_bytes()));
while i < bytes.len() {
// Template block: ${[ ... ]}
if starts_with(bytes, i, "${[") {
let start = i;
i += 3;
while i < bytes.len() && !starts_with(bytes, i, "]}") {
i += 1;
}
if starts_with(bytes, i, "]}") {
i += 2;
}
toks.push(Template(&input[start..i]));
continue;
}
if bytes[i] == b'<' {
// Comments
if starts_with(bytes, i, "<!--") {
let start = i;
i += 4;
while i < bytes.len() && !starts_with(bytes, i, "-->") {
i += 1;
}
if starts_with(bytes, i, "-->") {
i += 3;
}
toks.push(Comment(&input[start..i]));
continue;
}
// CDATA
if starts_with(bytes, i, "<![CDATA[") {
let start = i;
i += 9;
while i < bytes.len() && !starts_with(bytes, i, "]]>") {
i += 1;
}
if starts_with(bytes, i, "]]>") {
i += 3;
}
toks.push(CData(&input[start..i]));
continue;
}
// Processing Instruction
if starts_with(bytes, i, "<?") {
let start = i;
i += 2;
while i < bytes.len() && !starts_with(bytes, i, "?>") {
i += 1;
}
if starts_with(bytes, i, "?>") {
i += 2;
}
toks.push(ProcInst(&input[start..i]));
continue;
}
// DOCTYPE or other "<!"
if starts_with(bytes, i, "<!") {
let start = i;
i += 2;
while i < bytes.len() && bytes[i] != b'>' {
i += 1;
}
if i < bytes.len() {
i += 1;
}
toks.push(Doctype(&input[start..i]));
continue;
}
// Normal tag (open/close/self)
let start = i;
i += 1; // '<'
let is_close = if i < bytes.len() && bytes[i] == b'/' {
i += 1;
true
} else {
false
};
// read until '>' (respecting quotes)
let mut in_quote: Option<u8> = None;
while i < bytes.len() {
let c = bytes[i];
if let Some(q) = in_quote {
if c == q {
in_quote = None;
}
i += 1;
} else {
if c == b'\'' || c == b'"' {
in_quote = Some(c);
i += 1;
} else if c == b'>' {
i += 1;
break;
} else {
i += 1;
}
}
}
let raw = &input[start..i];
let is_self = raw.as_bytes().len() >= 2 && raw.as_bytes()[raw.len() - 2] == b'/';
if is_close {
let name = parse_close_name(raw);
toks.push(CloseTag { raw, name });
} else if is_self {
toks.push(SelfCloseTag(raw));
} else {
let name = parse_open_name(raw);
toks.push(OpenTag { raw, name });
}
continue;
}
// Text node until next '<' or template start
let start = i;
while i < bytes.len() && bytes[i] != b'<' && !starts_with(bytes, i, "${[") {
i += 1;
}
toks.push(XmlTok::Text(&input[start..i]));
}
toks
}
fn parse_open_name(raw: &str) -> &str {
// raw looks like "<name ...>" or "<name>"
// slice between '<' and first whitespace or '>' or '/>'
let s = &raw[1..]; // skip '<'
let end = s.find(|c: char| c.is_whitespace() || c == '>' || c == '/').unwrap_or(s.len());
&s[..end]
}
fn parse_close_name(raw: &str) -> &str {
// raw looks like "</name>"
let s = &raw[2..]; // skip "</"
let end = s.find('>').unwrap_or(s.len());
&s[..end]
}
#[cfg(test)]
mod tests {
use super::format_xml;
#[test]
fn inline_text_child() {
let src = r#"<root><foo>this might be a string</foo><bar attr="x">ok</bar></root>"#;
let want = r#"<root>
<foo>this might be a string</foo>
<bar attr="x">ok</bar>
</root>"#;
assert_eq!(format_xml(src, " "), want);
}
#[test]
fn works_when_nested() {
let src = r#"<root><foo><b>bold</b></foo></root>"#;
let want = r#"<root>
<foo>
<b>bold</b>
</foo>
</root>"#;
assert_eq!(format_xml(src, " "), want);
}
#[test]
fn trims_and_keeps_nonempty() {
let src = "<root><foo> hi </foo></root>";
let want = "<root>\n <foo>hi</foo>\n</root>";
assert_eq!(format_xml(src, " "), want);
}
#[test]
fn attributes_inline_text_child() {
// Keeps attributes verbatim and inlines simple text children
let src = r#"<root><item id="42" class='a b'>value</item></root>"#;
let want = r#"<root>
<item id="42" class='a b'>value</item>
</root>"#;
assert_eq!(format_xml(src, " "), want);
}
#[test]
fn attributes_with_irregular_spacing_preserved() {
// We don't normalize spaces inside the tag; raw is preserved
let src = r#"<root><a x = "1" y='2' >t</a></root>"#;
let want = r#"<root>
<a x = "1" y='2' >t</a>
</root>"#;
assert_eq!(format_xml(src, " "), want);
}
#[test]
fn self_closing_with_attributes() {
let src =
r#"<root><img src="x" alt='hello &quot;world&quot;' width="10" height="20"/></root>"#;
let want = r#"<root>
<img src="x" alt='hello &quot;world&quot;' width="10" height="20"/>
</root>"#;
assert_eq!(format_xml(src, " "), want);
}
#[test]
fn template_in_attribute_self_closing() {
let src = r#"<root><x attr=${[ compute(1, "two") ]}/></root>"#;
let want = r#"<root>
<x attr=${[ compute(1, "two") ]}/>
</root>"#;
assert_eq!(format_xml(src, " "), want);
}
#[test]
fn attributes_and_nested_children_expand() {
// Not inlined because child is an element, not plain text
let src = r#"<root><box kind="card"><b>bold</b></box></root>"#;
let want = r#"<root>
<box kind="card">
<b>bold</b>
</box>
</root>"#;
assert_eq!(format_xml(src, " "), want);
}
#[test]
fn namespace_and_xml_attrs() {
let src = r#"<root><ns:el xml:lang="en">ok</ns:el></root>"#;
let want = r#"<root>
<ns:el xml:lang="en">ok</ns:el>
</root>"#;
assert_eq!(format_xml(src, " "), want);
}
#[test]
fn mixed_quote_styles_in_attributes() {
// Single-quoted attr containing double quotes is fine; we don't re-quote
let src = r#"<root><a title='He said "hi"'>hello</a></root>"#;
let want = r#"<root>
<a title='He said "hi"'>hello</a>
</root>"#;
assert_eq!(format_xml(src, " "), want);
}
}

View File

@@ -1,10 +1,9 @@
pub mod error;
pub mod escape;
pub mod format;
pub mod format_json;
pub mod parser;
pub mod renderer;
pub mod wasm;
pub mod format_xml;
pub use parser::*;
pub use renderer::*;

View File

@@ -259,6 +259,22 @@ mod parse_and_render_tests {
Ok(())
}
#[tokio::test]
async fn render_empty_var() -> Result<()> {
let empty_cb = EmptyCB {};
let template = "${[ foo ]}";
let mut vars = HashMap::new();
vars.insert("foo".to_string(), "".to_string());
let opt = RenderOptions {
error_behavior: RenderErrorBehavior::Throw,
};
assert_eq!(
parse_and_render(template, &vars, &empty_cb, &opt).await,
Ok("".to_string())
);
Ok(())
}
#[tokio::test]
async fn render_self_referencing_var() -> Result<()> {
let empty_cb = EmptyCB {};

View File

@@ -1,6 +1,6 @@
use crate::error::Result;
use std::collections::BTreeMap;
use yaak_models::models::{Environment, HttpRequestHeader, WebsocketRequest};
use yaak_models::models::{Environment, HttpRequestHeader, HttpUrlParameter, WebsocketRequest};
use yaak_models::render::make_vars_hashmap;
use yaak_templates::{parse_and_render, render_json_value_raw, RenderOptions, TemplateCallback};
@@ -12,6 +12,16 @@ pub async fn render_websocket_request<T: TemplateCallback>(
) -> Result<WebsocketRequest> {
let vars = &make_vars_hashmap(environment_chain);
let mut url_parameters = Vec::new();
for p in r.url_parameters.clone() {
url_parameters.push(HttpUrlParameter {
enabled: p.enabled,
name: parse_and_render(&p.name, vars, cb, opt).await?,
value: parse_and_render(&p.value, vars, cb, opt).await?,
id: p.id,
})
}
let mut headers = Vec::new();
for p in r.headers.clone() {
headers.push(HttpRequestHeader {
@@ -33,6 +43,7 @@ pub async fn render_websocket_request<T: TemplateCallback>(
Ok(WebsocketRequest {
url,
url_parameters,
headers,
authentication,
message,

View File

@@ -34,7 +34,7 @@ export const createFolder = createFastMutation<
confirmText: 'Create',
placeholder: 'Name',
});
if (name == null) throw new Error('No name provided to create folder');
if (name == null) return;
patch.name = name;
}

View File

@@ -1,8 +1,9 @@
import { createWorkspaceModel, type Environment } from '@yaakapp-internal/models';
import { type Environment } from '@yaakapp-internal/models';
import { CreateEnvironmentDialog } from '../components/CreateEnvironmentDialog';
import { activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace';
import { createFastMutation } from '../hooks/useFastMutation';
import { showDialog } from '../lib/dialog';
import { jotaiStore } from '../lib/jotai';
import { showPrompt } from '../lib/prompt';
import { setWorkspaceSearchParams } from '../lib/setWorkspaceSearchParams';
export const createSubEnvironmentAndActivate = createFastMutation<
@@ -21,24 +22,23 @@ export const createSubEnvironmentAndActivate = createFastMutation<
throw new Error('Cannot create environment when no active workspace');
}
const name = await showPrompt({
id: 'new-environment',
title: 'New Environment',
description: 'Create multiple environments with different sets of variables',
label: 'Name',
placeholder: 'My Environment',
defaultValue: 'My Environment',
confirmText: 'Create',
});
if (name == null) return null;
return createWorkspaceModel({
model: 'environment',
name,
variables: [],
workspaceId,
parentId: baseEnvironment.id,
parentModel: 'environment',
return new Promise<string | null>((resolve) => {
showDialog({
id: 'new-environment',
title: 'New Environment',
description: 'Create multiple environments with different sets of variables',
size: 'sm',
onClose: () => resolve(null),
render: ({ hide }) => (
<CreateEnvironmentDialog
workspaceId={workspaceId}
hide={hide}
onCreate={(id: string) => {
resolve(id);
}}
/>
),
});
});
},
onSuccess: async (environmentId) => {

View File

@@ -0,0 +1,28 @@
import classNames from 'classnames';
import type { CSSProperties } from 'react';
interface Props {
color: string | null;
onClick?: () => void;
className?: string;
}
export function ColorIndicator({ color, onClick, className }: Props) {
const style: CSSProperties = { backgroundColor: color ?? undefined };
const finalClassName = classNames(
className,
'inline-block w-[0.75em] h-[0.75em] rounded-full mr-1.5 border border-transparent flex-shrink-0',
);
if (onClick) {
return (
<button
onClick={onClick}
style={style}
className={classNames(finalClassName, 'hover:border-text')}
/>
);
} else {
return <span style={style} className={finalClassName} />;
}
}

View File

@@ -85,11 +85,6 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
action: 'settings.show',
onSelect: () => openSettings.mutate(null),
},
{
key: 'folder.create',
label: 'Create Folder',
onSelect: () => createFolder.mutate({}),
},
{
key: 'app.create',
label: 'Create Workspace',

View File

@@ -0,0 +1,68 @@
import { createWorkspaceModel } from '@yaakapp-internal/models';
import { useState } from 'react';
import { useToggle } from '../hooks/useToggle';
import { ColorIndicator } from './ColorIndicator';
import { Button } from './core/Button';
import { Checkbox } from './core/Checkbox';
import { ColorPickerWithThemeColors } from './core/ColorPicker';
import { Label } from './core/Label';
import { PlainInput } from './core/PlainInput';
interface Props {
onCreate: (id: string) => void;
hide: () => void;
workspaceId: string;
}
export function CreateEnvironmentDialog({ workspaceId, hide, onCreate }: Props) {
const [name, setName] = useState<string>('');
const [color, setColor] = useState<string | null>(null);
const [sharable, toggleSharable] = useToggle(false);
return (
<form
className="pb-3 flex flex-col gap-3"
onSubmit={async (e) => {
e.preventDefault();
const id = await createWorkspaceModel({
model: 'environment',
name,
color,
variables: [],
public: sharable,
workspaceId,
parentModel: 'environment',
});
hide();
onCreate(id);
}}
>
<PlainInput
label="Name"
required
defaultValue={name}
onChange={setName}
placeholder="Production"
/>
<Checkbox
checked={sharable}
title="Share this environment"
help="Sharable environments are included in data export and directory sync."
onChange={toggleSharable}
/>
<div>
<Label
htmlFor="color"
className="mb-1.5"
help="Select a color to be displayed when this environment is active, to help identify it."
>
Color
</Label>
<ColorPickerWithThemeColors onChange={setColor} color={color} />
</div>
<Button type="submit" color="secondary" className="mt-3">
{color != null && <ColorIndicator color={color} />}
Create Environment
</Button>
</form>
);
}

View File

@@ -1,29 +1,23 @@
import type { Environment } from '@yaakapp-internal/models';
import classNames from 'classnames';
import { showColorPicker } from '../lib/showColorPicker';
import { ColorIndicator } from './ColorIndicator';
export function EnvironmentColorIndicator({
environment,
clickToEdit,
className,
}: {
environment: Environment | null;
clickToEdit?: boolean;
className?: string;
}) {
if (environment?.color == null) return null;
const style = { backgroundColor: environment.color };
const className =
'inline-block w-[0.75em] h-[0.75em] rounded-full mr-1.5 border border-transparent';
if (clickToEdit) {
return (
<button
onClick={() => showColorPicker(environment)}
style={style}
className={classNames(className, 'hover:border-text')}
/>
);
} else {
return <span style={style} className={className} />;
}
return (
<ColorIndicator
className={className}
color={environment?.color ?? null}
onClick={clickToEdit ? () => showColorPicker(environment) : undefined}
/>
);
}

View File

@@ -1,6 +1,8 @@
import { useState } from 'react';
import { ColorIndicator } from './ColorIndicator';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import { ColorPicker } from './core/ColorPicker';
import { ColorPickerWithThemeColors } from './core/ColorPicker';
export function EnvironmentColorPicker({
color: defaultColor,
@@ -12,21 +14,20 @@ export function EnvironmentColorPicker({
const [color, setColor] = useState<string | null>(defaultColor);
return (
<form
className="flex flex-col items-stretch gap-3 pb-2 w-full"
className="flex flex-col items-stretch gap-5 pb-2 w-full"
onSubmit={(e) => {
e.preventDefault();
onChange(color);
}}
>
<ColorPicker color={color} onChange={setColor} />
<div className="grid grid-cols-[1fr_1fr] gap-1.5">
<Button variant="border" color="secondary" onClick={() => onChange(null)}>
Clear
</Button>
<Button type="submit" color="primary">
Save
</Button>
</div>
<Banner color="secondary">
This color will be used to color the interface when this environment is active
</Banner>
<ColorPickerWithThemeColors color={color} onChange={setColor} />
<Button type="submit" color="secondary">
{color != null && <ColorIndicator color={color} />}
Save
</Button>
</form>
);
}

View File

@@ -1,25 +1,28 @@
import type { Environment } from '@yaakapp-internal/models';
import type { Environment, Workspace } from '@yaakapp-internal/models';
import { duplicateModel, patchModel } from '@yaakapp-internal/models';
import classNames from 'classnames';
import type { ReactNode } from 'react';
import React, { useCallback, useState } from 'react';
import { atom, useAtomValue } from 'jotai';
import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createSubEnvironmentAndActivate } from '../commands/createEnvironment';
import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown';
import { activeWorkspaceAtom, activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace';
import {
environmentsBreakdownAtom,
useEnvironmentsBreakdown,
} from '../hooks/useEnvironmentsBreakdown';
import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm';
import { jotaiStore } from '../lib/jotai';
import { isBaseEnvironment } from '../lib/model_util';
import { showPrompt } from '../lib/prompt';
import { resolvedModelName } from '../lib/resolvedModelName';
import { showColorPicker } from '../lib/showColorPicker';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import type { DropdownItem } from './core/Dropdown';
import { ContextMenu } from './core/Dropdown';
import type { ContextMenuProps, DropdownItem } from './core/Dropdown';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { IconTooltip } from './core/IconTooltip';
import { InlineCode } from './core/InlineCode';
import { Separator } from './core/Separator';
import { SplitLayout } from './core/SplitLayout';
import type { TreeNode } from './core/tree/common';
import type { TreeHandle, TreeProps } from './core/tree/Tree';
import { Tree } from './core/tree/Tree';
import { EnvironmentColorIndicator } from './EnvironmentColorIndicator';
import { EnvironmentEditor } from './EnvironmentEditor';
import { EnvironmentSharableTooltip } from './EnvironmentSharableTooltip';
@@ -28,9 +31,10 @@ interface Props {
initialEnvironment: Environment | null;
}
type TreeModel = Environment | Workspace;
export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
const { baseEnvironment, otherBaseEnvironments, subEnvironments, allEnvironments } =
useEnvironmentsBreakdown();
const { allEnvironments, baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown();
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<string | null>(
initialEnvironment?.id ?? null,
);
@@ -40,23 +44,71 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
? allEnvironments.find((e) => e.id === selectedEnvironmentId)
: baseEnvironment;
const handleCreateEnvironment = async () => {
if (baseEnvironment == null) return;
const id = await createSubEnvironmentAndActivate.mutateAsync(baseEnvironment);
if (id != null) setSelectedEnvironmentId(id);
};
return (
<SplitLayout
name="env_editor"
defaultRatio={0.75}
layout="horizontal"
className="gap-0"
resizeHandleClassName="-translate-x-[1px]"
firstSlot={() => (
<EnvironmentEditDialogSidebar
selectedEnvironmentId={selectedEnvironment?.id ?? null}
setSelectedEnvironmentId={setSelectedEnvironmentId}
/>
)}
secondSlot={() => (
<div className="grid grid-rows-[auto_minmax(0,1fr)]">
{baseEnvironments.length > 1 ? (
<div className="p-3">
<Banner color="notice">
There are multiple base environments for this workspace. Please delete the
environments you no longer need.
</Banner>
</div>
) : (
<span />
)}
{selectedEnvironment == null ? (
<div className="p-3 mt-10">
<Banner color="danger">
Failed to find selected environment <InlineCode>{selectedEnvironmentId}</InlineCode>
</Banner>
</div>
) : (
<EnvironmentEditor className="pl-4 pt-3" environment={selectedEnvironment} />
)}
</div>
)}
/>
);
};
const handleDuplicateEnvironment = useCallback(async (environment: Environment) => {
const name = await showPrompt({
id: 'duplicate-environment',
title: 'Duplicate Environment',
label: 'Name',
defaultValue: environment.name,
});
if (name) {
const newId = await duplicateModel({ ...environment, name, public: false });
setSelectedEnvironmentId(newId);
}
const sharableTooltip = (
<IconTooltip
icon="eye"
iconSize="sm"
content="This environment will be included in Directory Sync and data exports"
/>
);
function EnvironmentEditDialogSidebar({
selectedEnvironmentId,
setSelectedEnvironmentId,
}: {
selectedEnvironmentId: string | null;
setSelectedEnvironmentId: (id: string | null) => void;
}) {
const activeWorkspaceId = useAtomValue(activeWorkspaceIdAtom) ?? '';
const treeId = `environment.${activeWorkspaceId}.sidebar`;
const treeRef = useRef<TreeHandle>(null);
const { baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown();
useLayoutEffect(() => {
if (selectedEnvironmentId == null) return;
treeRef.current?.selectItem(selectedEnvironmentId);
treeRef.current?.focus();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleDeleteEnvironment = useCallback(
@@ -66,218 +118,283 @@ export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
setSelectedEnvironmentId(baseEnvironment?.id ?? null);
}
},
[baseEnvironment?.id, selectedEnvironmentId],
[baseEnvironment?.id, selectedEnvironmentId, setSelectedEnvironmentId],
);
if (baseEnvironment == null) {
return null;
}
const actions = useMemo(() => {
const enable = () => treeRef.current?.hasFocus() ?? false;
return (
<SplitLayout
name="env_editor"
defaultRatio={0.75}
layout="horizontal"
className="gap-0"
firstSlot={() => (
<aside className="w-full min-w-0 pt-2">
<div className="min-w-0 h-full overflow-y-auto pt-1">
{[baseEnvironment, ...otherBaseEnvironments].map((e) => (
<EnvironmentDialogSidebarButton
key={e.id}
active={selectedEnvironment?.id == e.id}
onClick={() => setSelectedEnvironmentId(e.id)}
environment={e}
duplicateEnvironment={handleDuplicateEnvironment}
// Allow deleting the base environment if there are multiples
deleteEnvironment={
otherBaseEnvironments.length > 0 ? handleDeleteEnvironment : null
}
rightSlot={e.public && sharableTooltip}
outerRightSlot={
<IconButton
size="sm"
iconSize="md"
title="Add sub environment"
icon="plus_circle"
iconClassName="text-text-subtlest group-hover:text-text-subtle"
className="group mr-0.5"
onClick={handleCreateEnvironment}
/>
}
>
{resolvedModelName(e)}
</EnvironmentDialogSidebarButton>
))}
{subEnvironments.length > 0 && (
<div className="px-2">
<Separator className="my-3" />
</div>
)}
{subEnvironments.map((e) => (
<EnvironmentDialogSidebarButton
key={e.id}
active={selectedEnvironment?.id === e.id}
environment={e}
onClick={() => setSelectedEnvironmentId(e.id)}
rightSlot={e.public && sharableTooltip}
duplicateEnvironment={handleDuplicateEnvironment}
deleteEnvironment={handleDeleteEnvironment}
>
{e.name}
</EnvironmentDialogSidebarButton>
))}
</div>
</aside>
)}
secondSlot={() =>
selectedEnvironment == null ? (
<div className="p-3 mt-10">
<Banner color="danger">
Failed to find selected environment <InlineCode>{selectedEnvironmentId}</InlineCode>
</Banner>
</div>
) : (
<EnvironmentEditor
className="pl-4 pt-3 border-l border-border-subtle"
environment={selectedEnvironment}
/>
)
const actions = {
'sidebar.selected.rename': {
enable,
allowDefault: true,
priority: 100,
cb: async function (items: TreeModel[]) {
const item = items[0];
if (items.length === 1 && item != null) {
treeRef.current?.renameItem(item.id);
}
},
},
'sidebar.selected.delete': {
priority: 100,
enable,
cb: (items: TreeModel[]) => deleteModelWithConfirm(items),
},
'sidebar.selected.duplicate': {
priority: 100,
enable,
cb: async function (items: TreeModel[]) {
if (items.length === 1) {
const item = items[0]!;
const newId = await duplicateModel(item);
setSelectedEnvironmentId(newId);
} else {
await Promise.all(items.map(duplicateModel));
}
},
},
} as const;
return actions;
}, [setSelectedEnvironmentId]);
const hotkeys = useMemo<TreeProps<TreeModel>['hotkeys']>(() => ({ actions }), [actions]);
const getContextMenu = useCallback(
(items: TreeModel[]): ContextMenuProps['items'] => {
const environment = items[0];
if (environment == null || environment.model !== 'environment') return [];
const singleEnvironment = items.length === 1;
const menuItems: DropdownItem[] = [
{
label: 'Rename',
leftSlot: <Icon icon="pencil" />,
hidden: isBaseEnvironment(environment) || !singleEnvironment,
hotKeyAction: 'sidebar.selected.rename',
hotKeyLabelOnly: true,
onSelect: async () => {
// Not sure why this is needed, but without it the
// edit input blurs immediately after opening.
requestAnimationFrame(() => {
actions['sidebar.selected.rename'].cb(items);
});
},
},
{
label: 'Duplicate',
leftSlot: <Icon icon="copy" />,
hidden: isBaseEnvironment(environment),
hotKeyAction: 'sidebar.selected.duplicate',
hotKeyLabelOnly: true,
onSelect: () => actions['sidebar.selected.duplicate'].cb(items),
},
{
label: environment.color ? 'Change Color' : 'Assign Color',
leftSlot: <Icon icon="palette" />,
hidden: isBaseEnvironment(environment) || !singleEnvironment,
onSelect: async () => showColorPicker(environment),
},
{
label: `Make ${environment.public ? 'Private' : 'Sharable'}`,
leftSlot: <Icon icon={environment.public ? 'eye_closed' : 'eye'} />,
rightSlot: <EnvironmentSharableTooltip />,
onSelect: async () => {
await patchModel(environment, { public: !environment.public });
},
},
{
color: 'danger',
label: 'Delete',
hotKeyAction: 'sidebar.selected.delete',
hotKeyLabelOnly: true,
hidden: !(isBaseEnvironment(environment) && baseEnvironments.length > 1),
leftSlot: <Icon icon="trash" />,
onSelect: () => handleDeleteEnvironment(environment),
},
];
if (isBaseEnvironment(environment) && singleEnvironment) {
menuItems.push({ type: 'separator' });
menuItems.push({
label: 'Create Sub Environment',
leftSlot: <Icon icon="plus" />,
hidden: !isBaseEnvironment(environment),
onSelect: async () => {
await createSubEnvironment();
},
});
}
/>
return menuItems;
},
[actions, baseEnvironments.length, handleDeleteEnvironment],
);
};
function EnvironmentDialogSidebarButton({
children,
className,
active,
onClick,
deleteEnvironment,
rightSlot,
outerRightSlot,
duplicateEnvironment,
environment,
}: {
className?: string;
children: ReactNode;
active: boolean;
onClick: () => void;
rightSlot?: ReactNode;
outerRightSlot?: ReactNode;
environment: Environment;
deleteEnvironment: ((environment: Environment) => void) | null;
duplicateEnvironment: ((environment: Environment) => void) | null;
}) {
const [showContextMenu, setShowContextMenu] = useState<{
x: number;
y: number;
} | null>(null);
const handleDragEnd = useCallback(async function handleDragEnd({
items,
children,
insertAt,
}: {
items: TreeModel[];
children: TreeModel[];
insertAt: number;
}) {
const prev = children[insertAt - 1] as Exclude<TreeModel, Workspace>;
const next = children[insertAt] as Exclude<TreeModel, Workspace>;
const handleContextMenu = useCallback((e: React.MouseEvent<HTMLElement>) => {
e.preventDefault();
e.stopPropagation();
setShowContextMenu({ x: e.clientX, y: e.clientY });
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 })));
} else {
const range = afterPriority - beforePriority;
const increment = range / (items.length + 2);
await Promise.all(
items.map((m, i) => {
const sortPriority = beforePriority + (i + 1) * increment;
// Spread item sortPriority out over before/after range
return patchModel(m, { sortPriority });
}),
);
}
} catch (e) {
console.error(e);
}
}, []);
const handleActivate = useCallback(
(item: TreeModel) => {
setSelectedEnvironmentId(item.id);
},
[setSelectedEnvironmentId],
);
const tree = useAtomValue(treeAtom);
return (
<aside className="x-theme-sidebar h-full w-full min-w-0 grid overflow-y-auto border-r border-border-subtle ">
{tree != null && (
<div className="pt-2">
<Tree
ref={treeRef}
treeId={treeId}
className="px-2 pb-10"
hotkeys={hotkeys}
root={tree}
getContextMenu={getContextMenu}
onDragEnd={handleDragEnd}
getItemKey={(i) => `${i.id}::${i.name}`}
ItemLeftSlotInner={ItemLeftSlotInner}
ItemRightSlot={ItemRightSlot}
ItemInner={ItemInner}
onActivate={handleActivate}
getEditOptions={getEditOptions}
/>
</div>
)}
</aside>
);
}
const treeAtom = atom<TreeNode<TreeModel> | null>((get) => {
const activeWorkspace = get(activeWorkspaceAtom);
const { baseEnvironment, baseEnvironments, subEnvironments } = get(environmentsBreakdownAtom);
if (activeWorkspace == null || baseEnvironment == null) return null;
const root: TreeNode<TreeModel> = {
item: activeWorkspace,
parent: null,
children: [],
depth: 0,
};
for (const item of baseEnvironments) {
root.children?.push({
item,
parent: root,
depth: 0,
draggable: false,
});
}
const parent = root.children?.[0];
if (baseEnvironments.length <= 1 && parent != null) {
const sortedEnvironments = [...subEnvironments].sort((a, b) => {
if (a.sortPriority === b.sortPriority) return a.updatedAt > b.updatedAt ? 1 : -1;
else return a.sortPriority - b.sortPriority;
});
parent.children = sortedEnvironments.map((item) => ({
item,
parent,
depth: 1,
localDrag: true,
}));
}
return root;
});
function ItemLeftSlotInner({ item }: { item: TreeModel }) {
const { baseEnvironments } = useEnvironmentsBreakdown();
return baseEnvironments.length > 1 ? (
<Icon icon="alert_triangle" color="notice" />
) : (
item.model === 'environment' && item.color && <EnvironmentColorIndicator environment={item} />
);
}
function ItemRightSlot({ item }: { item: TreeModel }) {
const { baseEnvironments } = useEnvironmentsBreakdown();
return (
<>
<div
className={classNames(
className,
'w-full grid grid-cols-[minmax(0,1fr)_auto] items-center gap-0.5',
'px-2', // Padding to show the focus border
)}
>
<Button
{item.model === 'environment' && baseEnvironments.length <= 1 && isBaseEnvironment(item) && (
<IconButton
size="sm"
color="custom"
size="xs"
className={classNames(
'w-full',
active ? 'text bg-surface-active' : 'text-text-subtle hover:text',
)}
justify="start"
onClick={onClick}
onContextMenu={handleContextMenu}
rightSlot={rightSlot}
>
<EnvironmentColorIndicator environment={environment} />
{children}
</Button>
{outerRightSlot}
</div>
<ContextMenu
triggerPosition={showContextMenu}
onClose={() => setShowContextMenu(null)}
items={[
{
label: 'Rename',
leftSlot: <Icon icon="pencil" />,
hidden: isBaseEnvironment(environment),
onSelect: async () => {
const name = await showPrompt({
id: 'rename-environment',
title: 'Rename Environment',
description: (
<>
Enter a new name for <InlineCode>{environment.name}</InlineCode>
</>
),
label: 'Name',
confirmText: 'Save',
placeholder: 'New Name',
defaultValue: environment.name,
});
if (name == null) return;
await patchModel(environment, { name });
},
},
...((duplicateEnvironment
? [
{
label: 'Duplicate',
leftSlot: <Icon icon="copy" />,
onSelect: () => {
duplicateEnvironment?.(environment);
},
},
]
: []) as DropdownItem[]),
{
label: environment.color ? 'Change Color' : 'Assign Color',
leftSlot: <Icon icon="palette" />,
hidden: isBaseEnvironment(environment),
onSelect: async () => showColorPicker(environment),
},
{
label: `Make ${environment.public ? 'Private' : 'Sharable'}`,
leftSlot: <Icon icon={environment.public ? 'eye_closed' : 'eye'} />,
rightSlot: <EnvironmentSharableTooltip />,
onSelect: async () => {
await patchModel(environment, { public: !environment.public });
},
},
...((deleteEnvironment
? [
{
color: 'danger',
label: 'Delete',
leftSlot: <Icon icon="trash" />,
onSelect: () => {
deleteEnvironment(environment);
},
},
]
: []) as DropdownItem[]),
]}
/>
iconSize="sm"
icon="plus_circle"
className="opacity-50 hover:opacity-100"
title="Add Sub-Environment"
onClick={createSubEnvironment}
/>
)}
</>
);
}
const sharableTooltip = (
<IconTooltip
icon="eye"
content="This environment will be included in Directory Sync and data exports"
/>
);
function ItemInner({ item }: { item: TreeModel }) {
return (
<div className="grid grid-cols-[auto_minmax(0,1fr)] w-full items-center">
{item.model === 'environment' && item.public ? (
<div className="mr-2 flex items-center">{sharableTooltip}</div>
) : (
<span aria-hidden />
)}
<div className="truncate min-w-0 text-left">{resolvedModelName(item)}</div>
</div>
);
}
async function createSubEnvironment() {
const { baseEnvironment } = jotaiStore.get(environmentsBreakdownAtom);
if (baseEnvironment == null) return;
const id = await createSubEnvironmentAndActivate.mutateAsync(baseEnvironment);
return id;
}
function getEditOptions(item: TreeModel) {
const options: ReturnType<NonNullable<TreeProps<TreeModel>['getEditOptions']>> = {
defaultValue: item.name,
placeholder: 'Name',
async onChange(item, name) {
await patchModel(item, { name });
},
};
return options;
}

View File

@@ -98,10 +98,10 @@ export function EnvironmentEditor({
};
return (
<div className={classNames(className, 'h-full grid grid-rows-[auto_minmax(0,1fr)] gap-2')}>
<div className={classNames(className, 'h-full grid grid-rows-[auto_minmax(0,1fr)] gap-2 pr-3 pb-3')}>
<div className="flex flex-col gap-4">
<Heading className="w-full flex items-center gap-0.5">
<EnvironmentColorIndicator clickToEdit environment={environment ?? null} />
<EnvironmentColorIndicator className="mr-2" clickToEdit environment={environment ?? null} />
{!hideName && <div className="mr-2">{environment?.name}</div>}
{isEncryptionEnabled ? (
!allVariableAreEncrypted ? (

View File

@@ -31,11 +31,11 @@ interface Props {
workspace: Workspace;
}
interface TreeNode {
interface CommitTreeNode {
model: HttpRequest | GrpcRequest | WebsocketRequest | Folder | Environment | Workspace;
status: GitStatusEntry;
children: TreeNode[];
ancestors: TreeNode[];
children: CommitTreeNode[];
ancestors: CommitTreeNode[];
}
export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
@@ -80,14 +80,14 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
const hasAddedAnything = allEntries.find((e) => e.staged) != null;
const hasAnythingToAdd = allEntries.find((e) => e.status !== 'current') != null;
const tree: TreeNode | null = useMemo(() => {
const next = (model: TreeNode['model'], ancestors: TreeNode[]): TreeNode | null => {
const tree: CommitTreeNode | null = useMemo(() => {
const next = (model: CommitTreeNode['model'], ancestors: CommitTreeNode[]): CommitTreeNode | null => {
const statusEntry = internalEntries?.find((s) => s.relaPath.includes(model.id));
if (statusEntry == null) {
return null;
}
const node: TreeNode = {
const node: CommitTreeNode = {
model,
status: statusEntry,
children: [],
@@ -128,7 +128,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
return <EmptyStateText>No changes since last commit</EmptyStateText>;
}
const checkNode = (treeNode: TreeNode) => {
const checkNode = (treeNode: CommitTreeNode) => {
const checked = nodeCheckedStatus(treeNode);
const newChecked = checked === 'indeterminate' ? true : !checked;
setCheckedAndChildren(treeNode, newChecked, unstage.mutate, add.mutate);
@@ -211,9 +211,9 @@ function TreeNodeChildren({
depth,
onCheck,
}: {
node: TreeNode | null;
node: CommitTreeNode | null;
depth: number;
onCheck: (node: TreeNode, checked: boolean) => void;
onCheck: (node: CommitTreeNode, checked: boolean) => void;
}) {
if (node === null) return null;
if (!isNodeRelevant(node)) return null;
@@ -318,12 +318,12 @@ function ExternalTreeNode({
);
}
function nodeCheckedStatus(root: TreeNode): CheckboxProps['checked'] {
function nodeCheckedStatus(root: CommitTreeNode): CheckboxProps['checked'] {
let numVisited = 0;
let numChecked = 0;
let numCurrent = 0;
const visitChildren = (n: TreeNode) => {
const visitChildren = (n: CommitTreeNode) => {
numVisited += 1;
if (n.status.status === 'current') {
numCurrent += 1;
@@ -347,7 +347,7 @@ function nodeCheckedStatus(root: TreeNode): CheckboxProps['checked'] {
}
function setCheckedAndChildren(
node: TreeNode,
node: CommitTreeNode,
checked: boolean,
unstage: (args: { relaPaths: string[] }) => void,
add: (args: { relaPaths: string[] }) => void,
@@ -355,7 +355,7 @@ function setCheckedAndChildren(
const toAdd: string[] = [];
const toUnstage: string[] = [];
const next = (node: TreeNode) => {
const next = (node: CommitTreeNode) => {
for (const child of node.children) {
next(child);
}
@@ -375,7 +375,7 @@ function setCheckedAndChildren(
if (toUnstage.length > 0) unstage({ relaPaths: toUnstage });
}
function isNodeRelevant(node: TreeNode): boolean {
function isNodeRelevant(node: CommitTreeNode): boolean {
if (node.status.status !== 'current') {
return true;
}

View File

@@ -306,7 +306,7 @@ const GitMenuButton = forwardRef<HTMLButtonElement, HTMLAttributes<HTMLButtonEle
ref={ref}
className={classNames(
className,
'px-3 h-md border-t border-border flex items-center justify-between text-text-subtle',
'px-3 h-md border-t border-border flex items-center justify-between text-text-subtle outline-none focus-visible:bg-surface-highlight',
)}
{...props}
/>

View File

@@ -57,14 +57,18 @@ export function HeaderSize({
style={finalStyle}
className={classNames(
className,
'px-1', // Give it some space on either end
'pt-[1px]', // Make up for bottom border
'select-none relative',
'w-full border-b border-border-subtle min-w-0',
)}
>
{/* NOTE: This needs display:grid or else the element shrinks (even though scrollable) */}
<div className="pointer-events-none h-full w-full overflow-x-auto hide-scrollbars grid">
<div
className={classNames(
'pointer-events-none h-full w-full overflow-x-auto hide-scrollbars grid',
'px-1', // Give it some space on either end for focus outlines
)}
>
{children}
</div>
<WindowControls onlyX={onlyXWindowControl} />

View File

@@ -15,7 +15,7 @@ const details: Record<
commercial_use: null,
invalid_license: { label: 'License Error', color: 'danger' },
personal_use: { label: 'Personal Use', color: 'notice' },
trialing: { label: 'Trialing', color: 'info' },
trialing: { label: 'Commercial Trial', color: 'secondary' },
};
export function LicenseBadge() {

View File

@@ -1,497 +0,0 @@
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, { memo, 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, activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace';
import { allRequestsAtom } from '../hooks/useAllRequests';
import { getCreateDropdownItems } from '../hooks/useCreateDropdownItems';
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 SidebarModel = Workspace | Folder | HttpRequest | GrpcRequest | WebsocketRequest;
const OPACITY_SUBTLE = 'opacity-80';
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: SidebarModel[];
parent: SidebarModel;
children: SidebarModel[];
insertAt: number;
}) {
const prev = children[insertAt - 1] as Exclude<SidebarModel, Workspace>;
const next = children[insertAt] as Exclude<SidebarModel, 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: SidebarModel,
): ReturnType<NonNullable<TreeItemProps<SidebarModel>['getEditOptions']>> {
return {
onChange: handleSubmitEdit,
defaultValue: resolvedModelName(item),
placeholder: item.name,
};
}
async function handleSubmitEdit(item: SidebarModel, text: string) {
await patchModel(item, { name: text });
}
function handleActivate(item: SidebarModel) {
// TODO: Add folder layout support
if (item.model !== 'folder' && item.model !== 'workspace') {
navigateToRequestOrFolderOrWorkspace(item.id, item.model);
}
}
const allPotentialChildrenAtom = atom<SidebarModel[]>((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<SidebarModel, 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<SidebarModel>> = {};
if (activeWorkspace == null) {
return null;
}
// Put requests and folders into a tree structure
const next = (node: TreeNode<SidebarModel>, depth: number): TreeNode<SidebarModel> => {
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, depth }, depth + 1));
}
}
return node;
};
return next(
{
item: activeWorkspace,
children: [],
parent: null,
depth: 0,
},
1,
);
});
const actions = {
'sidebar.selected.delete': {
enable: isSidebarFocused,
cb: async function (_: TreeHandle, items: SidebarModel[]) {
await deleteModelWithConfirm(items);
},
},
'sidebar.selected.rename': {
enable: isSidebarFocused,
allowDefault: true,
cb: async function (tree: TreeHandle, items: SidebarModel[]) {
const item = items[0];
if (items.length === 1 && item != null) {
tree.renameItem(item.id);
}
},
},
'sidebar.selected.duplicate': {
priority: 999,
enable: isSidebarFocused,
cb: async function (_: TreeHandle, items: SidebarModel[]) {
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': {
enable: isSidebarFocused,
cb: async function (_: TreeHandle, items: SidebarModel[]) {
await Promise.all(
items.filter((i) => i.model === 'http_request').map((i) => sendAnyHttpRequest.mutate(i.id)),
);
},
},
} as const;
const hotkeys: TreeProps<SidebarModel>['hotkeys'] = { actions };
async function getContextMenu(tree: TreeHandle, items: SidebarModel[]): Promise<DropdownItem[]> {
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
const child = items[0];
// No children means we're in the root
if (child == null) {
return getCreateDropdownItems({ workspaceId, activeRequest: null, folderId: null });
}
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'].cb(tree, 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 modelCreationItems: DropdownItem[] =
items.length === 1 && child.model === 'folder'
? [
{ type: 'separator' },
...getCreateDropdownItems({ workspaceId, activeRequest: null, folderId: child.id }),
]
: [];
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,
hotKeyAction: 'sidebar.selected.rename',
hotKeyLabelOnly: true,
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['sidebar.selected.duplicate'].cb(tree, 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.selected.delete',
hotKeyLabelOnly: true,
leftSlot: <Icon icon="trash" />,
onSelect: () => actions['sidebar.selected.delete'].cb(tree, items),
},
...modelCreationItems,
];
return menuItems;
}
function getItemKey(item: SidebarModel) {
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('::');
}
const SidebarLeftSlot = memo(function SidebarLeftSlot({
treeId,
item,
}: {
treeId: string;
item: SidebarModel;
}) {
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 && OPACITY_SUBTLE)}
request={item}
/>
);
}
});
const SidebarInnerItem = memo(function SidebarInnerItem({
item,
}: {
treeId: string;
item: SidebarModel;
}) {
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>
);
});

View File

@@ -54,7 +54,6 @@ export function Overlay({
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>(
@@ -67,12 +66,11 @@ export function Overlay({
'[tabindex]:not([tabindex="-1"])',
'[contenteditable]:not([contenteditable="false"])',
].join(', '),
) ?? undefined,
) ?? false,
}}
>
<m.div
ref={containerRef}
tabIndex={-1}
className={classNames('fixed inset-0', zIndexes[zIndex])}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}

View File

@@ -51,7 +51,7 @@ export function RecentGrpcConnectionsDropdown({
>
<IconButton
title="Show connection history"
icon={activeConnection?.id === latestConnectionId ? 'chevron_down' : 'pin'}
icon={activeConnection?.id === latestConnectionId ? 'history' : 'pin'}
className="m-0.5 text-text-subtle"
size="sm"
iconSize="md"

View File

@@ -79,7 +79,7 @@ export const RecentHttpResponsesDropdown = function ResponsePane({
>
<IconButton
title="Show response history"
icon={activeResponse?.id === latestResponseId ? 'chevron_down' : 'pin'}
icon={activeResponse?.id === latestResponseId ? 'history' : 'pin'}
className="m-0.5 text-text-subtle"
size="sm"
iconSize="md"

View File

@@ -55,7 +55,7 @@ export function RecentWebsocketConnectionsDropdown({
>
<IconButton
title="Show connection history"
icon={activeConnection?.id === latestConnectionId ? 'chevron_down' : 'pin'}
icon={activeConnection?.id === latestConnectionId ? 'history' : 'pin'}
className="m-0.5 text-text-subtle"
size="sm"
iconSize="md"

View File

@@ -5,7 +5,7 @@ import { memo, useCallback, useMemo } from 'react';
import { showPrompt } from '../lib/prompt';
import { Button } from './core/Button';
import type { DropdownItem } from './core/Dropdown';
import { HttpMethodTag } from './core/HttpMethodTag';
import { HttpMethodTag, HttpMethodTagRaw } from './core/HttpMethodTag';
import { Icon } from './core/Icon';
import type { RadioDropdownItem } from './core/RadioDropdown';
import { RadioDropdown } from './core/RadioDropdown';
@@ -26,7 +26,7 @@ const radioItems: RadioDropdownItem<string>[] = [
'HEAD',
].map((m) => ({
value: m,
label: m,
label: <HttpMethodTagRaw method={m} />,
}));
export const RequestMethodDropdown = memo(function RequestMethodDropdown({

View File

@@ -1,12 +1,22 @@
import classNames from 'classnames';
import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react';
import React from 'react';
import React, { useCallback, useRef, useState } from 'react';
interface ResizeBarProps {
const START_DISTANCE = 7;
export interface ResizeHandleEvent {
x: number;
y: number;
xStart: number;
yStart: number;
}
interface Props {
style?: CSSProperties;
className?: string;
isResizing: boolean;
onResizeStart: (e: ReactMouseEvent<HTMLDivElement>) => void;
onResizeStart?: () => void;
onResizeEnd?: () => void;
onResizeMove?: (e: ResizeHandleEvent) => void;
onReset?: () => void;
side: 'left' | 'right' | 'top';
justify: 'center' | 'end' | 'start';
@@ -17,22 +27,70 @@ export function ResizeHandle({
justify,
className,
onResizeStart,
onResizeEnd,
onResizeMove,
onReset,
isResizing,
side,
}: ResizeBarProps) {
}: Props) {
const vertical = side === 'top';
const [isResizing, setIsResizing] = useState<boolean>(false);
const moveState = useRef<{
move: (e: MouseEvent) => void;
up: (e: MouseEvent) => void;
calledStart: boolean;
xStart: number;
yStart: number;
} | null>(null);
const handlePointerDown = useCallback(
(e: ReactMouseEvent<HTMLDivElement>) => {
function move(e: MouseEvent) {
if (moveState.current == null) return;
const xDistance = moveState.current.xStart - e.clientX;
const yDistance = moveState.current.yStart - e.clientY;
const distance = Math.abs(vertical ? yDistance : xDistance);
if (moveState.current.calledStart) {
onResizeMove?.({
x: e.clientX,
y: e.clientY,
xStart: moveState.current.xStart,
yStart: moveState.current.yStart,
});
} else if (distance > START_DISTANCE) {
onResizeStart?.();
moveState.current.calledStart = true;
setIsResizing(true);
}
}
function up() {
setIsResizing(false);
moveState.current = null;
document.documentElement.removeEventListener('mousemove', move);
document.documentElement.removeEventListener('mouseup', up);
onResizeEnd?.();
}
moveState.current = { calledStart: false, xStart: e.clientX, yStart: e.clientY, move, up };
document.documentElement.addEventListener('mousemove', move);
document.documentElement.addEventListener('mouseup', up);
},
[moveState, onResizeEnd, onResizeMove, onResizeStart, vertical],
);
return (
<div
aria-hidden
style={style}
onPointerDown={onResizeStart}
onDoubleClick={onReset}
onPointerDown={handlePointerDown}
className={classNames(
className,
'group z-10 flex select-none transition-colors hover:bg-surface-active rounded-full',
// 'bg-info', // For debugging
vertical ? 'w-full h-2 cursor-row-resize' : 'h-full w-2 cursor-col-resize',
vertical ? 'w-full h-1.5 cursor-row-resize' : 'h-full w-1.5 cursor-col-resize',
justify === 'center' && 'justify-center',
justify === 'end' && 'justify-end',
justify === 'start' && 'justify-start',
@@ -45,7 +103,8 @@ export function ResizeHandle({
{isResizing && (
<div
className={classNames(
'fixed -left-20 -right-20 -top-20 -bottom-20',
// 'bg-[rgba(255,0,0,0.1)]', // For debugging
'fixed -left-[100vw] -right-[100vw] -top-[100vh] -bottom-[100vh]',
vertical && 'cursor-row-resize',
!vertical && 'cursor-col-resize',
)}

View File

@@ -71,37 +71,25 @@ export function SettingsGeneral() {
disabled={!settings.autoupdate}
help="Automatically download Yaak updates (!50MB) in the background, so they will be immediately ready to install."
title="Automatically download updates"
onChange={(autoDownloadUpdates) =>
patchModel(settings, { autoDownloadUpdates })
}
onChange={(autoDownloadUpdates) => patchModel(settings, { autoDownloadUpdates })}
/>
<Separator className="my-4" />
</CargoFeature>
<Select
name="switchWorkspaceBehavior"
label="Workspace Window Behavior"
labelPosition="left"
labelClassName="w-[14rem]"
size="sm"
value={
settings.openWorkspaceNewWindow === true
? 'new'
: settings.openWorkspaceNewWindow === false
? 'current'
: 'ask'
}
onChange={async (v) => {
if (v === 'current') await patchModel(settings, { openWorkspaceNewWindow: false });
else if (v === 'new') await patchModel(settings, { openWorkspaceNewWindow: true });
else await patchModel(settings, { openWorkspaceNewWindow: null });
}}
options={[
{ label: 'Always ask', value: 'ask' },
{ label: 'Open in current window', value: 'current' },
{ label: 'Open in new window', value: 'new' },
]}
/>
<Checkbox
className="pl-2 mt-1 ml-[14rem]"
checked={settings.checkNotifications}
title="Check for notifications"
help="Periodically ping Yaak servers to check for relevant notifications."
onChange={(checkNotifications) => patchModel(settings, { checkNotifications })}
/>
<Checkbox
disabled
className="pl-2 mt-1 ml-[14rem]"
checked={false}
title="Send anonymous usage statistics"
help="Yaak is local-first and does not collect analytics or usage data 🔐"
onChange={(checkNotifications) => patchModel(settings, { checkNotifications })}
/>
</CargoFeature>
<Separator className="my-4" />
@@ -129,7 +117,7 @@ export function SettingsGeneral() {
<Checkbox
checked={workspace.settingValidateCertificates}
help="When disabled, skip validation of server certificates, useful when interacting with self-signed certs."
title="Validate TLS Certificates"
title="Validate TLS certificates"
onChange={(settingValidateCertificates) =>
patchModel(workspace, { settingValidateCertificates })
}
@@ -137,7 +125,7 @@ export function SettingsGeneral() {
<Checkbox
checked={workspace.settingFollowRedirects}
title="Follow Redirects"
title="Follow redirects"
onChange={(settingFollowRedirects) =>
patchModel(workspace, {
settingFollowRedirects,

View File

@@ -39,15 +39,38 @@ export function SettingsInterface() {
return (
<VStack space={3} className="mb-4">
<Select
name="switchWorkspaceBehavior"
label="Open workspace behavior"
size="sm"
help="When opening a workspace, should it open in the current window or a new window?"
value={
settings.openWorkspaceNewWindow === true
? 'new'
: settings.openWorkspaceNewWindow === false
? 'current'
: 'ask'
}
onChange={async (v) => {
if (v === 'current') await patchModel(settings, { openWorkspaceNewWindow: false });
else if (v === 'new') await patchModel(settings, { openWorkspaceNewWindow: true });
else await patchModel(settings, { openWorkspaceNewWindow: null });
}}
options={[
{ label: 'Always ask', value: 'ask' },
{ label: 'Open in current window', value: 'current' },
{ label: 'Open in new window', value: 'new' },
]}
/>
<HStack space={2} alignItems="end">
{fonts.data && (
<Select
size="sm"
name="uiFont"
label="Interface Font"
label="Interface font"
value={settings.interfaceFont ?? NULL_FONT_VALUE}
options={[
{ label: 'System Default', value: NULL_FONT_VALUE },
{ label: 'System default', value: NULL_FONT_VALUE },
...(fonts.data.uiFonts.map((f) => ({
label: f,
value: f,
@@ -69,7 +92,7 @@ export function SettingsInterface() {
size="sm"
name="interfaceFontSize"
label="Interface Font Size"
defaultValue="15"
defaultValue="14"
value={`${settings.interfaceFontSize}`}
options={fontSizeOptions}
onChange={(v) => patchModel(settings, { interfaceFontSize: parseInt(v) })}
@@ -80,10 +103,10 @@ export function SettingsInterface() {
<Select
size="sm"
name="editorFont"
label="Editor Font"
label="Editor font"
value={settings.editorFont ?? NULL_FONT_VALUE}
options={[
{ label: 'System Default', value: NULL_FONT_VALUE },
{ label: 'System default', value: NULL_FONT_VALUE },
...(fonts.data.editorFonts.map((f) => ({
label: f,
value: f,
@@ -100,7 +123,7 @@ export function SettingsInterface() {
size="sm"
name="editorFontSize"
label="Editor Font Size"
defaultValue="13"
defaultValue="12"
value={`${settings.editorFontSize}`}
options={fontSizeOptions}
onChange={(v) =>
@@ -112,19 +135,19 @@ export function SettingsInterface() {
leftSlot={<Icon icon="keyboard" color="secondary" />}
size="sm"
name="editorKeymap"
label="Editor Keymap"
label="Editor keymap"
value={`${settings.editorKeymap}`}
options={keymaps}
onChange={(v) => patchModel(settings, { editorKeymap: v })}
/>
<Checkbox
checked={settings.editorSoftWrap}
title="Wrap Editor Lines"
title="Wrap editor lines"
onChange={(editorSoftWrap) => patchModel(settings, { editorSoftWrap })}
/>
<Checkbox
checked={settings.coloredMethods}
title="Colorize Request Methods"
title="Colorize request methods"
onChange={(coloredMethods) => patchModel(settings, { coloredMethods })}
/>
<CargoFeature feature="license">
@@ -134,7 +157,7 @@ export function SettingsInterface() {
{type() !== 'macos' && (
<Checkbox
checked={settings.hideWindowControls}
title="Hide Window Controls"
title="Hide window controls"
help="Hide the close/maximize/minimize controls on Windows or Linux"
onChange={(hideWindowControls) => patchModel(settings, { hideWindowControls })}
/>

View File

@@ -56,8 +56,8 @@ function SettingsLicenseCmp() {
<h2 className="text-lg font-bold">Hey, I&apos;m Greg 👋🏼</h2>
<p>
Yaak is free for personal projects and learning.{' '}
{check.data?.type === 'trialing' ? 'After your trial, a ' : 'A '}
license is required for work or commercial use.
{check.data?.type === 'trialing' ? 'Once your trial ends, a ' : 'A '}
license will be required for work or commercial use.
</p>
<p>
<Link

View File

@@ -198,7 +198,7 @@ function PluginTableRow({
<Button
variant="border"
color="primary"
title={`Install ${latestVersion}`}
title={`Install ${version}`}
size="xs"
isLoading={installPluginMutation.isPending}
onClick={() => installPluginMutation.mutate(name)}

View File

@@ -38,9 +38,9 @@ export function SettingsProxy() {
}
}}
options={[
{ label: 'Automatic Proxy Detection', value: 'automatic' },
{ label: 'Custom Proxy Configuration', value: 'enabled' },
{ label: 'No Proxy', value: 'disabled' },
{ label: 'Automatic proxy detection', value: 'automatic' },
{ label: 'Custom proxy configuration', value: 'enabled' },
{ label: 'No proxy', value: 'disabled' },
]}
/>
{settings.proxy?.type === 'enabled' && (

View File

@@ -0,0 +1,739 @@
import type { Extension } from '@codemirror/state';
import { Compartment } from '@codemirror/state';
import { debounce } from '@yaakapp-internal/lib';
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, { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { useKey } from 'react-use';
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, activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace';
import { allRequestsAtom } from '../hooks/useAllRequests';
import { getCreateDropdownItems } from '../hooks/useCreateDropdownItems';
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 { 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 { Dropdown } from './core/Dropdown';
import type { FieldDef } from './core/Editor/filter/extension';
import { filter } from './core/Editor/filter/extension';
import { evaluate, parseQuery } from './core/Editor/filter/query';
import { HttpMethodTag } from './core/HttpMethodTag';
import { HttpStatusTag } from './core/HttpStatusTag';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { InlineCode } from './core/InlineCode';
import type { InputHandle } from './core/Input';
import { Input } from './core/Input';
import { LoadingIcon } from './core/LoadingIcon';
import { collapsedFamily, 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 SidebarModel = Workspace | Folder | HttpRequest | GrpcRequest | WebsocketRequest;
const OPACITY_SUBTLE = 'opacity-80';
function Sidebar({ className }: { className?: string }) {
const [hidden, setHidden] = useSidebarHidden();
const activeWorkspaceId = useAtomValue(activeWorkspaceAtom)?.id;
const treeId = 'tree.' + (activeWorkspaceId ?? 'unknown');
const filterText = useAtomValue(sidebarFilterAtom);
const [tree, allFields] = useAtomValue(sidebarTreeAtom) ?? [];
const wrapperRef = useRef<HTMLElement>(null);
const treeRef = useRef<TreeHandle>(null);
const filterRef = useRef<InputHandle>(null);
const allHidden = useMemo(() => {
if (tree?.children?.length === 0) return false;
else if (filterText) return tree?.children?.every((c) => c.hidden);
else return true;
}, [filterText, tree?.children]);
const focusActiveItem = useCallback(() => {
const didFocus = treeRef.current?.focus();
// If we weren't able to focus any items, focus the filter bar
if (!didFocus) filterRef.current?.focus();
}, []);
useHotKey(
'sidebar.filter',
() => {
filterRef.current?.focus();
},
{
enable: isSidebarFocused,
},
);
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
setTimeout(focusActiveItem, 100);
});
const handleDragEnd = useCallback(async function handleDragEnd({
items,
parent,
children,
insertAt,
}: {
items: SidebarModel[];
parent: SidebarModel;
children: SidebarModel[];
insertAt: number;
}) {
const prev = children[insertAt - 1] as Exclude<SidebarModel, Workspace>;
const next = children[insertAt] as Exclude<SidebarModel, 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);
}, []);
// Ensure active id is always selected when it changes
useEffect(() => {
return jotaiStore.sub(activeIdAtom, () => {
const activeId = jotaiStore.get(activeIdAtom);
if (activeId == null) return;
treeRef.current?.selectItem(activeId);
});
}, []);
const clearFilterText = useCallback(() => {
jotaiStore.set(sidebarFilterAtom, { text: '', key: `${Math.random()}` });
requestAnimationFrame(() => {
filterRef.current?.focus();
});
}, []);
const handleFilterKeyDown = useCallback(
(e: KeyboardEvent) => {
e.stopPropagation(); // Don't trigger tree navigation hotkeys
if (e.key === 'Escape') {
e.preventDefault();
clearFilterText();
}
},
[clearFilterText],
);
const handleFilterChange = useMemo(
() =>
debounce((text: string) => {
jotaiStore.set(sidebarFilterAtom, (prev) => ({ ...prev, text }));
}, 0),
[],
);
// Focus the first sidebar item on arrow down from filter
useKey('ArrowDown', (e) => {
if (e.key === 'ArrowDown' && filterRef.current?.isFocused()) {
e.preventDefault();
treeRef.current?.focus();
}
});
const actions = useMemo(() => {
const enable = () => treeRef.current?.hasFocus() ?? false;
const actions = {
'sidebar.context_menu': {
enable,
cb: () => treeRef.current?.showContextMenu(),
},
'sidebar.expand_all': {
enable: isSidebarFocused,
cb: () => {
jotaiStore.set(collapsedFamily(treeId), {});
},
},
'sidebar.collapse_all': {
enable: isSidebarFocused,
cb: () => {
if (tree == null) return;
const next = (node: TreeNode<SidebarModel>, collapsed: Record<string, boolean>) => {
for (const n of node.children ?? []) {
if (n.item.model !== 'folder') continue;
collapsed[n.item.id] = true;
}
return collapsed;
};
jotaiStore.set(collapsedFamily(treeId), next(tree, {}));
},
},
'sidebar.selected.delete': {
enable,
cb: async function (items: SidebarModel[]) {
await deleteModelWithConfirm(items);
},
},
'sidebar.selected.rename': {
enable,
allowDefault: true,
cb: async function (items: SidebarModel[]) {
const item = items[0];
if (items.length === 1 && item != null) {
treeRef.current?.renameItem(item.id);
}
},
},
'sidebar.selected.duplicate': {
priority: 10,
enable,
cb: async function (items: SidebarModel[]) {
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': {
enable,
cb: async function (items: SidebarModel[]) {
await Promise.all(
items
.filter((i) => i.model === 'http_request')
.map((i) => sendAnyHttpRequest.mutate(i.id)),
);
},
},
} as const;
return actions;
}, [tree, treeId]);
const getContextMenu = useCallback<(items: SidebarModel[]) => Promise<DropdownItem[]>>(
async (items) => {
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
const child = items[0];
// No children means we're in the root
if (child == null) {
return getCreateDropdownItems({ workspaceId, activeRequest: null, folderId: null });
}
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'].cb(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 modelCreationItems: DropdownItem[] =
items.length === 1 && child.model === 'folder'
? [
{ type: 'separator' },
...getCreateDropdownItems({ workspaceId, activeRequest: null, folderId: child.id }),
]
: [];
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,
hotKeyAction: 'sidebar.selected.rename',
hotKeyLabelOnly: true,
onSelect: () => {
treeRef.current?.renameItem(child.id);
},
},
{
label: 'Duplicate',
hotKeyAction: 'model.duplicate',
hotKeyLabelOnly: true, // Would trigger for every request (bad)
leftSlot: <Icon icon="copy" />,
onSelect: () => actions['sidebar.selected.duplicate'].cb(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.selected.delete',
hotKeyLabelOnly: true,
leftSlot: <Icon icon="trash" />,
onSelect: () => actions['sidebar.selected.delete'].cb(items),
},
...modelCreationItems,
];
return menuItems;
},
[actions],
);
const hotkeys = useMemo<TreeProps<SidebarModel>['hotkeys']>(() => ({ actions }), [actions]);
// Use a language compartment for the filter so we can reconfigure it when the autocompletion changes
const filterLanguageCompartmentRef = useRef(new Compartment());
const filterCompartmentMountExtRef = useRef<Extension | null>(null);
if (filterCompartmentMountExtRef.current == null) {
filterCompartmentMountExtRef.current = filterLanguageCompartmentRef.current.of(
filter({ fields: allFields ?? [] }),
);
}
useEffect(() => {
const view = filterRef.current;
if (!view) return;
const ext = filter({ fields: allFields ?? [] });
view.dispatch({ effects: filterLanguageCompartmentRef.current.reconfigure(ext) });
}, [allFields]);
if (tree == null || hidden) {
return null;
}
return (
<aside
ref={wrapperRef}
aria-hidden={hidden ?? undefined}
className={classNames(className, 'h-full grid grid-rows-[auto_minmax(0,1fr)_auto]')}
>
<div className="px-3 pt-3 grid grid-cols-[1fr_auto] items-center -mr-2.5">
{(tree.children?.length ?? 0) > 0 && (
<>
<Input
hideLabel
ref={filterRef}
size="sm"
label="filter"
language={null} // Explicitly disable
placeholder="Search"
onChange={handleFilterChange}
defaultValue={filterText.text}
forceUpdateKey={filterText.key}
onKeyDown={handleFilterKeyDown}
stateKey={null}
wrapLines={false}
extraExtensions={filterCompartmentMountExtRef.current ?? undefined}
rightSlot={
filterText.text && (
<IconButton
className="!h-auto min-h-full opacity-50 hover:opacity-100 -mr-1"
icon="x"
title="Clear filter"
onClick={clearFilterText}
/>
)
}
/>
<Dropdown
items={[
{
label: 'Expand All Folders',
leftSlot: <Icon icon="chevrons_up_down" />,
onSelect: actions['sidebar.expand_all'].cb,
hotKeyAction: 'sidebar.expand_all',
hotKeyLabelOnly: true,
},
{
label: 'Collapse All Folders',
leftSlot: <Icon icon="chevrons_down_up" />,
onSelect: actions['sidebar.collapse_all'].cb,
hotKeyAction: 'sidebar.collapse_all',
hotKeyLabelOnly: true,
},
]}
>
<IconButton
size="xs"
className="ml-0.5 text-text-subtle hover:text-text"
icon="ellipsis_vertical"
hotkeyAction="sidebar.collapse_all"
title="Show sidebar actions menu"
/>
</Dropdown>
</>
)}
</div>
{allHidden ? (
<div className="italic text-text-subtle p-3 text-sm text-center">
No results for <InlineCode>{filterText.text}</InlineCode>
</div>
) : (
<Tree
ref={handleTreeRefInit}
root={tree}
treeId={treeId}
hotkeys={hotkeys}
getItemKey={getItemKey}
ItemInner={SidebarInnerItem}
ItemLeftSlotInner={SidebarLeftSlot}
getContextMenu={getContextMenu}
onActivate={handleActivate}
getEditOptions={getEditOptions}
className="pl-2 pr-3 pt-2 pb-2"
onDragEnd={handleDragEnd}
/>
)}
<GitDropdown />
</aside>
);
}
export default Sidebar;
const activeIdAtom = atom<string | null>((get) => {
return get(activeRequestIdAtom) || get(activeFolderIdAtom);
});
function getEditOptions(
item: SidebarModel,
): ReturnType<NonNullable<TreeItemProps<SidebarModel>['getEditOptions']>> {
return {
onChange: handleSubmitEdit,
defaultValue: resolvedModelName(item),
placeholder: item.name,
};
}
async function handleSubmitEdit(item: SidebarModel, text: string) {
await patchModel(item, { name: text });
}
function handleActivate(item: SidebarModel) {
// TODO: Add folder layout support
if (item.model !== 'folder' && item.model !== 'workspace') {
navigateToRequestOrFolderOrWorkspace(item.id, item.model);
}
}
const allPotentialChildrenAtom = atom<SidebarModel[]>((get) => {
const requests = get(allRequestsAtom);
const folders = get(foldersAtom);
return [...requests, ...folders];
});
const memoAllPotentialChildrenAtom = deepEqualAtom(allPotentialChildrenAtom);
const sidebarFilterAtom = atom<{ text: string; key: string }>({ text: '', key: '' });
const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get) => {
const allModels = get(memoAllPotentialChildrenAtom);
const activeWorkspace = get(activeWorkspaceAtom);
const filter = get(sidebarFilterAtom);
const childrenMap: Record<string, Exclude<SidebarModel, 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);
}
}
if (activeWorkspace == null) {
return null;
}
const queryAst = parseQuery(filter.text);
// returns true if this node OR any child matches the filter
const allFields: Record<string, Set<string>> = {};
const build = (node: TreeNode<SidebarModel>, depth: number): boolean => {
const childItems = childrenMap[node.item.id] ?? [];
let matchesSelf = true;
const fields = getItemFields(node.item);
for (const [field, value] of Object.entries(fields)) {
if (!value) continue;
allFields[field] = allFields[field] ?? new Set();
allFields[field].add(value);
}
if (queryAst != null) {
matchesSelf = evaluate(queryAst, { text: getItemText(node.item), fields });
}
let matchesChild = false;
// Recurse to children
const m = node.item.model;
node.children = m === 'folder' || m === 'workspace' ? [] : undefined;
if (node.children != null) {
childItems.sort((a, b) => {
if (a.sortPriority === b.sortPriority) {
return a.updatedAt > b.updatedAt ? 1 : -1;
}
return a.sortPriority - b.sortPriority;
});
for (const item of childItems) {
const childNode = { item, parent: node, depth };
const childMatches = build(childNode, depth + 1);
if (childMatches) {
matchesChild = true;
}
node.children.push(childNode);
}
}
// hide node IFF nothing in its subtree matches
const anyMatch = matchesSelf || matchesChild;
node.hidden = !anyMatch;
return anyMatch;
};
const root: TreeNode<SidebarModel> = {
item: activeWorkspace,
parent: null,
children: [],
depth: 0,
};
// Build tree and mark visibility in one pass
build(root, 1);
const fields: FieldDef[] = [];
for (const [name, values] of Object.entries(allFields)) {
fields.push({ name, values: Array.from(values).filter((v) => v.length < 20) });
}
return [root, fields] as const;
});
function getItemKey(item: SidebarModel) {
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';
const service = 'service' in item ? item.service : 'n/a';
return [
item.id,
item.name,
url,
method,
service,
latestResponse?.elapsed,
latestResponse?.id ?? 'n/a',
].join('::');
}
const SidebarLeftSlot = memo(function SidebarLeftSlot({
treeId,
item,
}: {
treeId: string;
item: SidebarModel;
}) {
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 pl-1.5', !isSelected && OPACITY_SUBTLE)}
request={item}
/>
);
}
});
const SidebarInnerItem = memo(function SidebarInnerItem({
item,
}: {
treeId: string;
item: SidebarModel;
}) {
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 getItemFields(item: SidebarModel): Record<string, string> {
if (item.model === 'workspace') return {};
const fields: Record<string, string> = {};
if (item.model === 'http_request') {
fields.method = item.method.toUpperCase();
}
if (item.model === 'grpc_request') {
fields.grpc_method = item.method ?? '';
fields.grpc_service = item.service ?? '';
}
if ('url' in item) fields.url = item.url;
fields.name = resolvedModelName(item);
fields.type = 'http';
if (item.model === 'grpc_request') fields.type = 'grpc';
else if (item.model === 'websocket_request') fields.type = 'ws';
return fields;
}
function getItemText(item: SidebarModel): string {
return resolvedModelName(item);
}

View File

@@ -1,4 +1,3 @@
import type { EditorView } from '@codemirror/view';
import type { HttpRequest } from '@yaakapp-internal/models';
import classNames from 'classnames';
import type { FormEvent, ReactNode } from 'react';
@@ -6,7 +5,7 @@ import { memo, useRef, useState } from 'react';
import { useHotKey } from '../hooks/useHotKey';
import type { IconProps } from './core/Icon';
import { IconButton } from './core/IconButton';
import type { InputProps } from './core/Input';
import type { InputHandle, InputProps } from './core/Input';
import { Input } from './core/Input';
import { HStack } from './core/Stacks';
@@ -44,15 +43,11 @@ export const UrlBar = memo(function UrlBar({
isLoading,
stateKey,
}: Props) {
const inputRef = useRef<EditorView>(null);
const inputRef = useRef<InputHandle>(null);
const [isFocused, setIsFocused] = useState<boolean>(false);
useHotKey('url_bar.focus', () => {
const head = inputRef.current?.state.doc.length ?? 0;
inputRef.current?.dispatch({
selection: { anchor: 0, head },
});
inputRef.current?.focus();
inputRef.current?.selectAll();
});
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {

View File

@@ -2,7 +2,7 @@ import { workspacesAtom } from '@yaakapp-internal/models';
import classNames from 'classnames';
import { useAtomValue } from 'jotai';
import * as m from 'motion/react-m';
import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react';
import type { CSSProperties } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import {
useEnsureActiveCookieJar,
@@ -27,7 +27,6 @@ import { useShouldFloatSidebar } from '../hooks/useShouldFloatSidebar';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { useSidebarWidth } from '../hooks/useSidebarWidth';
import { useSyncWorkspaceRequestTitle } from '../hooks/useSyncWorkspaceRequestTitle';
import { useToggleCommandPalette } from '../hooks/useToggleCommandPalette';
import { duplicateRequestOrFolderAndNavigate } from '../lib/duplicateRequestOrFolderAndNavigate';
import { importData } from '../lib/importData';
import { jotaiStore } from '../lib/jotai';
@@ -42,9 +41,10 @@ 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 type { ResizeHandleEvent } from './ResizeHandle';
import { ResizeHandle } from './ResizeHandle';
import Sidebar from './Sidebar';
import { SidebarActions } from './SidebarActions';
import { WebsocketRequestLayout } from './WebsocketRequestLayout';
import { WorkspaceHeader } from './WorkspaceHeader';
@@ -59,55 +59,40 @@ export function Workspace() {
useGlobalWorkspaceHooks();
const workspaces = useAtomValue(workspacesAtom);
const { setWidth, width, resetWidth } = useSidebarWidth();
const [width, setWidth, resetWidth] = useSidebarWidth();
const [sidebarHidden, setSidebarHidden] = useSidebarHidden();
const [floatingSidebarHidden, setFloatingSidebarHidden] = useFloatingSidebarHidden();
const activeEnvironment = useAtomValue(activeEnvironmentAtom);
const floating = useShouldFloatSidebar();
const [isResizing, setIsResizing] = useState<boolean>(false);
const moveState = useRef<{ move: (e: MouseEvent) => void; up: (e: MouseEvent) => void } | null>(
null,
);
const startWidth = useRef<number | null>(null);
const unsub = () => {
if (moveState.current !== null) {
document.documentElement.removeEventListener('mousemove', moveState.current.move);
document.documentElement.removeEventListener('mouseup', moveState.current.up);
}
};
const handleResizeMove = useCallback(
async ({ x, xStart }: ResizeHandleEvent) => {
if (width == null || startWidth.current == null) return;
const handleResizeStart = useCallback(
(e: ReactMouseEvent<HTMLDivElement>) => {
if (width === undefined) return;
unsub();
const mouseStartX = e.clientX;
const startWidth = width;
moveState.current = {
move: async (e: MouseEvent) => {
e.preventDefault(); // Prevent text selection and things
const newWidth = startWidth + (e.clientX - mouseStartX);
if (newWidth < 50) {
await setSidebarHidden(true);
resetWidth();
} else {
await setSidebarHidden(false);
setWidth(newWidth);
}
},
up: (e: MouseEvent) => {
e.preventDefault();
unsub();
setIsResizing(false);
},
};
document.documentElement.addEventListener('mousemove', moveState.current.move);
document.documentElement.addEventListener('mouseup', moveState.current.up);
setIsResizing(true);
const newWidth = startWidth.current + (x - xStart);
if (newWidth < 50) {
await setSidebarHidden(true);
resetWidth();
} else {
await setSidebarHidden(false);
setWidth(newWidth);
}
},
[width, setSidebarHidden, resetWidth, setWidth],
);
const handleResizeStart = useCallback(() => {
startWidth.current = width ?? null;
setIsResizing(true);
}, [width]);
const handleResizeEnd = useCallback(() => {
setIsResizing(false);
startWidth.current = null;
}, []);
const sideWidth = sidebarHidden ? 0 : width;
const styles = useMemo<CSSProperties>(
() => ({
@@ -164,7 +149,7 @@ export function Workspace() {
<SidebarActions />
</HeaderSize>
<ErrorBoundary name="Sidebar (Floating)">
<NewSidebar />
<Sidebar />
</ErrorBoundary>
</m.div>
</Overlay>
@@ -172,15 +157,17 @@ export function Workspace() {
<>
<div style={side} className={classNames('x-theme-sidebar', 'overflow-hidden bg-surface')}>
<ErrorBoundary name="Sidebar">
<NewSidebar className="border-r border-border-subtle" />
<Sidebar className="border-r border-border-subtle" />
</ErrorBoundary>
</div>
<ResizeHandle
className="-translate-x-[50%]"
style={drag}
className="-translate-x-[1px]"
justify="end"
side="right"
isResizing={isResizing}
onResizeStart={handleResizeStart}
onResizeEnd={handleResizeEnd}
onResizeMove={handleResizeMove}
onReset={resetWidth}
/>
</>
@@ -276,9 +263,6 @@ function useGlobalWorkspaceHooks() {
useSyncWorkspaceRequestTitle();
const toggleCommandPalette = useToggleCommandPalette();
useHotKey('command_palette.toggle', toggleCommandPalette);
useHotKey('model.duplicate', () =>
duplicateRequestOrFolderAndNavigate(jotaiStore.get(activeRequestAtom)),
);

View File

@@ -1,4 +1,3 @@
import { type } from '@tauri-apps/plugin-os';
import classNames from 'classnames';
import { useAtom, useAtomValue } from 'jotai';
import React, { memo } from 'react';
@@ -38,7 +37,7 @@ export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Prop
'grid grid-cols-[auto_minmax(0,1fr)_auto] items-center w-full h-full',
)}
>
<HStack space={0.5} className="flex-1 pointer-events-none">
<HStack space={0.5} className={classNames("flex-1 pointer-events-none")}>
<SidebarActions />
<CookieDropdown />
<HStack className="min-w-0">
@@ -75,9 +74,10 @@ export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Prop
}
/>
<IconButton
icon={type() == 'macos' ? 'command' : 'square_terminal'}
icon="search"
title="Search or execute a command"
size="sm"
hotkeyAction="command_palette.toggle"
iconColor="secondary"
onClick={togglePalette}
/>

View File

@@ -21,6 +21,8 @@ export type ButtonProps = Omit<HTMLAttributes<HTMLButtonElement>, 'color' | 'onC
leftSlot?: ReactNode;
rightSlot?: ReactNode;
hotkeyAction?: HotkeyAction;
hotkeyLabelOnly?: boolean;
hotkeyPriority?: number;
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
@@ -39,6 +41,8 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
rightSlot,
disabled,
hotkeyAction,
hotkeyPriority,
hotkeyLabelOnly,
title,
onClick,
...props
@@ -63,7 +67,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
'hocus:opacity-100', // Force opacity for certain hover effects
'whitespace-nowrap outline-none',
'flex-shrink-0 flex items-center',
'focus-visible-or-class:ring',
'outline-0',
disabled ? 'pointer-events-none opacity-disabled' : 'pointer-events-auto',
justify === 'start' && 'justify-start',
justify === 'center' && 'justify-center',
@@ -74,10 +78,10 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
// Solids
variant === 'solid' && 'border-transparent',
variant === 'solid' && color === 'custom' && 'ring-border-focus',
variant === 'solid' && color === 'custom' && 'focus-visible:outline-2 outline-border-focus',
variant === 'solid' &&
color !== 'custom' &&
'enabled:hocus:text-text enabled:hocus:bg-surface-highlight ring-border-subtle',
'enabled:hocus:text-text enabled:hocus:bg-surface-highlight outline-border-subtle',
variant === 'solid' && color !== 'custom' && color !== 'default' && 'bg-surface',
// Borders
@@ -85,7 +89,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
variant === 'border' &&
color !== 'custom' &&
'border-border-subtle text-text-subtle enabled:hocus:border-border ' +
'enabled:hocus:bg-surface-highlight enabled:hocus:text-text ring-border-subtler',
'enabled:hocus:bg-surface-highlight enabled:hocus:text-text outline-border-subtler',
);
const buttonRef = useRef<HTMLButtonElement>(null);
@@ -94,9 +98,13 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
() => buttonRef.current,
);
useHotKey(hotkeyAction ?? null, () => {
buttonRef.current?.click();
});
useHotKey(
hotkeyAction ?? null,
() => {
buttonRef.current?.click();
},
{ priority: hotkeyPriority, enable: !hotkeyLabelOnly },
);
return (
<button

View File

@@ -1,16 +1,20 @@
import classNames from 'classnames';
import { useState } from 'react';
import { HexColorPicker } from 'react-colorful';
import { useRandomKey } from '../../hooks/useRandomKey';
import { Icon } from './Icon';
import { PlainInput } from './PlainInput';
interface Props {
onChange: (value: string | null) => void;
color: string | null;
className?: string;
}
export function ColorPicker({ onChange, color }: Props) {
export function ColorPicker({ onChange, color, className }: Props) {
const [updateKey, regenerateKey] = useRandomKey();
return (
<div>
<div className={className}>
<HexColorPicker
color={color ?? undefined}
className="!w-full"
@@ -30,3 +34,84 @@ export function ColorPicker({ onChange, color }: Props) {
</div>
);
}
const colors = [
null,
'danger',
'warning',
'notice',
'success',
'primary',
'info',
'secondary',
'custom',
] as const;
export function ColorPickerWithThemeColors({ onChange, color, className }: Props) {
const [updateKey, regenerateKey] = useRandomKey();
const [selectedColor, setSelectedColor] = useState<string | null>(() => {
if (color == null) return null;
const c = color?.match(/var\(--([a-z]+)\)/)?.[1];
return c ?? 'custom';
});
return (
<div className={classNames(className, 'flex flex-col gap-3')}>
<div className="flex items-center gap-2.5">
{colors.map((color) => (
<button
type="button"
key={color}
onClick={() => {
setSelectedColor(color);
if (color == null) {
onChange(null);
} else if (color === 'custom') {
onChange('#ffffff');
} else {
onChange(`var(--${color})`);
}
}}
className={classNames(
'flex items-center justify-center',
'w-8 h-8 rounded-full transition-all',
selectedColor === color && 'scale-[1.15]',
selectedColor === color ? 'opacity-100' : 'opacity-60',
color === null && 'border border-text-subtle',
color === 'primary' && 'bg-primary',
color === 'secondary' && 'bg-secondary',
color === 'success' && 'bg-success',
color === 'notice' && 'bg-notice',
color === 'warning' && 'bg-warning',
color === 'danger' && 'bg-danger',
color === 'info' && 'bg-info',
color === 'custom' &&
'bg-[conic-gradient(var(--danger),var(--warning),var(--notice),var(--success),var(--info),var(--primary),var(--danger))]',
)}
>
{color == null && <Icon icon="minus" className="text-text-subtle" size="md" />}
</button>
))}
</div>
{selectedColor === 'custom' && (
<>
<HexColorPicker
color={color ?? undefined}
className="!w-full"
onChange={(color) => {
onChange(color);
regenerateKey(); // To force input to change
}}
/>
<PlainInput
hideLabel
label="Plain Color"
forceUpdateKey={updateKey}
defaultValue={color ?? ''}
onChange={onChange}
validate={(color) => color.match(/#[0-9a-fA-F]{6}$/) !== null}
/>
</>
)}
</div>
);
}

View File

@@ -1,6 +1,7 @@
import type { Color } from '@yaakapp-internal/plugins';
import type { FormEvent } from 'react';
import { useState } from 'react';
import { CopyIconButton } from '../CopyIconButton';
import { Button } from './Button';
import { PlainInput } from './PlainInput';
import { HStack } from './Stacks';
@@ -42,9 +43,19 @@ export function Confirm({
<PlainInput
autoFocus
onChange={setConfirm}
placeholder={requireTyping}
labelRightSlot={
<CopyIconButton
text={requireTyping}
title="Copy name"
className="text-text-subtlest"
iconSize="sm"
size="2xs"
/>
}
label={
<>
Type <strong className="!select-auto cursor-auto">{requireTyping}</strong> to confirm
Type <strong>{requireTyping}</strong> to confirm
</>
}
/>

View File

@@ -35,7 +35,7 @@ export function DismissibleBanner({
color={a.color ?? props.color}
size="xs"
onClick={a.onClick}
title="Dismiss message"
title={a.label}
>
{a.label}
</Button>

View File

@@ -205,18 +205,8 @@
@apply bg-transparent;
}
.cm-wrapper:not(.cm-readonly) .cm-editor {
&.cm-focused .cm-activeLineGutter {
@apply text-text-subtle;
}
}
/* Cursor and mouse cursor for readonly mode */
.cm-wrapper.cm-readonly {
.cm-editor .cm-cursor {
@apply hidden !important;
}
&.cm-singleline * {
@apply cursor-default;
}

View File

@@ -17,12 +17,12 @@ import { useAtomValue } from 'jotai';
import { md5 } from 'js-md5';
import type { ReactNode, RefObject } from 'react';
import {
useEffect,
Children,
cloneElement,
forwardRef,
isValidElement,
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
@@ -75,14 +75,14 @@ export interface EditorProps {
defaultValue?: string | null;
disableTabIndent?: boolean;
disabled?: boolean;
extraExtensions?: Extension[];
extraExtensions?: Extension[] | Extension;
forcedEnvironmentId?: string;
forceUpdateKey?: string | number;
format?: (v: string) => Promise<string>;
heightMode?: 'auto' | 'full';
hideGutter?: boolean;
id?: string;
language?: EditorLanguage | 'pairs' | 'url';
language?: EditorLanguage | 'pairs' | 'url' | null;
graphQLSchema?: GraphQLSchema | null;
onBlur?: () => void;
onChange?: (value: string) => void;
@@ -439,7 +439,11 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
onBlur: handleBlur,
onKeyDown: handleKeyDown,
}),
...(extraExtensions ?? []),
...(Array.isArray(extraExtensions)
? extraExtensions
: extraExtensions
? [extraExtensions]
: []),
];
const cachedJsonState = getCachedEditorState(defaultValue ?? '', stateKey);
@@ -470,9 +474,17 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[forceUpdateKey],
[],
);
// Update editor doc when force update key changes
useEffect(() => {
if (cm.current?.view != null) {
updateContents(cm.current.view, defaultValue || '');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [forceUpdateKey]);
// For read-only mode, update content when `defaultValue` changes
useEffect(
function updateReadOnlyEditor() {

View File

@@ -10,7 +10,8 @@ import { json } from '@codemirror/lang-json';
import { markdown } from '@codemirror/lang-markdown';
import { xml } from '@codemirror/lang-xml';
import type { LanguageSupport } from '@codemirror/language';
import { bracketMatching ,
import {
bracketMatching,
codeFolding,
foldGutter,
foldKeymap,
@@ -152,8 +153,11 @@ export function getLanguageExtension({
];
}
const base_ = syntaxExtensions[language ?? 'text'] ?? text();
const base = typeof base_ === 'function' ? base_() : text();
const maybeBase = language ? syntaxExtensions[language] : null;
const base = typeof maybeBase === 'function' ? maybeBase() : null;
if (base == null) {
return [];
}
if (!useTemplating) {
return [base, extraExtensions];

View File

@@ -0,0 +1,182 @@
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import { autocompletion, startCompletion } from '@codemirror/autocomplete';
import { LanguageSupport, LRLanguage, syntaxTree } from '@codemirror/language';
import { parser } from './filter';
export interface FieldDef {
name: string;
// Optional static or dynamic value suggestions for this field
values?: string[] | (() => string[]);
info?: string;
}
export interface FilterOptions {
fields: FieldDef[] | null; // e.g., ['method','status','path'] or [{name:'tag', values:()=>cachedTags}]
}
const IDENT = /[A-Za-z0-9_/]+$/;
const IDENT_ONLY = /^[A-Za-z0-9_/]+$/;
function normalizeFields(fields: FieldDef[]): {
fieldNames: string[];
fieldMap: Record<string, { values?: string[] | (() => string[]); info?: string }>;
} {
const fieldNames: string[] = [];
const fieldMap: Record<string, { values?: string[] | (() => string[]); info?: string }> = {};
for (const f of fields) {
fieldNames.push(f.name);
fieldMap[f.name] = { values: f.values, info: f.info };
}
return { fieldNames, fieldMap };
}
function wordBefore(doc: string, pos: number): { from: number; to: number; text: string } | null {
const upto = doc.slice(0, pos);
const m = upto.match(IDENT);
if (!m) return null;
const from = pos - m[0].length;
return { from, to: pos, text: m[0] };
}
function inPhrase(ctx: CompletionContext): boolean {
// Lezer node names from your grammar: Phrase is the quoted token
let n = syntaxTree(ctx.state).resolveInner(ctx.pos, -1);
for (; n; n = n.parent!) {
if (n.name === 'Phrase') return true;
}
return false;
}
// While typing an incomplete quote, there's no Phrase token yet.
function inUnclosedQuote(doc: string, pos: number): boolean {
let quotes = 0;
for (let i = 0; i < pos; i++) {
if (doc[i] === '"' && doc[i - 1] !== '\\') quotes++;
}
return quotes % 2 === 1; // odd = inside an open quote
}
/**
* Heuristic context detector (works without relying on exact node names):
* - If there's a ':' after the last whitespace and before the cursor, we're in a field value.
* - Otherwise, we're in a field name or bare term position.
*/
function contextInfo(stateDoc: string, pos: number) {
const lastColon = stateDoc.lastIndexOf(':', pos - 1);
const lastBoundary = Math.max(
stateDoc.lastIndexOf(' ', pos - 1),
stateDoc.lastIndexOf('\t', pos - 1),
stateDoc.lastIndexOf('\n', pos - 1),
stateDoc.lastIndexOf('(', pos - 1),
stateDoc.lastIndexOf(')', pos - 1),
);
const inValue = lastColon > lastBoundary;
let fieldName: string | null = null;
let emptyAfterColon = false;
if (inValue) {
// word before the colon = field name
const beforeColon = stateDoc.slice(0, lastColon);
const m = beforeColon.match(IDENT);
fieldName = m ? m[0] : null;
// nothing (or only spaces) typed after the colon?
const after = stateDoc.slice(lastColon + 1, pos);
emptyAfterColon = after.length === 0 || /^\s+$/.test(after);
}
return { inValue, fieldName, lastColon, emptyAfterColon };
}
/** Build a completion list for field names */
function fieldNameCompletions(fieldNames: string[]): Completion[] {
return fieldNames.map((name) => ({
label: name,
type: 'property',
apply: (view, _completion, from, to) => {
// Insert "name:" (leave cursor right after colon)
view.dispatch({
changes: { from, to, insert: `${name}:` },
selection: { anchor: from + name.length + 1 },
});
startCompletion(view);
},
}));
}
/** Build a completion list for field values (if provided) */
function fieldValueCompletions(
def: { values?: string[] | (() => string[]); info?: string } | undefined,
): Completion[] | null {
if (!def || !def.values) return null;
const vals = Array.isArray(def.values) ? def.values : def.values();
// console.log("HELLO", v, v.match(IDENT));
return vals.map((v) => ({
label: v.match(IDENT_ONLY) ? v : `"${v}"`,
displayLabel: v,
type: 'constant',
}));
}
/** The main completion source */
function makeCompletionSource(opts: FilterOptions) {
const { fieldNames, fieldMap } = normalizeFields(opts.fields ?? []);
return (ctx: CompletionContext): CompletionResult | null => {
const { state, pos } = ctx;
const doc = state.doc.toString();
if (inPhrase(ctx) || inUnclosedQuote(doc, pos)) {
return null;
}
const w = wordBefore(doc, pos);
const from = w?.from ?? pos;
const to = pos;
const { inValue, fieldName, emptyAfterColon } = contextInfo(doc, pos);
// In field value position
if (inValue && fieldName) {
const valDefs = fieldMap[fieldName];
const vals = fieldValueCompletions(valDefs);
// If user hasn't typed a value char yet:
// - Show value suggestions if available
// - Otherwise show nothing (no fallback to field names)
if (emptyAfterColon) {
if (vals?.length) {
return { from, to, options: vals, filter: true };
}
return null; // <-- key change: do not suggest fields here
}
// User started typing a value; filter value suggestions (if any)
if (vals?.length) {
return { from, to, options: vals, filter: true };
}
// No specific values: also show nothing (keeps UI quiet)
return null;
}
// Not in a value: suggest field names (and maybe boolean ops)
const options: Completion[] = fieldNameCompletions(fieldNames);
return { from, to, options, filter: true };
};
}
const language = LRLanguage.define({
name: 'filter',
parser,
languageData: {
autocompletion: {},
},
});
/** Public extension */
export function filter(options: FilterOptions) {
const source = makeCompletionSource(options);
return new LanguageSupport(language, [autocompletion({ override: [source] })]);
}

View File

@@ -0,0 +1,76 @@
@top Query { Expr }
@skip { space+ }
@tokens {
space { std.whitespace+ }
LParen { "(" }
RParen { ")" }
Colon { ":" }
Not { "-" | "NOT" }
// Keywords (case-insensitive)
And { "AND" }
Or { "OR" }
// "quoted phrase" with simple escapes: \" and \\
Phrase { '"' (!["\\] | "\\" _)* '"' }
// field/word characters (keep generous for URLs/paths)
Word { $[A-Za-z0-9_]+ }
@precedence { Not, And, Or, Word }
}
@detectDelim
// Precedence: NOT (highest) > AND > OR (lowest)
// We also allow implicit AND in your parser/evaluator, but for highlighting,
// this grammar parses explicit AND/OR/NOT + adjacency as a sequence (Seq).
Expr {
OrExpr
}
OrExpr {
AndExpr (Or AndExpr)*
}
AndExpr {
Unary (And Unary | Unary)* // allow implicit AND by adjacency: Unary Unary
}
Unary {
Not Unary
| Primary
}
Primary {
Group
| Field
| Phrase
| Term
}
Group {
LParen Expr RParen
}
Field {
FieldName Colon FieldValue
}
FieldName {
Word
}
FieldValue {
Phrase
| Term
| Group
}
Term {
Word
}
@external propSource highlight from "./highlight"

View File

@@ -0,0 +1,23 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
import {LRParser} from "@lezer/lr"
import {highlight} from "./highlight"
export const parser = LRParser.deserialize({
version: 14,
states: "%QOVQPOOPeOPOOOVQPO'#CfOjQPO'#ChO!XQPO'#CgOOQO'#Cc'#CcOVQPO'#CaOOQO'#Ca'#CaO!oQPO'#C`O!|QPO'#C_OOQO'#C^'#C^QOQPOOPOOO'#Cp'#CpP#XOPO)C>jO#`QPO,59QO#eQPO,59ROOQO,58{,58{OVQPO'#CqOOQO'#Cq'#CqO#pQPO,58zOVQPO'#CrO#}QPO,58yPOOO-E6n-E6nOOQO1G.l1G.lOOQO'#Cm'#CmOOQO'#Ck'#CkOOQO1G.m1G.mOOQO,59],59]OOQO-E6o-E6oOOQO,59^,59^OOQO-E6p-E6p",
stateData: "$`~OiPQ~OUUOXQO]RO`TO~Oi[O~OUaXXaX]aX^[X`aXbaXcaXgaXWaX~O^_O~OUUOXQO]RO`TObaO~OcSXgSXWSX~P!^OcdOgRXWRX~Oi[O~Qh]WgO~OXQO]hO`iO~OcSagSaWSa~P!^OcdOgRaWRa~OUbc]c~",
goto: "#hgPPhnryP!YPP!c!o!xPP#RP!cPP#U#[#bQZOR^QTYOQSXOQRmdUWOQdQ`USbWcRka_VOQUWacd^TOQUWacdRi__TOQUWacd_SOQUWacdRj_Q]PRf]QcWRlcQeXRne",
nodeNames: "⚠ Query Expr OrExpr AndExpr Unary Not Primary RParen LParen Group Field FieldName Word Colon FieldValue Phrase Term And Or",
maxTerm: 25,
nodeProps: [
["openedBy", 8,"LParen"],
["closedBy", 9,"RParen"]
],
propSources: [highlight],
skippedNodes: [0,20],
repeatNodeCount: 3,
tokenData: ")f~RgX^!jpq!jrs#_xy${yz%Q}!O%V!Q![%[![!]%m!c!d%r!d!p%[!p!q'V!q!r(j!r!}%[#R#S%[#T#o%[#y#z!j$f$g!j#BY#BZ!j$IS$I_!j$I|$JO!j$JT$JU!j$KV$KW!j&FU&FV!j~!oYi~X^!jpq!j#y#z!j$f$g!j#BY#BZ!j$IS$I_!j$I|$JO!j$JT$JU!j$KV$KW!j&FU&FV!j~#bVOr#_rs#ws#O#_#O#P#|#P;'S#_;'S;=`$u<%lO#_~#|O`~~$PRO;'S#_;'S;=`$Y;=`O#_~$]WOr#_rs#ws#O#_#O#P#|#P;'S#_;'S;=`$u;=`<%l#_<%lO#_~$xP;=`<%l#_~%QOX~~%VOW~~%[OU~~%aS]~!Q![%[!c!}%[#R#S%[#T#o%[~%rO^~~%wU]~!Q![%[!c!p%[!p!q&Z!q!}%[#R#S%[#T#o%[~&`U]~!Q![%[!c!f%[!f!g&r!g!}%[#R#S%[#T#o%[~&ySb~]~!Q![%[!c!}%[#R#S%[#T#o%[~'[U]~!Q![%[!c!q%[!q!r'n!r!}%[#R#S%[#T#o%[~'sU]~!Q![%[!c!v%[!v!w(V!w!}%[#R#S%[#T#o%[~(^SU~]~!Q![%[!c!}%[#R#S%[#T#o%[~(oU]~!Q![%[!c!t%[!t!u)R!u!}%[#R#S%[#T#o%[~)YSc~]~!Q![%[!c!}%[#R#S%[#T#o%[",
tokenizers: [0],
topRules: {"Query":[0,1]},
tokenPrec: 148
})

View File

@@ -0,0 +1,24 @@
import { styleTags, tags as t } from '@lezer/highlight';
export const highlight = styleTags({
// Boolean operators
And: t.operatorKeyword,
Or: t.operatorKeyword,
Not: t.operatorKeyword,
// Structural punctuation
LParen: t.paren,
RParen: t.paren,
Colon: t.punctuation,
Minus: t.operator,
// Literals
Phrase: t.string, // "quoted string"
Term: t.variableName, // bare terms like foo, bar
// Fields
'FieldName/Word': t.tagName,
// Grouping
Group: t.paren,
});

View File

@@ -0,0 +1,298 @@
// query.ts
// A tiny query language parser with NOT/AND/OR, parentheses, phrases, negation, and field:value.
import { fuzzyMatch } from 'fuzzbunny';
/////////////////////////
// AST
/////////////////////////
export type Ast =
| { type: 'Term'; value: string } // foo
| { type: 'Phrase'; value: string } // "hi there"
| { type: 'Field'; field: string; value: string } // method:POST or title:"exact phrase"
| { type: 'Not'; node: Ast } // -foo or NOT foo
| { type: 'And'; left: Ast; right: Ast } // a AND b
| { type: 'Or'; left: Ast; right: Ast }; // a OR b
/////////////////////////
// Tokenizer
/////////////////////////
type Tok =
| { kind: 'LPAREN' }
| { kind: 'RPAREN' }
| { kind: 'AND' }
| { kind: 'OR' }
| { kind: 'NOT' } // explicit NOT
| { kind: 'MINUS' } // unary minus before term/phrase/paren group
| { kind: 'COLON' }
| { kind: 'WORD'; text: string } // bareword (unquoted)
| { kind: 'PHRASE'; text: string } // "quoted phrase"
| { kind: 'EOF' };
const isSpace = (c: string) => /\s/.test(c);
const isIdent = (c: string) => /[A-Za-z0-9_\-./]/.test(c);
export function tokenize(input: string): Tok[] {
const toks: Tok[] = [];
let i = 0;
const n = input.length;
const peek = () => input[i] ?? '';
const advance = () => input[i++];
const readWord = () => {
let s = '';
while (i < n && isIdent(peek())) s += advance();
return s;
};
const readPhrase = () => {
// assumes current char is opening quote
advance(); // consume opening "
let s = '';
while (i < n) {
const c = advance();
if (c === `"`) break;
if (c === '\\' && i < n) {
// escape \" and \\ (simple)
const next = advance();
s += next;
} else {
s += c;
}
}
return s;
};
while (i < n) {
const c = peek();
if (isSpace(c)) {
i++;
continue;
}
if (c === '(') {
toks.push({ kind: 'LPAREN' });
i++;
continue;
}
if (c === ')') {
toks.push({ kind: 'RPAREN' });
i++;
continue;
}
if (c === ':') {
toks.push({ kind: 'COLON' });
i++;
continue;
}
if (c === `"`) {
const text = readPhrase();
toks.push({ kind: 'PHRASE', text });
continue;
}
if (c === '-') {
toks.push({ kind: 'MINUS' });
i++;
continue;
}
// WORD / AND / OR / NOT
if (isIdent(c)) {
const w = readWord();
const upper = w.toUpperCase();
if (upper === 'AND') toks.push({ kind: 'AND' });
else if (upper === 'OR') toks.push({ kind: 'OR' });
else if (upper === 'NOT') toks.push({ kind: 'NOT' });
else toks.push({ kind: 'WORD', text: w });
continue;
}
// Unknown char—skip to be forgiving
i++;
}
toks.push({ kind: 'EOF' });
return toks;
}
class Parser {
private i = 0;
constructor(private toks: Tok[]) {}
private peek(): Tok {
return this.toks[this.i] ?? { kind: 'EOF' };
}
private advance(): Tok {
return this.toks[this.i++] ?? { kind: 'EOF' };
}
private at(kind: Tok['kind']) {
return this.peek().kind === kind;
}
// Top-level: parse OR-precedence chain, allowing implicit AND.
parse(): Ast | null {
if (this.at('EOF')) return null;
const expr = this.parseOr();
if (!this.at('EOF')) {
// Optionally, consume remaining tokens or throw
}
return expr;
}
// Precedence: NOT (highest), AND, OR (lowest)
private parseOr(): Ast {
let node = this.parseAnd();
while (this.at('OR')) {
this.advance();
const rhs = this.parseAnd();
node = { type: 'Or', left: node, right: rhs };
}
return node;
}
private parseAnd(): Ast {
let node = this.parseUnary();
// Implicit AND: if next token starts a primary, treat as AND.
while (this.at('AND') || this.startsPrimary()) {
if (this.at('AND')) this.advance();
const rhs = this.parseUnary();
node = { type: 'And', left: node, right: rhs };
}
return node;
}
private parseUnary(): Ast {
if (this.at('NOT') || this.at('MINUS')) {
this.advance();
const node = this.parseUnary();
return { type: 'Not', node };
}
return this.parsePrimaryOrField();
}
private startsPrimary(): boolean {
const k = this.peek().kind;
return k === 'WORD' || k === 'PHRASE' || k === 'LPAREN' || k === 'MINUS' || k === 'NOT';
}
private parsePrimaryOrField(): Ast {
// Parenthesized group
if (this.at('LPAREN')) {
this.advance();
const inside = this.parseOr();
// if (!this.at('RPAREN')) throw new Error("Missing closing ')'");
this.advance();
return inside;
}
// Phrase
if (this.at('PHRASE')) {
const t = this.advance() as Extract<Tok, { kind: 'PHRASE' }>;
return { type: 'Phrase', value: t.text };
}
// Field or bare word
if (this.at('WORD')) {
const wordTok = this.advance() as Extract<Tok, { kind: 'WORD' }>;
if (this.at('COLON')) {
// field:value or field:"phrase"
this.advance(); // :
let value: string;
if (this.at('PHRASE')) {
const p = this.advance() as Extract<Tok, { kind: 'PHRASE' }>;
value = p.text;
} else if (this.at('WORD')) {
const w = this.advance() as Extract<Tok, { kind: 'WORD' }>;
value = w.text;
} else {
// Anything else after colon is treated literally as a single Term token.
const t = this.advance();
value = tokText(t);
}
return { type: 'Field', field: wordTok.text, value };
}
// plain term
return { type: 'Term', value: wordTok.text };
}
const w = this.advance() as Extract<Tok, { kind: 'WORD' }>;
return { type: 'Phrase', value: 'text' in w ? w.text : '' };
}
}
function tokText(t: Tok): string {
if ('text' in t) return t.text;
switch (t.kind) {
case 'COLON':
return ':';
case 'LPAREN':
return '(';
case 'RPAREN':
return ')';
default:
return '';
}
}
export function parseQuery(q: string): Ast | null {
if (q.trim() === '') return null;
const toks = tokenize(q);
const parser = new Parser(toks);
return parser.parse();
}
export type Doc = {
text?: string;
fields?: Record<string, unknown>;
};
type Technique = 'substring' | 'fuzzy' | 'strict';
function includes(hay: string | undefined, needle: string, technique: Technique): boolean {
if (!hay || !needle) return false;
else if (technique === 'strict') return hay === needle;
else if (technique === 'fuzzy') return !!fuzzyMatch(hay, needle);
else return hay.indexOf(needle) !== -1;
}
export function evaluate(ast: Ast | null, doc: Doc): boolean {
if (!ast) return true; // Match everything if no query is provided
const text = (doc.text ?? '').toLowerCase();
const fieldsNorm: Record<string, string[]> = {};
for (const [k, v] of Object.entries(doc.fields ?? {})) {
if (!(typeof v === 'string' || Array.isArray(v))) continue;
fieldsNorm[k.toLowerCase()] = Array.isArray(v)
? v.filter((v) => typeof v === 'string').map((s) => s.toLowerCase())
: [String(v ?? '').toLowerCase()];
}
const evalNode = (node: Ast): boolean => {
switch (node.type) {
case 'Term':
return includes(text, node.value.toLowerCase(), 'fuzzy');
case 'Phrase':
// Quoted phrases match exactly
return includes(text, node.value.toLowerCase(), 'substring');
case 'Field': {
const vals = fieldsNorm[node.field.toLowerCase()] ?? [];
if (vals.length === 0) return false;
return vals.some((v) => includes(v, node.value.toLowerCase(), 'substring'));
}
case 'Not':
return !evalNode(node.node);
case 'And':
return evalNode(node.left) && evalNode(node.right);
case 'Or':
return evalNode(node.left) || evalNode(node.right);
}
};
return evalNode(ast);
}

View File

@@ -25,7 +25,6 @@ const methodNames: Record<string, string> = {
};
export const HttpMethodTag = memo(function HttpMethodTag({ request, className, short }: Props) {
const settings = useAtomValue(settingsAtom);
const method =
request.model === 'http_request' && request.bodyType === 'graphql'
? 'graphql'
@@ -35,25 +34,16 @@ export const HttpMethodTag = memo(function HttpMethodTag({ request, className, s
? 'websocket'
: request.method;
return (
<HttpMethodTagRaw
method={method}
colored={settings.coloredMethods}
className={className}
short={short}
/>
);
return <HttpMethodTagRaw method={method} className={className} short={short} />;
});
function HttpMethodTagRaw({
export function HttpMethodTagRaw({
className,
method,
colored,
short,
}: {
method: string;
className?: string;
colored: boolean;
short?: boolean;
}) {
let label = method.toUpperCase();
@@ -64,6 +54,8 @@ function HttpMethodTagRaw({
const m = method.toUpperCase();
const colored = useAtomValue(settingsAtom).coloredMethods;
return (
<span
className={classNames(
@@ -72,9 +64,9 @@ function HttpMethodTagRaw({
colored && m === 'GRAPHQL' && 'text-info',
colored && m === 'WEBSOCKET' && 'text-info',
colored && m === 'GRPC' && 'text-info',
colored && m === 'QUERY' && 'text-secondary',
colored && m === 'QUERY' && 'text-text-subtle',
colored && m === 'OPTIONS' && 'text-info',
colored && m === 'HEAD' && 'text-secondary',
colored && m === 'HEAD' && 'text-text-subtle',
colored && m === 'GET' && 'text-primary',
colored && m === 'PUT' && 'text-warning',
colored && m === 'PATCH' && 'text-notice',

View File

@@ -25,11 +25,14 @@ import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
ChevronsDownUpIcon,
ChevronsUpDownIcon,
CircleAlertIcon,
CircleDashedIcon,
CircleDollarSignIcon,
CircleFadingArrowUpIcon,
CircleHelpIcon,
CircleOffIcon,
ClipboardPasteIcon,
ClockIcon,
CodeIcon,
@@ -43,6 +46,7 @@ import {
DotIcon,
DownloadIcon,
EllipsisIcon,
EllipsisVerticalIcon,
ExpandIcon,
ExternalLinkIcon,
EyeIcon,
@@ -149,6 +153,8 @@ const icons = {
check_square_unchecked: SquareIcon,
chevron_down: ChevronDownIcon,
chevron_left: ChevronLeftIcon,
chevrons_up_down: ChevronsUpDownIcon,
chevrons_down_up: ChevronsDownUpIcon,
chevron_right: ChevronRightIcon,
circle_alert: CircleAlertIcon,
circle_dashed: CircleDashedIcon,
@@ -166,6 +172,7 @@ const icons = {
dot: DotIcon,
download: DownloadIcon,
ellipsis: EllipsisIcon,
ellipsis_vertical: EllipsisVerticalIcon,
expand: ExpandIcon,
external_link: ExternalLinkIcon,
eye: EyeIcon,
@@ -191,6 +198,7 @@ const icons = {
git_fork: GitForkIcon,
git_pull_request: GitPullRequestIcon,
grip_vertical: GripVerticalIcon,
circle_off: CircleOffIcon,
hand: HandIcon,
help: CircleHelpIcon,
history: HistoryIcon,

View File

@@ -75,13 +75,22 @@ export type InputProps = Pick<
rightSlot?: ReactNode;
size?: '2xs' | 'xs' | 'sm' | 'md' | 'auto';
stateKey: EditorProps['stateKey'];
extraExtensions?: EditorProps['extraExtensions'];
tint?: Color;
type?: 'text' | 'password';
validate?: boolean | ((v: string) => boolean);
wrapLines?: boolean;
};
export const Input = forwardRef<EditorView, InputProps>(function Input({ type, ...props }, ref) {
export interface InputHandle {
focus: () => void;
isFocused: () => boolean;
value: () => string;
selectAll: () => void;
dispatch: EditorView['dispatch'];
}
export const Input = forwardRef<InputHandle, InputProps>(function Input({ type, ...props }, ref) {
// If it's a password and template functions are supported (ie. secure(...)) then
// use the encrypted input component.
if (type === 'password' && props.autocompleteFunctions) {
@@ -91,7 +100,7 @@ export const Input = forwardRef<EditorView, InputProps>(function Input({ type, .
}
});
const BaseInput = forwardRef<EditorView, InputProps>(function InputBase(
const BaseInput = forwardRef<InputHandle, InputProps>(function InputBase(
{
className,
containerClassName,
@@ -132,7 +141,29 @@ const BaseInput = forwardRef<EditorView, InputProps>(function InputBase(
const [hasChanged, setHasChanged] = useStateWithDeps<boolean>(false, [forceUpdateKey]);
const editorRef = useRef<EditorView | null>(null);
useImperativeHandle<EditorView | null, EditorView | null>(ref, () => editorRef.current);
const inputHandle = useMemo<InputHandle>(
() => ({
focus: () => {
editorRef.current?.focus();
},
isFocused: () => editorRef.current?.hasFocus ?? false,
value: () => editorRef.current?.state.doc.toString() ?? '',
dispatch: (...args) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
editorRef.current?.dispatch(...(args as any));
},
selectAll() {
const head = editorRef.current?.state.doc.length ?? 0;
editorRef.current?.dispatch({
selection: { anchor: 0, head },
});
editorRef.current?.focus();
},
}),
[],
);
useImperativeHandle(ref, (): InputHandle => inputHandle, [inputHandle]);
const lastWindowFocus = useRef<number>(0);
useEffect(() => {
@@ -343,7 +374,7 @@ function EncryptionInput({
security: ReturnType<typeof analyzeTemplate> | null;
obscured: boolean;
error: string | null;
}>({ fieldType: 'encrypted', value: null, security: null, obscured: true, error: null }, [
}>({ fieldType: 'text', value: null, security: null, obscured: true, error: null }, [
ogForceUpdateKey,
]);

View File

@@ -9,6 +9,7 @@ export function Label({
visuallyHidden,
tags = [],
required,
rightSlot,
help,
...props
}: HTMLAttributes<HTMLLabelElement> & {
@@ -16,6 +17,7 @@ export function Label({
required?: boolean;
tags?: string[];
visuallyHidden?: boolean;
rightSlot?: ReactNode;
children: ReactNode;
help?: ReactNode;
}) {
@@ -40,6 +42,7 @@ export function Label({
</span>
))}
{help && <IconTooltip tabIndex={-1} content={help} />}
{rightSlot && <div className="ml-auto">{rightSlot}</div>}
</label>
);
}

View File

@@ -1,4 +1,3 @@
import type { EditorView } from '@codemirror/view';
import type { DragEndEvent, DragMoveEvent, DragStartEvent } from '@dnd-kit/core';
import {
DndContext,
@@ -39,7 +38,7 @@ import type { GenericCompletionConfig } from './Editor/genericCompletion';
import { Editor } from './Editor/LazyEditor';
import { Icon } from './Icon';
import { IconButton } from './IconButton';
import type { InputProps } from './Input';
import type { InputHandle, InputProps } from './Input';
import { Input } from './Input';
import { ensurePairId } from './PairEditor.util';
import { PlainInput } from './PlainInput';
@@ -421,8 +420,8 @@ export function PairEditorRow({
valueType,
valueValidate,
}: PairEditorRowProps) {
const nameInputRef = useRef<EditorView>(null);
const valueInputRef = useRef<EditorView>(null);
const nameInputRef = useRef<InputHandle>(null);
const valueInputRef = useRef<InputHandle>(null);
useEffect(() => {
if (forceFocusNamePairId === pair.id) {

View File

@@ -1,5 +1,5 @@
import classNames from 'classnames';
import type { FocusEvent, HTMLAttributes } from 'react';
import type { FocusEvent, HTMLAttributes, ReactNode } from 'react';
import {
forwardRef,
useCallback,
@@ -21,6 +21,7 @@ export type PlainInputProps = Omit<InputProps, 'wrapLines' | 'onKeyDown' | 'type
type?: 'text' | 'password' | 'number';
step?: number;
hideObscureToggle?: boolean;
labelRightSlot?: ReactNode;
};
export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(function PlainInput(
@@ -37,6 +38,7 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
label,
labelClassName,
labelPosition = 'top',
labelRightSlot,
leftSlot,
name,
onBlur,
@@ -91,10 +93,11 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
// Force input to update when receiving change and not in focus
useLayoutEffect(() => {
if (!focused) {
const isFocused = document.activeElement === inputRef.current;
if (defaultValue != null && !isFocused) {
regenerateFocusedUpdateKey();
}
}, [focused, regenerateFocusedUpdateKey, defaultValue]);
}, [regenerateFocusedUpdateKey, defaultValue]);
const id = `input-${name}`;
const commonClassName = classNames(
@@ -136,6 +139,7 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
visuallyHidden={hideLabel}
required={required}
help={help}
rightSlot={labelRightSlot}
>
{label}
</Label>

View File

@@ -1,11 +1,12 @@
import classNames from 'classnames';
import { useAtomValue } from 'jotai';
import type { CSSProperties, MouseEvent as ReactMouseEvent, ReactNode } from 'react';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import type { CSSProperties, ReactNode } from 'react';
import React, { useCallback, useMemo, useRef } from 'react';
import { useLocalStorage } from 'react-use';
import { activeWorkspaceAtom } from '../../hooks/useActiveWorkspace';
import { useContainerSize } from '../../hooks/useContainerQuery';
import { clamp } from '../../lib/clamp';
import type { ResizeHandleEvent } from '../ResizeHandle';
import { ResizeHandle } from '../ResizeHandle';
export type SplitLayoutLayout = 'responsive' | 'horizontal' | 'vertical';
@@ -25,6 +26,7 @@ interface Props {
minHeightPx?: number;
minWidthPx?: number;
layout?: SplitLayoutLayout;
resizeHandleClassName?: string;
}
const baseProperties = { minWidth: 0 };
@@ -41,6 +43,7 @@ export function SplitLayout({
className,
name,
layout = 'responsive',
resizeHandleClassName,
defaultRatio = 0.5,
minHeightPx = 10,
minWidthPx = 10,
@@ -55,10 +58,6 @@ export function SplitLayout({
);
const width = widthRaw ?? defaultRatio;
let height = heightRaw ?? defaultRatio;
const [isResizing, setIsResizing] = useState<boolean>(false);
const moveState = useRef<{ move: (e: MouseEvent) => void; up: (e: MouseEvent) => void } | null>(
null,
);
if (!secondSlot) {
height = 0;
@@ -86,60 +85,37 @@ export function SplitLayout({
};
}, [style, vertical, height, minHeightPx, width]);
const unsub = () => {
if (moveState.current !== null) {
document.documentElement.removeEventListener('pointermove', moveState.current.move);
document.documentElement.removeEventListener('pointerup', moveState.current.up);
}
};
const handleReset = useCallback(() => {
if (vertical) setHeight(defaultRatio);
else setWidth(defaultRatio);
}, [vertical, setHeight, defaultRatio, setWidth]);
const handleResizeStart = useCallback(
(e: ReactMouseEvent<HTMLDivElement>) => {
const handleResizeMove = useCallback(
(e: ResizeHandleEvent) => {
if (containerRef.current === null) return;
unsub();
const containerRect = containerRef.current.getBoundingClientRect();
// const containerRect = containerRef.current.getBoundingClientRect();
const { paddingLeft, paddingRight, paddingTop, paddingBottom } = getComputedStyle(
containerRef.current,
);
const $c = containerRef.current;
const containerWidth = $c.clientWidth - parseFloat(paddingLeft) - parseFloat(paddingRight);
const containerHeight = $c.clientHeight - parseFloat(paddingTop) - parseFloat(paddingBottom);
const mouseStartX = e.clientX;
const mouseStartY = e.clientY;
const startWidth = containerRect.width * width;
const startHeight = containerRect.height * height;
const mouseStartX = e.xStart;
const mouseStartY = e.yStart;
const startWidth = containerWidth * width;
const startHeight = containerHeight * height;
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;
const newHeightPx = clamp(
startHeight - (e.clientY - mouseStartY),
minHeightPx,
maxHeightPx,
);
setHeight(newHeightPx / containerRect.height);
} else {
const maxWidthPx = containerRect.width - minWidthPx;
const newWidthPx = clamp(
startWidth - (e.clientX - mouseStartX),
minWidthPx,
maxWidthPx,
);
setWidth(newWidthPx / containerRect.width);
}
},
up: (e: MouseEvent) => {
e.preventDefault();
unsub();
setIsResizing(false);
},
};
document.documentElement.addEventListener('pointermove', moveState.current.move);
document.documentElement.addEventListener('pointerup', moveState.current.up);
if (vertical) {
const maxHeightPx = containerHeight - minHeightPx;
const newHeightPx = clamp(startHeight - (e.y - mouseStartY), minHeightPx, maxHeightPx);
setHeight(newHeightPx / containerHeight);
} else {
const maxWidthPx = containerWidth - minWidthPx;
const newWidthPx = clamp(startWidth - (e.x - mouseStartX), minWidthPx, maxWidthPx);
setWidth(newWidthPx / containerWidth);
}
},
[width, height, vertical, minHeightPx, setHeight, minWidthPx, setWidth],
);
@@ -155,9 +131,11 @@ export function SplitLayout({
<>
<ResizeHandle
style={areaD}
isResizing={isResizing}
className={classNames(vertical ? '-translate-y-1' : '-translate-x-1')}
onResizeStart={handleResizeStart}
className={classNames(
resizeHandleClassName,
vertical ? '-translate-y-1' : '-translate-x-1',
)}
onResizeMove={handleResizeMove}
onReset={handleReset}
side={vertical ? 'top' : 'left'}
justify="center"

View File

@@ -2,6 +2,8 @@ import classNames from 'classnames';
import type { ReactNode } from 'react';
import { memo, useEffect, useRef } from 'react';
import { ErrorBoundary } from '../../ErrorBoundary';
import type { ButtonProps } from '../Button';
import { Button } from '../Button';
import { Icon } from '../Icon';
import type { RadioDropdownProps } from '../RadioDropdown';
import { RadioDropdown } from '../RadioDropdown';
@@ -55,7 +57,6 @@ export function Tabs({
if (parent !== ref.current) {
// Tab is part of a nested tab container, so ignore it
} else if (v === value) {
tab.setAttribute('tabindex', '-1');
tab.setAttribute('data-state', 'active');
tab.setAttribute('aria-hidden', 'false');
tab.style.display = 'block';
@@ -103,18 +104,28 @@ export function Tabs({
}
const isActive = t.value === value;
const btnClassName = classNames(
'h-sm flex items-center rounded whitespace-nowrap',
'!px-2 ml-[1px] hocus:text-text',
addBorders && 'border hocus:bg-surface-highlight',
isActive ? 'text-text' : 'text-text-subtle',
isActive && addBorders
? 'border-surface-active bg-surface-active'
: layout === 'vertical'
? 'border-border-subtle'
: 'border-transparent',
layout === 'horizontal' && 'flex justify-between min-w-[10rem]',
);
const btnProps: Partial<ButtonProps> = {
size: 'sm',
color: 'custom',
justify: layout === 'horizontal' ? 'start' : 'center',
onClick: isActive ? undefined : () => onChangeValue(t.value),
className: classNames(
'flex items-center rounded whitespace-nowrap',
'!px-2 ml-[1px]',
'outline-none',
'ring-none',
'focus-visible-or-class:outline-2',
addBorders && 'border focus-visible:bg-surface-highlight',
isActive ? 'text-text' : 'text-text-subtle',
isActive && addBorders
? 'border-surface-active bg-surface-active'
: layout === 'vertical'
? 'border-border-subtle'
: 'border-transparent',
layout === 'horizontal' && 'min-w-[10rem]',
),
};
if ('options' in t) {
const option = t.options.items.find(
@@ -129,35 +140,33 @@ export function Tabs({
value={t.options.value}
onChange={t.options.onChange}
>
<button
onClick={isActive ? undefined : () => onChangeValue(t.value)}
className={classNames(btnClassName)}
<Button
rightSlot={
<>
{t.rightSlot}
<Icon
size="sm"
icon="chevron_down"
className={classNames(
'ml-1',
isActive ? 'text-text-subtle' : 'text-text-subtlest',
)}
/>
</>
}
{...btnProps}
>
{option && 'shortLabel' in option && option.shortLabel
? option.shortLabel
: (option?.label ?? 'Unknown')}
{t.rightSlot}
<Icon
size="sm"
icon="chevron_down"
className={classNames(
'ml-1',
isActive ? 'text-text-subtle' : 'text-text-subtlest',
)}
/>
</button>
</Button>
</RadioDropdown>
);
} else {
return (
<button
key={t.value}
onClick={isActive ? undefined : () => onChangeValue(t.value)}
className={btnClassName}
>
<Button key={t.value} rightSlot={t.rightSlot} {...btnProps}>
{t.label}
{t.rightSlot}
</button>
</Button>
);
}
})}

View File

@@ -1,6 +1,7 @@
import type { DragEndEvent, DragMoveEvent, DragStartEvent } from '@dnd-kit/core';
import {
DndContext,
MeasuringStrategy,
PointerSensor,
pointerWithin,
useDroppable,
@@ -14,6 +15,7 @@ import React, {
forwardRef,
memo,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
@@ -31,36 +33,33 @@ import {
draggingIdsFamily,
focusIdsFamily,
hoveredParentFamily,
isCollapsedFamily,
selectedIdsFamily,
} from './atoms';
import type { SelectableTreeNode, TreeNode } from './common';
import { equalSubtree, getSelectedItems, hasAncestor } from './common';
import { closestVisibleNode, equalSubtree, getSelectedItems, hasAncestor } from './common';
import { TreeDragOverlay } from './TreeDragOverlay';
import type { TreeItemHandle, TreeItemProps } from './TreeItem';
import type { TreeItemClickEvent, TreeItemHandle, TreeItemProps } from './TreeItem';
import type { TreeItemListProps } from './TreeItemList';
import { TreeItemList } from './TreeItemList';
import { useSelectableItems } from './useSelectableItems';
/** So we re-calculate after expanding a folder during drag */
const measuring = { droppable: { strategy: MeasuringStrategy.Always } };
export interface TreeProps<T extends { id: string }> {
root: TreeNode<T>;
treeId: string;
getItemKey: (item: T) => string;
getContextMenu?: (t: TreeHandle, items: T[]) => Promise<ContextMenuProps['items']>;
getContextMenu?: (items: T[]) => ContextMenuProps['items'] | Promise<ContextMenuProps['items']>;
ItemInner: ComponentType<{ treeId: string; item: T }>;
ItemLeftSlot?: ComponentType<{ treeId: string; item: T }>;
ItemLeftSlotInner?: ComponentType<{ treeId: string; item: T }>;
ItemRightSlot?: 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,
{
cb: (h: TreeHandle, items: T[]) => void;
enable?: boolean | ((h: TreeHandle) => boolean);
} & Omit<HotKeyOptions, 'enable'>
>
>;
actions: Partial<Record<HotkeyAction, { cb: (items: T[]) => void } & HotKeyOptions>>;
};
getEditOptions?: (item: T) => {
defaultValue: string;
@@ -70,9 +69,12 @@ export interface TreeProps<T extends { id: string }> {
}
export interface TreeHandle {
focus: () => void;
treeId: string;
focus: () => boolean;
hasFocus: () => boolean;
selectItem: (id: string) => void;
renameItem: (id: string) => void;
showContextMenu: () => void;
}
function TreeInner<T extends { id: string }>(
@@ -85,7 +87,8 @@ function TreeInner<T extends { id: string }>(
onActivate,
onDragEnd,
ItemInner,
ItemLeftSlot,
ItemLeftSlotInner,
ItemRightSlot,
root,
treeId,
}: TreeProps<T>,
@@ -99,7 +102,6 @@ function TreeInner<T extends { id: string }>(
y: number;
} | null>(null);
const treeItemRefs = useRef<Record<string, TreeItemHandle>>({});
const handleAddTreeItemRef = useCallback((item: T, r: TreeItemHandle | null) => {
if (r == null) {
delete treeItemRefs.current[item.id];
@@ -108,14 +110,67 @@ function TreeInner<T extends { id: string }>(
}
}, []);
// Select the first item on first render
useEffect(() => {
const ids = jotaiStore.get(selectedIdsFamily(treeId));
const fallback = selectableItems[0];
if (ids.length === 0 && fallback != null) {
jotaiStore.set(selectedIdsFamily(treeId), [fallback.node.item.id]);
jotaiStore.set(focusIdsFamily(treeId), {
anchorId: fallback.node.item.id,
lastId: fallback.node.item.id,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [treeId]);
const handleCloseContextMenu = useCallback(() => {
setShowContextMenu(null);
}, []);
const tryFocus = useCallback(() => {
treeRef.current?.querySelector<HTMLButtonElement>('.tree-item button[tabindex="0"]')?.focus();
const isTreeFocused = useCallback(() => {
return treeRef.current?.contains(document.activeElement);
}, []);
const tryFocus = useCallback(() => {
const $el = treeRef.current?.querySelector<HTMLButtonElement>(
'.tree-item button[tabindex="0"]',
);
if ($el == null) {
return false;
} else {
$el?.focus();
return true;
}
}, []);
const ensureTabbableItem = useCallback(() => {
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;
const lastSelectedItem = selectableItems.find((i) => i.node.item.id === lastSelectedId);
if (lastSelectedItem == null) {
return false;
}
const closest = closestVisibleNode(treeId, lastSelectedItem.node);
if (closest != null && closest !== lastSelectedItem.node) {
const id = closest.item.id;
jotaiStore.set(selectedIdsFamily(treeId), [id]);
jotaiStore.set(focusIdsFamily(treeId), { anchorId: id, lastId: id });
}
}, [selectableItems, treeId]);
// Ensure there's always a tabbable item after collapsed state changes
useEffect(() => {
const unsub = jotaiStore.sub(collapsedFamily(treeId), ensureTabbableItem);
return unsub;
}, [ensureTabbableItem, isTreeFocused, selectableItems, treeId, tryFocus]);
// Ensure there's always a tabbable item after render
useEffect(() => {
requestAnimationFrame(ensureTabbableItem);
ensureTabbableItem();
});
const setSelected = useCallback(
function setSelected(ids: string[], focus: boolean) {
jotaiStore.set(selectedIdsFamily(treeId), ids);
@@ -127,14 +182,25 @@ function TreeInner<T extends { id: string }>(
const treeHandle = useMemo<TreeHandle>(
() => ({
treeId,
focus: tryFocus,
hasFocus: () => treeRef.current?.contains(document.activeElement) ?? false,
renameItem: (id) => treeItemRefs.current[id]?.rename(),
selectItem: (id) => {
setSelected([id], false);
jotaiStore.set(focusIdsFamily(treeId), { anchorId: id, lastId: id });
},
showContextMenu: async () => {
if (getContextMenu == null) return;
const items = getSelectedItems(treeId, selectableItems);
const menuItems = await getContextMenu(items);
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;
const rect = lastSelectedId ? treeItemRefs.current[lastSelectedId]?.rect() : null;
if (rect == null) return;
setShowContextMenu({ items: menuItems, x: rect.x, y: rect.y });
},
}),
[setSelected, treeId, tryFocus],
[getContextMenu, selectableItems, setSelected, treeId, tryFocus],
);
useImperativeHandle(ref, (): TreeHandle => treeHandle, [treeHandle]);
@@ -146,16 +212,16 @@ function TreeInner<T extends { id: string }>(
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(treeHandle, items);
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]);
setSelected([item.id], false);
jotaiStore.set(focusIdsFamily(treeId), (prev) => ({ ...prev, lastId: item.id }));
return getContextMenu(treeHandle, [item]);
return getContextMenu([item]);
}
};
}, [getContextMenu, selectableItems, treeHandle, treeId]);
}, [getContextMenu, selectableItems, setSelected, treeId]);
const handleSelect = useCallback<NonNullable<TreeItemProps<T>['onClick']>>(
(item, { shiftKey, metaKey, ctrlKey }) => {
@@ -176,16 +242,17 @@ function TreeInner<T extends { id: string }>(
return;
}
const validSelectableItems = getValidSelectableItems(treeId, selectableItems);
if (currIndex > anchorIndex) {
// Selecting down
const itemsToSelect = selectableItems.slice(anchorIndex, currIndex + 1);
const itemsToSelect = validSelectableItems.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);
const itemsToSelect = validSelectableItems.slice(currIndex, anchorIndex + 1);
setSelected(
itemsToSelect.map((v) => v.node.item.id),
true,
@@ -223,34 +290,117 @@ function TreeInner<T extends { id: string }>(
[handleSelect, onActivate],
);
useKey(
'ArrowUp',
(e) => {
if (!treeRef.current?.contains(document.activeElement)) return;
e.preventDefault();
const selectPrevItem = useCallback(
(e: TreeItemClickEvent) => {
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);
const validSelectableItems = getValidSelectableItems(treeId, selectableItems);
const index = validSelectableItems.findIndex((i) => i.node.item.id === lastSelectedId);
const item = validSelectableItems[index - 1];
if (item != null) {
handleSelect(item.node.item, e);
}
},
[handleSelect, selectableItems, treeId],
);
const selectNextItem = useCallback(
(e: TreeItemClickEvent) => {
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;
const validSelectableItems = getValidSelectableItems(treeId, selectableItems);
const index = validSelectableItems.findIndex((i) => i.node.item.id === lastSelectedId);
const item = validSelectableItems[index + 1];
if (item != null) {
handleSelect(item.node.item, e);
}
},
[handleSelect, selectableItems, treeId],
);
const selectParentItem = useCallback(
(e: TreeItemClickEvent) => {
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;
const lastSelectedItem =
selectableItems.find((i) => i.node.item.id === lastSelectedId)?.node ?? null;
if (lastSelectedItem?.parent != null) {
handleSelect(lastSelectedItem.parent.item, e);
}
},
[handleSelect, selectableItems, treeId],
);
useKey(
(e) => e.key === 'ArrowUp' || e.key.toLowerCase() === 'k',
(e) => {
if (!isTreeFocused()) return;
e.preventDefault();
selectPrevItem(e);
},
undefined,
[selectableItems, handleSelect],
);
useKey(
'ArrowDown',
(e) => e.key === 'ArrowDown' || e.key.toLowerCase() === 'j',
(e) => {
if (!treeRef.current?.contains(document.activeElement)) return;
if (!isTreeFocused()) 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);
selectNextItem(e);
},
undefined,
[selectableItems, handleSelect],
);
// If the selected item is a collapsed folder, expand it. Otherwise, select next item
useKey(
(e) => e.key === 'ArrowRight' || e.key === 'l',
(e) => {
if (!isTreeFocused()) return;
e.preventDefault();
const collapsed = jotaiStore.get(collapsedFamily(treeId));
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;
const lastSelectedItem = selectableItems.find((i) => i.node.item.id === lastSelectedId);
if (
lastSelectedId &&
lastSelectedItem?.node.children != null &&
collapsed[lastSelectedItem.node.item.id] === true
) {
jotaiStore.set(isCollapsedFamily({ treeId, itemId: lastSelectedId }), false);
} else {
selectNextItem(e);
}
},
undefined,
[selectableItems, handleSelect],
);
// If the selected item is in a folder, select its parent.
// If the selected item is an expanded folder, collapse it.
useKey(
(e) => e.key === 'ArrowLeft' || e.key === 'h',
(e) => {
if (!isTreeFocused()) return;
e.preventDefault();
const collapsed = jotaiStore.get(collapsedFamily(treeId));
const lastSelectedId = jotaiStore.get(focusIdsFamily(treeId)).lastId;
const lastSelectedItem = selectableItems.find((i) => i.node.item.id === lastSelectedId);
if (
lastSelectedId &&
lastSelectedItem?.node.children != null &&
collapsed[lastSelectedItem.node.item.id] !== true
) {
jotaiStore.set(isCollapsedFamily({ treeId, itemId: lastSelectedId }), true);
} else {
selectParentItem(e);
}
},
{ options: {} },
[selectableItems, handleSelect],
);
useKeyPressEvent('Escape', async () => {
if (!treeRef.current?.contains(document.activeElement)) return;
clearDragState();
@@ -278,6 +428,24 @@ function TreeInner<T extends { id: string }>(
return;
}
const overSelectableItem = selectableItems.find((i) => i.node.item.id === over.id) ?? null;
if (overSelectableItem == null) {
return;
}
const draggingItems = jotaiStore.get(draggingIdsFamily(treeId));
for (const id of draggingItems) {
const item = selectableItems.find((i) => i.node.item.id === id)?.node ?? null;
if (item == null) {
return;
}
const isSameParent = item.parent?.item.id === overSelectableItem.node.parent?.item.id;
if (item.localDrag && !isSameParent) {
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) {
@@ -290,12 +458,7 @@ function TreeInner<T extends { id: string }>(
return;
}
const selectableItem = selectableItems.find((i) => i.node.item.id === over.id) ?? null;
if (selectableItem == null) {
return;
}
const node = selectableItem.node;
const node = overSelectableItem.node;
const side = computeSideForDragMove(node.item.id, e);
const item = node.item;
@@ -303,14 +466,10 @@ function TreeInner<T extends { id: string }>(
const dragIndex = selectableItems.findIndex((n) => n.node.item.id === item.id) ?? -1;
const hovered = selectableItems[dragIndex]?.node ?? null;
const hoveredIndex = dragIndex + (side === 'above' ? 0 : 1);
let hoveredChildIndex = selectableItem.index + (side === 'above' ? 0 : 1);
let hoveredChildIndex = overSelectableItem.index + (side === 'above' ? 0 : 1);
const collapsedMap = jotaiStore.get(collapsedFamily(treeId));
const isHoveredItemCollapsed =
hovered != null ? hovered.children?.length === 0 || 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
// Move into the folder if it's open and we're moving below it
if (hovered?.children != null && side === 'below') {
hoveredParent = hovered;
hoveredChildIndex = 0;
}
@@ -328,12 +487,7 @@ function TreeInner<T extends { id: string }>(
childIndex === existing.childIndex
)
) {
jotaiStore.set(hoveredParentFamily(treeId), {
parentId: hoveredParent?.item.id ?? null,
parentDepth: hoveredParent?.depth ?? null,
index: hoveredIndex,
childIndex: hoveredChildIndex,
});
jotaiStore.set(hoveredParentFamily(treeId), { parentId, parentDepth, index, childIndex });
}
},
[root.depth, root.item.id, selectableItems, treeId],
@@ -341,20 +495,23 @@ function TreeInner<T extends { id: string }>(
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);
const isDraggingSelectedItem = selectedItems.find((i) => i.id === e.active.id);
// If we started dragging an already-selected item, we'll use that
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 });
// If we started dragging a non-selected item, only drag that item
const activeItem = selectableItems.find((i) => i.node.item.id === e.active.id)?.node.item;
if (activeItem != null) {
jotaiStore.set(draggingIdsFamily(treeId), [activeItem.id]);
// Also update selection to just be this one
handleSelect(activeItem, { shiftKey: false, metaKey: false, ctrlKey: false });
}
}
},
[handleSelect, selectableItems, treeId],
@@ -440,7 +597,8 @@ function TreeInner<T extends { id: string }>(
onClick: handleClick,
getEditOptions,
ItemInner,
ItemLeftSlot,
ItemLeftSlotInner,
ItemRightSlot,
};
const handleContextMenu = useCallback(
@@ -449,22 +607,17 @@ function TreeInner<T extends { id: string }>(
e.preventDefault();
e.stopPropagation();
const items = await getContextMenu(treeHandle, []);
const items = await getContextMenu([]);
setShowContextMenu({ items, x: e.clientX, y: e.clientY });
},
[getContextMenu, treeHandle],
[getContextMenu],
);
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
return (
<>
<TreeHotKeys
treeHandle={treeHandle}
treeId={treeId}
hotkeys={hotkeys}
selectableItems={selectableItems}
/>
<TreeHotKeys treeId={treeId} hotkeys={hotkeys} selectableItems={selectableItems} />
{showContextMenu && (
<ContextMenu
items={showContextMenu.items}
@@ -479,6 +632,7 @@ function TreeInner<T extends { id: string }>(
onDragEnd={handleDragEnd}
onDragCancel={clearDragState}
onDragAbort={clearDragState}
measuring={measuring}
onDragMove={handleDragMove}
autoScroll
>
@@ -559,10 +713,9 @@ interface TreeHotKeyProps<T extends { id: string }> {
action: HotkeyAction;
selectableItems: SelectableTreeNode<T>[];
treeId: string;
onDone: (h: TreeHandle, items: T[]) => void;
treeHandle: TreeHandle;
onDone: (items: T[]) => void;
priority?: number;
enable?: boolean | ((h: TreeHandle) => boolean);
enable?: boolean | (() => boolean);
}
function TreeHotKey<T extends { id: string }>({
@@ -570,20 +723,19 @@ function TreeHotKey<T extends { id: string }>({
action,
onDone,
selectableItems,
treeHandle,
enable,
...options
}: TreeHotKeyProps<T>) {
useHotKey(
action,
() => {
onDone(treeHandle, getSelectedItems(treeId, selectableItems));
onDone(getSelectedItems(treeId, selectableItems));
},
{
...options,
enable: () => {
if (enable == null) return true;
if (typeof enable === 'function') return enable(treeHandle);
if (typeof enable === 'function') return enable();
else return enable;
},
},
@@ -595,12 +747,10 @@ function TreeHotKeys<T extends { id: string }>({
treeId,
hotkeys,
selectableItems,
treeHandle,
}: {
treeId: string;
hotkeys: TreeProps<T>['hotkeys'];
selectableItems: SelectableTreeNode<T>[];
treeHandle: TreeHandle;
}) {
if (hotkeys == null) return null;
@@ -612,7 +762,6 @@ function TreeHotKeys<T extends { id: string }>({
action={hotkey as HotkeyAction}
treeId={treeId}
onDone={cb}
treeHandle={treeHandle}
selectableItems={selectableItems}
{...options}
/>
@@ -620,3 +769,19 @@ function TreeHotKeys<T extends { id: string }>({
</>
);
}
function getValidSelectableItems<T extends { id: string }>(
treeId: string,
selectableItems: SelectableTreeNode<T>[],
) {
const collapsed = jotaiStore.get(collapsedFamily(treeId));
return selectableItems.filter((i) => {
if (i.node.hidden) return false;
let p = i.node.parent;
while (p) {
if (collapsed[p.item.id]) return false;
p = p.parent;
}
return true;
});
}

View File

@@ -10,11 +10,11 @@ export function TreeDragOverlay<T extends { id: string }>({
selectableItems,
getItemKey,
ItemInner,
ItemLeftSlot,
ItemLeftSlotInner,
}: {
treeId: string;
selectableItems: SelectableTreeNode<T>[];
} & Pick<TreeProps<T>, 'getItemKey' | 'ItemInner' | 'ItemLeftSlot'>) {
} & Pick<TreeProps<T>, 'getItemKey' | 'ItemInner' | 'ItemLeftSlotInner'>) {
const draggingItems = useAtomValue(draggingIdsFamily(treeId));
return (
<DragOverlay dropAnimation={null}>
@@ -23,7 +23,7 @@ export function TreeDragOverlay<T extends { id: string }>({
nodes={selectableItems.filter((i) => draggingItems.includes(i.node.item.id))}
getItemKey={getItemKey}
ItemInner={ItemInner}
ItemLeftSlot={ItemLeftSlot}
ItemLeftSlotInner={ItemLeftSlotInner}
forceDepth={0}
/>
</DragOverlay>

View File

@@ -1,19 +1,19 @@
import classNames from 'classnames';
import { useAtomValue } from 'jotai';
import { memo } from 'react';
import { hoveredParentDepthFamily, isParentHoveredFamily } from './atoms';
import { hoveredParentDepthFamily, isAncestorHoveredFamily } from './atoms';
export const TreeIndentGuide = memo(function TreeIndentGuide({
treeId,
depth,
parentId,
ancestorIds,
}: {
treeId: string;
depth: number;
parentId: string | null;
ancestorIds: string[];
}) {
const parentDepth = useAtomValue(hoveredParentDepthFamily(treeId));
const isHovered = useAtomValue(isParentHoveredFamily({ treeId, parentId }));
const isHovered = useAtomValue(isAncestorHoveredFamily({ treeId, ancestorIds }));
return (
<div className="flex">
@@ -21,7 +21,7 @@ export const TreeIndentGuide = memo(function TreeIndentGuide({
<div
key={i}
className={classNames(
'w-[1rem] border-r border-r-text-subtlest',
'w-[calc(1rem+0.5px)] border-r border-r-text-subtlest',
!(parentDepth === i + 1 && isHovered) && 'opacity-30',
)}
/>

View File

@@ -1,5 +1,5 @@
import type { DragMoveEvent } from '@dnd-kit/core';
import { useDndMonitor, useDraggable, useDroppable } from '@dnd-kit/core';
import { useDndContext, useDndMonitor, useDraggable, useDroppable } from '@dnd-kit/core';
import classNames from 'classnames';
import { useAtomValue } from 'jotai';
import { selectAtom } from 'jotai/utils';
@@ -12,10 +12,11 @@ import { ContextMenu } from '../Dropdown';
import { Icon } from '../Icon';
import { collapsedFamily, isCollapsedFamily, isLastFocusedFamily, isSelectedFamily } from './atoms';
import type { TreeNode } from './common';
import { getNodeKey } from './common';
import type { TreeProps } from './Tree';
import { TreeIndentGuide } from './TreeIndentGuide';
interface OnClickEvent {
export interface TreeItemClickEvent {
shiftKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
@@ -23,12 +24,12 @@ interface OnClickEvent {
export type TreeItemProps<T extends { id: string }> = Pick<
TreeProps<T>,
'ItemInner' | 'ItemLeftSlot' | 'treeId' | 'getEditOptions' | 'getItemKey'
'ItemInner' | 'ItemLeftSlotInner' | 'ItemRightSlot' | 'treeId' | 'getEditOptions' | 'getItemKey'
> & {
node: TreeNode<T>;
className?: string;
onClick?: (item: T, e: OnClickEvent) => void;
getContextMenu?: (item: T) => Promise<ContextMenuProps['items']>;
onClick?: (item: T, e: TreeItemClickEvent) => void;
getContextMenu?: (item: T) => ContextMenuProps['items'] | Promise<ContextMenuProps['items']>;
depth: number;
addRef?: (item: T, n: TreeItemHandle | null) => void;
};
@@ -36,6 +37,8 @@ export type TreeItemProps<T extends { id: string }> = Pick<
export interface TreeItemHandle {
rename: () => void;
isRenaming: boolean;
rect: () => DOMRect;
focus: () => void;
}
const HOVER_CLOSED_FOLDER_DELAY = 800;
@@ -44,7 +47,8 @@ function TreeItem_<T extends { id: string }>({
treeId,
node,
ItemInner,
ItemLeftSlot,
ItemLeftSlotInner,
ItemRightSlot,
getContextMenu,
onClick,
getEditOptions,
@@ -60,34 +64,53 @@ function TreeItem_<T extends { id: string }>({
const [editing, setEditing] = useState<boolean>(false);
const [dropHover, setDropHover] = useState<null | 'drop' | 'animate'>(null);
const startedHoverTimeout = useRef<NodeJS.Timeout>(undefined);
useEffect(() => {
addRef?.(node.item, {
const handle = useMemo<TreeItemHandle>(
() => ({
focus: () => {
draggableRef.current?.focus();
},
rename: () => {
if (getEditOptions != null) {
setEditing(true);
}
},
isRenaming: editing,
});
}, [addRef, editing, getEditOptions, node.item]);
rect: () => {
if (listItemRef.current == null) {
return new DOMRect(0, 0, 0, 0);
}
return listItemRef.current.getBoundingClientRect();
},
}),
[editing, getEditOptions],
);
useEffect(() => {
addRef?.(node.item, handle);
}, [addRef, handle, node.item]);
const ancestorIds = useMemo(() => {
const ids: string[] = [];
let p = node.parent;
while (p) {
ids.push(p.item.id);
p = p.parent;
}
return ids;
}, [node]);
const isAncestorCollapsedAtom = useMemo(
() =>
selectAtom(
collapsedFamily(treeId),
(collapsed) => {
const next = (n: TreeNode<T>) => {
if (n.parent == null) return false;
if (collapsed[n.parent.item.id]) return true;
return next(n.parent);
};
return next(node);
},
(a, b) => a === b, // re-render only when boolean flips
(collapsed) => ancestorIds.some((id) => collapsed[id]),
(a, b) => a === b,
),
[node, treeId],
[ancestorIds, treeId],
);
const isAncestorCollapsed = useAtomValue(isAncestorCollapsedAtom);
const [showContextMenu, setShowContextMenu] = useState<{
items: DropdownItem[];
@@ -96,30 +119,24 @@ function TreeItem_<T extends { id: string }>({
} | null>(null);
useEffect(
function scrollIntoViewWhenSelected() {
return jotaiStore.sub(isSelectedFamily({ treeId, itemId: node.item.id }), () => {
() =>
jotaiStore.sub(isSelectedFamily({ treeId, itemId: node.item.id }), () => {
listItemRef.current?.scrollIntoView({ block: 'nearest' });
});
},
}),
[node.item.id, treeId],
);
const handleClick = useCallback(
function handleClick(e: MouseEvent<HTMLButtonElement>) {
onClick?.(node.item, e);
},
(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 toggleCollapsed = useCallback(() => {
jotaiStore.set(isCollapsedFamily({ treeId, itemId: node.item.id }), (prev) => !prev);
}, [node.item.id, treeId]);
const handleSubmitNameEdit = useCallback(
async function submitNameEdit(el: HTMLInputElement) {
async (el: HTMLInputElement) => {
getEditOptions?.(node.item).onChange(node.item, el.value);
onClick?.(node.item, { shiftKey: false, ctrlKey: false, metaKey: false });
// Slight delay for the model to propagate to the local store
@@ -142,7 +159,7 @@ function TreeItem_<T extends { id: string }>({
const handleEditKeyDown = useCallback(
async (e: React.KeyboardEvent<HTMLInputElement>) => {
e.stopPropagation();
e.stopPropagation(); // Don't trigger other tree keys (like arrows)
switch (e.key) {
case 'Enter':
if (editing) {
@@ -151,8 +168,10 @@ function TreeItem_<T extends { id: string }>({
}
break;
case 'Escape':
e.preventDefault();
setEditing(false);
if (editing) {
e.preventDefault();
setEditing(false);
}
break;
}
},
@@ -176,6 +195,8 @@ function TreeItem_<T extends { id: string }>({
setDropHover(null);
};
const dndContext = useDndContext();
// Toggle auto-expand of folders when hovering over them
useDndMonitor({
onDragEnd() {
@@ -192,6 +213,12 @@ function TreeItem_<T extends { id: string }>({
startedHoverTimeout.current = setTimeout(() => {
jotaiStore.set(isCollapsedFamily({ treeId, itemId: node.item.id }), false);
clearDropHover();
// Force re-measure everything because all containers below the folder have been pushed down
requestAnimationFrame(() => {
dndContext.measureDroppableContainers(
dndContext.droppableContainers.toArray().map((c) => c.id),
);
});
}, HOVER_CLOSED_FOLDER_DELAY);
} else if (isFolder && !hasChildren && side === 'below') {
setDropHover('drop');
@@ -208,7 +235,7 @@ function TreeItem_<T extends { id: string }>({
e.preventDefault();
e.stopPropagation();
const items = await getContextMenu(node.item);
setShowContextMenu({ items, x: e.clientX, y: e.clientY });
setShowContextMenu({ items, x: e.clientX ?? 100, y: e.clientY ?? 100 });
},
[getContextMenu, node.item],
);
@@ -217,7 +244,12 @@ function TreeItem_<T extends { id: string }>({
setShowContextMenu(null);
}, []);
const { attributes, listeners, setNodeRef: setDraggableRef } = useDraggable({ id: node.item.id });
const {
attributes,
listeners,
setNodeRef: setDraggableRef,
} = useDraggable({ id: node.item.id, disabled: node.draggable === false });
const { setNodeRef: setDroppableRef } = useDroppable({ id: node.item.id });
const handlePointerDown = useCallback(
@@ -239,7 +271,7 @@ function TreeItem_<T extends { id: string }>({
[setDraggableRef, setDroppableRef],
);
if (useAtomValue(isAncestorCollapsedAtom)) return null;
if (node.hidden || isAncestorCollapsed) return null;
return (
<li
@@ -260,11 +292,11 @@ function TreeItem_<T extends { id: string }>({
isSelected && 'selected',
)}
>
<TreeIndentGuide treeId={treeId} depth={depth} parentId={node.parent?.item.id ?? null} />
<TreeIndentGuide treeId={treeId} depth={depth} ancestorIds={ancestorIds} />
<div
className={classNames(
'text-text-subtle',
'grid grid-cols-[auto_minmax(0,1fr)] items-center rounded-md',
'grid grid-cols-[auto_minmax(0,1fr)_auto] gap-x-2 items-center rounded-md',
)}
>
{showContextMenu && (
@@ -275,7 +307,11 @@ function TreeItem_<T extends { id: string }>({
/>
)}
{node.children != null ? (
<button tabIndex={-1} className="h-full pl-[0.5rem]" onClick={toggleCollapsed}>
<button
tabIndex={-1}
className="h-full pl-[0.5rem] outline-none"
onClick={toggleCollapsed}
>
<Icon
icon={node.children.length === 0 ? 'dot' : 'chevron_right'}
className={classNames(
@@ -296,17 +332,18 @@ function TreeItem_<T extends { id: string }>({
onClick={handleClick}
onDoubleClick={handleDoubleClick}
disabled={editing}
className="cursor-default tree-item-inner px-2 focus:outline-none flex items-center gap-2 h-full whitespace-nowrap"
className="cursor-default tree-item-inner pr-1 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} />}
{ItemLeftSlotInner != null && <ItemLeftSlotInner treeId={treeId} item={node.item} />}
{getEditOptions != null && editing ? (
(() => {
const { defaultValue, placeholder } = getEditOptions(node.item);
return (
<input
data-disable-hotkey
ref={handleEditFocus}
defaultValue={defaultValue}
placeholder={placeholder}
@@ -320,6 +357,11 @@ function TreeItem_<T extends { id: string }>({
<ItemInner treeId={treeId} item={node.item} />
)}
</button>
{ItemRightSlot != null ? (
<ItemRightSlot treeId={treeId} item={node.item} />
) : (
<span aria-hidden />
)}
</div>
</li>
);
@@ -337,6 +379,9 @@ export const TreeItem = memo(
if (nonEqualKeys.length > 0) {
return false;
}
return nextProps.getItemKey(prevNode.item) === nextProps.getItemKey(nextNode.item);
return (
getNodeKey(prevNode, prevProps.getItemKey) === getNodeKey(nextNode, nextProps.getItemKey)
);
},
) as typeof TreeItem_;

View File

@@ -1,5 +1,5 @@
import type { CSSProperties } from 'react';
import { Fragment, memo } from 'react';
import { Fragment } from 'react';
import type { SelectableTreeNode } from './common';
import type { TreeProps } from './Tree';
import { TreeDropMarker } from './TreeDropMarker';
@@ -8,7 +8,7 @@ import { TreeItem } from './TreeItem';
export type TreeItemListProps<T extends { id: string }> = Pick<
TreeProps<T>,
'ItemInner' | 'ItemLeftSlot' | 'treeId' | 'getItemKey' | 'getEditOptions'
'ItemInner' | 'ItemLeftSlotInner' | 'ItemRightSlot' | 'treeId' | 'getItemKey' | 'getEditOptions'
> &
Pick<TreeItemProps<T>, 'onClick' | 'getContextMenu'> & {
nodes: SelectableTreeNode<T>[];
@@ -18,19 +18,15 @@ export type TreeItemListProps<T extends { id: string }> = Pick<
addTreeItemRef?: (item: T, n: TreeItemHandle | null) => void;
};
function TreeItemList_<T extends { id: string }>({
export function TreeItemList<T extends { id: string }>({
className,
getContextMenu,
getEditOptions,
getItemKey,
nodes,
onClick,
ItemInner,
ItemLeftSlot,
style,
treeId,
forceDepth,
addTreeItemRef,
...props
}: TreeItemListProps<T>) {
return (
<ul role="tree" style={style} className={className}>
@@ -38,16 +34,12 @@ function TreeItemList_<T extends { id: string }>({
{nodes.map((child, i) => (
<Fragment key={getItemKey(child.node.item)}>
<TreeItem
addRef={addTreeItemRef}
treeId={treeId}
addRef={addTreeItemRef}
node={child.node}
ItemInner={ItemInner}
ItemLeftSlot={ItemLeftSlot}
onClick={onClick}
getEditOptions={getEditOptions}
getContextMenu={getContextMenu}
getItemKey={getItemKey}
depth={forceDepth == null ? child.depth : forceDepth}
{...props}
/>
<TreeDropMarker node={child.node} treeId={treeId} index={i + 1} />
</Fragment>
@@ -55,33 +47,3 @@ function TreeItemList_<T extends { id: string }>({
</ul>
);
}
export const TreeItemList = memo(
TreeItemList_,
(
{ nodes: prevNodes, getItemKey: prevGetItemKey, ...prevProps },
{ nodes: nextNodes, getItemKey: nextGetItemKey, ...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;
}
if (prevNodes.length !== nextNodes.length) return false;
for (let i = 0; i < prevNodes.length; i++) {
const prev = prevNodes[i]!;
const next = nextNodes[i]!;
if (prevGetItemKey(prev.node.item) !== nextGetItemKey(next.node.item)) {
return false;
}
}
return true;
},
) as typeof TreeItemList_;

View File

@@ -51,6 +51,16 @@ export const isParentHoveredFamily = atomFamily(
(a, b) => a.treeId === b.treeId && a.parentId === b.parentId,
);
export const isAncestorHoveredFamily = atomFamily(
({ treeId, ancestorIds }: { treeId: string; ancestorIds: string[] }) =>
selectAtom(
hoveredParentFamily(treeId),
(v) => v.parentId && ancestorIds.includes(v.parentId),
Object.is,
),
(a, b) => a.treeId === b.treeId && a.ancestorIds.join(',') === b.ancestorIds.join(','),
);
export const isIndexHoveredFamily = atomFamily(
({ treeId, index }: { treeId: string; index: number }) =>
selectAtom(hoveredParentFamily(treeId), (v) => v.index === index, Object.is),

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