Compare commits

..

64 Commits

Author SHA1 Message Date
Gregory Schier
0a6228bf16 Fix Input ref timing, PairEditor initialization, and environment variable focus 2025-11-04 14:04:12 -08:00
Gregory Schier
fa3a0b57f9 Fix Editor.tsx wonkiness 2025-11-04 13:44:18 -08:00
Gregory Schier
4390c02117 Fix gRPC message editing 2025-11-04 12:35:36 -08:00
Gregory Schier
77011176af Fix tab flexbox issue 2025-11-04 09:22:28 -08:00
Gregory Schier
759fc503d3 Fix accidental typing 2025-11-04 08:51:46 -08:00
Gregory Schier
0cb633e479 A bunch of fixes 2025-11-04 08:44:08 -08:00
Gregory Schier
81ceb981e8 Oops 2025-11-03 15:05:50 -08:00
Gregory Schier
4dae1a7955 Improve selecting items during filter 2025-11-03 15:04:02 -08:00
Gregory Schier
d119f4cab2 Fix confirm with text autofocus 2025-11-03 14:42:30 -08:00
Gregory Schier
7e1eb90d29 Show error when enabling encryption fails 2025-11-03 14:34:43 -08:00
Gregory Schier
bf97ea1659 Some sidebar fixes 2025-11-03 14:17:11 -08:00
Gregory Schier
749ca968ec Fix environment sorting 2025-11-03 13:53:41 -08:00
Gregory Schier
0c54b481fb Fix unused variable 2025-11-03 13:29:47 -08:00
Jeroen Van den Berghe
4943bad8ec Import query parameters from Insomnia v4 and v5 exports (#290) 2025-11-03 13:03:24 -08:00
Gregory Schier
450dbd0053 Better syntax highlighting for filter expressions 2025-11-03 06:30:41 -08:00
Gregory Schier
236c8fa656 Fix sidebar reselection after dragging non-selelected item or renaming 2025-11-03 06:19:04 -08:00
Gregory Schier
1dfc2ee602 Support encoding values to base64 (url safe) 2025-11-03 06:07:34 -08:00
Gregory Schier
1d158082f6 Pass host environment variable to plugin runtime
https://feedback.yaak.app/p/when-i-use-clash-yaak-fails-to-launch
2025-11-03 06:02:18 -08:00
Gregory Schier
f3e44c53d7 Show full paths in command palette switcher
https://feedback.yaak.app/p/command-palette-search-should-include-parent-folder-names
2025-11-03 05:54:29 -08:00
Gregory Schier
c8d5e7c97b Add support for API key authentication in cURL conversion
https://feedback.yaak.app/p/copy-as-curl-without-api-key
2025-11-03 05:05:54 -08:00
Gregory Schier
9bde6bbd0a More efficient editor state saves 2025-11-02 06:16:45 -08:00
Gregory Schier
df5be218a5 Remove debug console logs from Input component 2025-11-02 05:52:56 -08:00
Gregory Schier
2deb870bb6 Fix pair editor 2025-11-02 05:52:36 -08:00
Gregory Schier
0f9975339c Fixes for last commit 2025-11-01 09:33:57 -07:00
Gregory Schier
6ad4e7bbb5 Click env var to edit AND improve input/editor ref handling 2025-11-01 08:39:07 -07:00
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
137 changed files with 5405 additions and 2329 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

@@ -8,10 +8,15 @@ if (!port) {
throw new Error('Plugin runtime missing PORT')
}
const host = process.env.HOST;
if (!host) {
throw new Error('Plugin runtime missing HOST')
}
const pluginToAppEvents = new EventChannel();
const plugins: Record<string, PluginHandle> = {};
const ws = new WebSocket(`ws://localhost:${port}`);
const ws = new WebSocket(`ws://${host}:${port}`);
ws.on('message', async (e: Buffer) => {
try {

View File

@@ -43,6 +43,26 @@ export async function convertToCurl(request: Partial<HttpRequest>) {
finalUrl = base + separator + queryString + (hash ? `#${hash}` : '');
}
// Add API key authentication
if (request.authenticationType === 'apikey') {
if (request.authentication?.location === 'query') {
const sep = request.url?.includes('?') ? '&' : '?';
finalUrl = [
finalUrl,
sep,
encodeURIComponent(request.authentication?.key ?? 'token'),
'=',
encodeURIComponent(request.authentication?.value ?? ''),
].join('');
} else {
request.headers = request.headers ?? [];
request.headers.push({
name: request.authentication?.key ?? 'X-Api-Key',
value: request.authentication?.value ?? '',
});
}
}
xs.push(quote(finalUrl));
xs.push(NEWLINE);
@@ -82,21 +102,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

@@ -27,6 +27,7 @@ describe('exporter-curl', () => {
}),
).toEqual([`curl 'https://yaak.app/path?a=aaa&b=bbb#section'`].join(` \\n `));
});
test('Exports POST with url form data', async () => {
expect(
await convertToCurl({
@@ -170,6 +171,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 +261,139 @@ 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('API key auth header', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
authenticationType: 'apikey',
authentication: {
location: 'header',
key: 'X-Header',
value: 'my-token'
},
}),
).toEqual(
[
`curl 'https://yaak.app'`,
`--header 'X-Header: my-token'`,
].join(` \\\n `),
);
});
test('API key auth header default', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
authenticationType: 'apikey',
authentication: {
location: 'header',
},
}),
).toEqual(
[
`curl 'https://yaak.app'`,
`--header 'X-Api-Key: '`,
].join(` \\\n `),
);
});
test('API key auth query', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app',
authenticationType: 'apikey',
authentication: {
location: 'query',
key: 'foo',
value: 'bar-baz'
},
}),
).toEqual(
[
`curl 'https://yaak.app?foo=bar-baz'`,
].join(` \\\n `),
);
});
test('API key auth query with existing', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app?foo=bar&baz=qux',
authenticationType: 'apikey',
authentication: {
location: 'query',
key: 'hi',
value: 'there'
},
}),
).toEqual(
[
`curl 'https://yaak.app?foo=bar&baz=qux&hi=there'`,
].join(` \\\n `),
);
});
test('API key auth query default', async () => {
expect(
await convertToCurl({
url: 'https://yaak.app?foo=bar&baz=qux',
authenticationType: 'apikey',
authentication: {
location: 'query',
},
}),
).toEqual(
[
`curl 'https://yaak.app?foo=bar&baz=qux&token='`,
].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

@@ -122,6 +122,12 @@ function importHttpRequest(r: any, workspaceId: string): PartialImportResources[
name: r.name,
description: r.description || undefined,
url: convertSyntax(r.url),
urlParameters: (r.parameters ?? [])
.map((p: any) => ({
enabled: !p.disabled,
name: p.name ?? '',
value: p.value ?? '',
})),
body,
bodyType,
authentication,
@@ -190,9 +196,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

@@ -125,6 +125,12 @@ function importHttpRequest(
name: r.name,
description: r.meta?.description || undefined,
url: convertSyntax(r.url),
urlParameters: (r.parameters ?? [])
.map((p: any) => ({
enabled: !p.disabled,
name: p.name ?? '',
value: p.value ?? '',
})),
body,
bodyType,
method: r.method,
@@ -295,9 +301,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

@@ -113,6 +113,13 @@
"model": "http_request",
"name": "New Request",
"url": "${[BASE_URL ]}/foo/:id",
"urlParameters": [
{
"name": "query",
"value": "qqq",
"enabled": true
}
],
"workspaceId": "GENERATE_ID::wrk_d4d92f7c0ee947b89159243506687019"
}
],

View File

@@ -76,6 +76,7 @@
"sortPriority": -1747414129276,
"updatedAt": "2025-05-16T16:48:49.313",
"url": "https://httpbin.org/post",
"urlParameters": [],
"workspaceId": "GENERATE_ID::wrk_9717dd1c9e0c4b2e9ed6d2abcf3bd45c"
},
{
@@ -98,6 +99,7 @@
"name": "New Request",
"sortPriority": -1747414160498,
"updatedAt": "2025-05-16T16:49:20.497",
"urlParameters": [],
"workspaceId": "GENERATE_ID::wrk_9717dd1c9e0c4b2e9ed6d2abcf3bd45c"
}
],

View File

@@ -135,6 +135,13 @@
"name": "New Request",
"sortPriority": -1736781406672,
"url": "${[BASE_URL ]}/foo/:id",
"urlParameters": [
{
"name": "query",
"value": "qqq",
"enabled": true
}
],
"workspaceId": "GENERATE_ID::wrk_c1eacfa750a04f3ea9985ef28043fa53"
}
],

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

@@ -5,9 +5,29 @@ export const plugin: PluginDefinition = {
{
name: 'base64.encode',
description: 'Encode a value to base64',
args: [{ label: 'Plain Text', type: 'text', name: 'value', multiLine: true }],
args: [
{
label: 'Encoding',
type: 'select',
name: 'encoding',
defaultValue: 'base64',
options: [
{
label: 'Base64',
value: 'base64',
},
{
label: 'Base64 URL-safe',
value: 'base64url',
},
],
},
{ label: 'Plain Text', type: 'text', name: 'value', multiLine: true },
],
async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
return Buffer.from(String(args.values.value ?? '')).toString('base64');
return Buffer.from(String(args.values.value ?? '')).toString(
args.values.encoding === 'base64url' ? 'base64url' : 'base64',
);
},
},
{

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,9 +77,16 @@ 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};
use yaak_license::{LicenseCheckStatus, check_license};
match check_license(window).await {
Ok(LicenseCheckStatus::PersonalUse { .. }) => "personal".to_string(),
Ok(LicenseCheckStatus::CommercialUse) => "commercial".to_string(),
@@ -132,6 +139,7 @@ async fn get_kv<R: Runtime>(app_handle: &AppHandle<R>) -> Result<Vec<String>> {
}
}
#[allow(unused)]
fn get_updater_status<R: Runtime>(app_handle: &AppHandle<R>) -> &'static str {
#[cfg(not(feature = "updater"))]
{

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

@@ -3,7 +3,7 @@ use crate::window_menu::app_menu;
use log::{info, warn};
use rand::random;
use tauri::{
AppHandle, Emitter, LogicalSize, Manager, Runtime, WebviewUrl, WebviewWindow, WindowEvent,
AppHandle, Emitter, LogicalSize, Manager, PhysicalSize, Runtime, WebviewUrl, WebviewWindow, WindowEvent
};
use tauri_plugin_opener::OpenerExt;
use tokio::sync::mpsc;
@@ -160,6 +160,11 @@ pub(crate) fn create_window<R: Runtime>(
"dev.reset_size" => webview_window
.set_size(LogicalSize::new(DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT))
.unwrap(),
"dev.reset_size_record" => {
let width = webview_window.outer_size().unwrap().width;
let height = width * 9 / 16;
webview_window.set_size(PhysicalSize::new(width, height)).unwrap()
}
"dev.refresh" => webview_window.eval("location.reload()").unwrap(),
"dev.generate_theme_css" => {
w.emit("generate_theme_css", true).unwrap();

View File

@@ -143,6 +143,8 @@ pub fn app_menu<R: Runtime>(app_handle: &AppHandle<R>) -> tauri::Result<Menu<R>>
.build(app_handle)?,
&MenuItemBuilder::with_id("dev.reset_size".to_string(), "Reset Size")
.build(app_handle)?,
&MenuItemBuilder::with_id("dev.reset_size_record".to_string(), "Reset Size 16x9")
.build(app_handle)?,
&MenuItemBuilder::with_id(
"dev.generate_theme_css".to_string(),
"Generate Theme CSS",

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

@@ -8,7 +8,7 @@ import { newStoreData } from './util';
export const modelStoreDataAtom = atom(newStoreData());
export const cookieJarsAtom = createOrderedModelAtom('cookie_jar', 'name', 'asc');
export const environmentsAtom = createOrderedModelAtom('environment', 'name', 'asc');
export const environmentsAtom = createOrderedModelAtom('environment', 'sortPriority', 'asc');
export const foldersAtom = createModelAtom('folder');
export const grpcConnectionsAtom = createOrderedModelAtom('grpc_connection', 'createdAt', 'desc');
export const grpcEventsAtom = createOrderedModelAtom('grpc_event', 'createdAt', 'asc');

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

@@ -38,14 +38,12 @@ impl<'a> DbContext<'a> {
let mut stmt = self.conn.prepare(sql.as_str()).expect("Failed to prepare query");
match stmt.query_row(&*params.as_params(), M::from_row) {
Ok(result) => Ok(result),
Err(rusqlite::Error::QueryReturnedNoRows) => {
Err(ModelNotFound(format!(
r#"table "{}" {} == {}"#,
M::table_name().into_iden().to_string(),
col.into_iden().to_string(),
value_debug
)))
}
Err(rusqlite::Error::QueryReturnedNoRows) => Err(ModelNotFound(format!(
r#"table "{}" {} == {}"#,
M::table_name().into_iden().to_string(),
col.into_iden().to_string(),
value_debug
))),
Err(e) => Err(crate::error::Error::SqlError(e)),
}
}
@@ -69,7 +67,7 @@ impl<'a> DbContext<'a> {
.expect("Failed to run find on DB")
}
pub fn find_all<'s, M>(&self) -> crate::error::Result<Vec<M>>
pub fn find_all<'s, M>(&self) -> Result<Vec<M>>
where
M: Into<AnyModel> + Clone + UpsertModelInfo,
{
@@ -117,7 +115,7 @@ impl<'a> DbContext<'a> {
Ok(items.map(|v| v.unwrap()).collect())
}
pub fn upsert<M>(&self, model: &M, source: &UpdateSource) -> crate::error::Result<M>
pub fn upsert<M>(&self, model: &M, source: &UpdateSource) -> Result<M>
where
M: Into<AnyModel> + From<AnyModel> + UpsertModelInfo + Clone,
{
@@ -139,7 +137,7 @@ impl<'a> DbContext<'a> {
other_values: Vec<(impl IntoIden + Eq, impl Into<SimpleExpr>)>,
update_columns: Vec<impl IntoIden>,
source: &UpdateSource,
) -> crate::error::Result<M>
) -> Result<M>
where
M: Into<AnyModel> + From<AnyModel> + UpsertModelInfo + Clone,
{
@@ -178,7 +176,7 @@ impl<'a> DbContext<'a> {
Ok(m)
}
pub(crate) fn delete<'s, M>(&self, m: &M, source: &UpdateSource) -> crate::error::Result<M>
pub(crate) fn delete<'s, M>(&self, m: &M, source: &UpdateSource) -> Result<M>
where
M: Into<AnyModel> + Clone + UpsertModelInfo,
{

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

@@ -24,6 +24,7 @@ pub async fn start_nodejs_plugin_runtime<R: Runtime>(
let cmd = app
.shell()
.sidecar("yaaknode")?
.env("HOST", addr.ip().to_string())
.env("PORT", addr.port().to_string())
.args(&[&plugin_runtime_main]);

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

@@ -30,7 +30,10 @@ import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm';
import { showDialog } from '../lib/dialog';
import { editEnvironment } from '../lib/editEnvironment';
import { renameModelWithPrompt } from '../lib/renameModelWithPrompt';
import { resolvedModelNameWithFolders } from '../lib/resolvedModelName';
import {
resolvedModelNameWithFolders,
resolvedModelNameWithFoldersArray,
} from '../lib/resolvedModelName';
import { router } from '../lib/router';
import { setWorkspaceSearchParams } from '../lib/setWorkspaceSearchParams';
import { CookieDialog } from './CookieDialog';
@@ -40,7 +43,6 @@ import { HotKey } from './core/HotKey';
import { HttpMethodTag } from './core/HttpMethodTag';
import { Icon } from './core/Icon';
import { PlainInput } from './core/PlainInput';
import { HStack } from './core/Stacks';
interface CommandPaletteGroup {
key: string;
@@ -85,11 +87,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',
@@ -280,10 +277,17 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
key: `switch-request-${r.id}`,
searchText: resolvedModelNameWithFolders(r),
label: (
<HStack space={2}>
<HttpMethodTag short className="text-xs" request={r} />
<div className="truncate">{resolvedModelNameWithFolders(r)}</div>
</HStack>
<div className="flex items-center gap-x-0.5">
<HttpMethodTag short className="text-xs mr-2" request={r} />
{resolvedModelNameWithFoldersArray(r).map((name, i, all) => (
<>
{i !== 0 && (
<Icon icon="chevron_right" className="opacity-80"/>
)}
<div className={classNames(i < all.length - 1 && 'truncate')}>{name}</div>
</>
))}
</div>
),
onSelect: async () => {
await router.navigate({
@@ -405,9 +409,10 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
);
return (
<div className="h-full w-[400px] grid grid-rows-[auto_minmax(0,1fr)] overflow-hidden py-2">
<div className="h-full w-[min(700px,80vw)] grid grid-rows-[auto_minmax(0,1fr)] overflow-hidden py-2">
<div className="px-2 w-full">
<PlainInput
autoFocus
hideLabel
leftSlot={
<div className="h-md w-10 flex justify-center items-center">

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,12 +1,13 @@
import { useAtomValue } from 'jotai';
import React from 'react';
import type { ComponentType } from 'react';
import React, { useCallback } from 'react';
import { dialogsAtom, hideDialog } from '../lib/dialog';
import { Dialog, type DialogProps } from './core/Dialog';
import { ErrorBoundary } from './ErrorBoundary';
export type DialogInstance = {
id: string;
render: ({ hide }: { hide: () => void }) => React.ReactNode;
render: ComponentType<{ hide: () => void }>;
} & Omit<DialogProps, 'open' | 'children'>;
export function Dialogs() {
@@ -20,19 +21,20 @@ export function Dialogs() {
);
}
function DialogInstance({ render, onClose, id, ...props }: DialogInstance) {
const children = render({ hide: () => hideDialog(id) });
function DialogInstance({ render: Component, onClose, id, ...props }: DialogInstance) {
const hide = useCallback(() => {
hideDialog(id);
}, [id]);
const handleClose = useCallback(() => {
onClose?.();
hideDialog(id);
}, [id, onClose]);
return (
<ErrorBoundary name={`Dialog ${id}`}>
<Dialog
open
onClose={() => {
onClose?.();
hideDialog(id);
}}
{...props}
>
{children}
<Dialog open onClose={handleClose} {...props}>
<Component hide={hide} {...props} />
</Dialog>
</ErrorBoundary>
);

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,38 +1,44 @@
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 { isBaseEnvironment } from '../lib/model_util';
import { showPrompt } from '../lib/prompt';
import { jotaiStore } from '../lib/jotai';
import { isBaseEnvironment, isSubEnvironment } from '../lib/model_util';
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 type { PairEditorHandle } from './core/PairEditor';
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';
interface Props {
initialEnvironment: Environment | null;
initialEnvironmentId: string | null;
setRef?: (ref: PairEditorHandle | null) => void;
}
export const EnvironmentEditDialog = function ({ initialEnvironment }: Props) {
const { baseEnvironment, otherBaseEnvironments, subEnvironments, allEnvironments } =
useEnvironmentsBreakdown();
type TreeModel = Environment | Workspace;
export function EnvironmentEditDialog({ initialEnvironmentId, setRef }: Props) {
const { allEnvironments, baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown();
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<string | null>(
initialEnvironment?.id ?? null,
initialEnvironmentId ?? null,
);
const selectedEnvironment =
@@ -40,23 +46,76 @@ 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
setRef={setRef}
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
tabIndex={-1}
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 +125,286 @@ 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];
const addEnvironmentItem: DropdownItem = {
label: 'Create Sub Environment',
leftSlot: <Icon icon="plus" />,
onSelect: async () => {
await createSubEnvironment();
},
};
if (environment == null || environment.model !== 'environment') {
return [addEnvironmentItem];
}
/>
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 />,
hidden: items.length > 1,
onSelect: async () => {
await patchModel(environment, { public: !environment.public });
},
},
{
color: 'danger',
label: 'Delete',
hotKeyAction: 'sidebar.selected.delete',
hotKeyLabelOnly: true,
hidden:
(isBaseEnvironment(environment) && baseEnvironments.length <= 1) ||
!isSubEnvironment(environment),
leftSlot: <Icon icon="trash" />,
onSelect: () => handleDeleteEnvironment(environment),
},
];
// Add sub environment to base environment
if (isBaseEnvironment(environment) && singleEnvironment) {
menuItems.push({ type: 'separator' });
menuItems.push(addEnvironmentItem);
}
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) {
parent.children = subEnvironments.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

@@ -2,7 +2,7 @@ import type { Environment } from '@yaakapp-internal/models';
import { patchModel } from '@yaakapp-internal/models';
import type { GenericCompletionOption } from '@yaakapp-internal/plugins';
import classNames from 'classnames';
import React, { useCallback, useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown';
import { useIsEncryptionEnabled } from '../hooks/useIsEncryptionEnabled';
import { useKeyValue } from '../hooks/useKeyValue';
@@ -17,21 +17,20 @@ import { BadgeButton } from './core/BadgeButton';
import { DismissibleBanner } from './core/DismissibleBanner';
import type { GenericCompletionConfig } from './core/Editor/genericCompletion';
import { Heading } from './core/Heading';
import type { PairWithId } from './core/PairEditor';
import type { PairEditorHandle, PairWithId } from './core/PairEditor';
import { ensurePairId } from './core/PairEditor.util';
import { PairOrBulkEditor } from './core/PairOrBulkEditor';
import { EnvironmentColorIndicator } from './EnvironmentColorIndicator';
import { EnvironmentSharableTooltip } from './EnvironmentSharableTooltip';
export function EnvironmentEditor({
environment,
hideName,
className,
}: {
interface Props {
environment: Environment;
hideName?: boolean;
className?: string;
}) {
setRef?: (n: PairEditorHandle | null) => void;
}
export function EnvironmentEditor({ environment, hideName, className, setRef }: Props) {
const workspaceId = environment.workspaceId;
const isEncryptionEnabled = useIsEncryptionEnabled();
const valueVisibility = useKeyValue<boolean>({
@@ -98,10 +97,19 @@ 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 ? (
@@ -146,6 +154,7 @@ export function EnvironmentEditor({
)}
</div>
<PairOrBulkEditor
setRef={setRef}
className="h-full"
allowMultilineValues
preferenceName="environment"

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

@@ -10,7 +10,7 @@ import {
stateExtensions,
updateSchema,
} from 'codemirror-json-schema';
import { useEffect, useMemo, useRef } from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import type { ReflectResponseService } from '../hooks/useGrpc';
import { showAlert } from '../lib/alert';
import { showDialog } from '../lib/dialog';
@@ -40,6 +40,9 @@ export function GrpcEditor({
...extraEditorProps
}: Props) {
const editorViewRef = useRef<EditorView>(null);
const handleInitEditorViewRef = useCallback((h: EditorView | null) => {
editorViewRef.current = h;
}, []);
// Find the schema for the selected service and method and update the editor
useEffect(() => {
@@ -167,6 +170,7 @@ export function GrpcEditor({
return (
<div className="h-full w-full grid grid-cols-1 grid-rows-[minmax(0,100%)_auto_auto_minmax(0,auto)]">
<Editor
setRef={handleInitEditorViewRef}
language="json"
autocompleteFunctions
autocompleteVariables
@@ -174,7 +178,6 @@ export function GrpcEditor({
defaultValue={request.message}
heightMode="auto"
placeholder="..."
ref={editorViewRef}
extraExtensions={extraExtensions}
actions={actions}
stateKey={`grpc_message.${request.id}`}

View File

@@ -1,18 +1,23 @@
import { type } from '@tauri-apps/plugin-os';
import { settingsAtom } from '@yaakapp-internal/models';
import classNames from 'classnames';
import { useAtomValue } from 'jotai';
import type { CSSProperties, HTMLAttributes, ReactNode } from 'react';
import React, { useMemo } from 'react';
import { useIsFullscreen } from '../hooks/useIsFullscreen';
import { HEADER_SIZE_LG, HEADER_SIZE_MD, WINDOW_CONTROLS_WIDTH } from '../lib/constants';
import { WindowControls } from './WindowControls';
import { type } from "@tauri-apps/plugin-os";
import { settingsAtom } from "@yaakapp-internal/models";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import type { CSSProperties, HTMLAttributes, ReactNode } from "react";
import React, { useMemo } from "react";
import { useIsFullscreen } from "../hooks/useIsFullscreen";
import {
HEADER_SIZE_LG,
HEADER_SIZE_MD,
WINDOW_CONTROLS_WIDTH,
} from "../lib/constants";
import { WindowControls } from "./WindowControls";
interface HeaderSizeProps extends HTMLAttributes<HTMLDivElement> {
children?: ReactNode;
size: 'md' | 'lg';
size: "md" | "lg";
ignoreControlsSpacing?: boolean;
onlyXWindowControl?: boolean;
hideControls?: boolean;
}
export function HeaderSize({
@@ -22,6 +27,7 @@ export function HeaderSize({
ignoreControlsSpacing,
onlyXWindowControl,
children,
hideControls,
}: HeaderSizeProps) {
const settings = useAtomValue(settingsAtom);
const isFullscreen = useIsFullscreen();
@@ -29,10 +35,10 @@ export function HeaderSize({
const s = { ...style };
// Set the height (use min-height because scaling font size may make it larger
if (size === 'md') s.minHeight = HEADER_SIZE_MD;
if (size === 'lg') s.minHeight = HEADER_SIZE_LG;
if (size === "md") s.minHeight = HEADER_SIZE_MD;
if (size === "lg") s.minHeight = HEADER_SIZE_LG;
if (type() === 'macos') {
if (type() === "macos") {
if (!isFullscreen) {
// Add large padding for window controls
s.paddingLeft = 72 / settings.interfaceScale;
@@ -57,17 +63,21 @@ 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',
"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} />
{!hideControls && <WindowControls onlyX={onlyXWindowControl} />}
</div>
);
}

View File

@@ -354,7 +354,6 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
isLoading={activeResponse != null && activeResponse.state !== 'closed'}
/>
<Tabs
key={activeRequest.id} // Freshen tabs on request change
value={activeTab}
label="Request"
onChangeValue={setActiveTab}

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

@@ -52,27 +52,17 @@ export function Overlay({
{open && (
<FocusTrap
focusTrapOptions={{
allowOutsideClick: true, // So we can still click toasts and things
// Allow outside click so we can click things like toasts
allowOutsideClick: true,
delayInitialFocus: true,
fallbackFocus: () => containerRef.current!, // always have a target
initialFocus: () =>
// Doing this explicitly seems to work better than the default behavior for some reason
containerRef.current?.querySelector<HTMLElement>(
[
'a[href]',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'button:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
'[contenteditable]:not([contenteditable="false"])',
].join(', '),
) ?? undefined,
checkCanFocusTrap: async () => {
// Not sure why delayInitialFocus: true doesn't help, but having this no-op promise
// seems to be required to make things work.
},
}}
>
<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

@@ -1,7 +1,6 @@
import { revealItemInDir } from '@tauri-apps/plugin-opener';
import { patchModel, settingsAtom } from '@yaakapp-internal/models';
import { useAtomValue } from 'jotai';
import React from 'react';
import { activeWorkspaceAtom } from '../../hooks/useActiveWorkspace';
import { appInfo } from '../../lib/appInfo';
import { useCheckForUpdates } from '../../hooks/useCheckForUpdates';
@@ -71,37 +70,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 +116,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 +124,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,767 @@
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 { 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 { 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, selectedIdsFamily } 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 setFilterRef = useCallback((h: InputHandle | null) => {
filterRef.current = h;
}, []);
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;
const selectedIds = jotaiStore.get(selectedIdsFamily(treeId));
if (selectedIds.length > 0) return;
n.selectItem(activeId);
},
[treeId],
);
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),
[],
);
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="w-full px-3 pt-3 grid grid-cols-[minmax(0,1fr)_auto] items-center -mr-2.5">
{(tree.children?.length ?? 0) > 0 && (
<>
<Input
hideLabel
setRef={setFilterRef}
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="!bg-transparent !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);
const model = node.item.model;
const isLeafNode = !(model === 'folder' || model === 'workspace');
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 = isLeafNode && evaluate(queryAst, { text: getItemText(node.item), fields });
}
let matchesChild = false;
// Recurse to children
node.children = !isLeafNode ? [] : undefined;
if (node.children != null) {
childItems.sort((a, b) => {
if (a.sortPriority === b.sortPriority) {
return a.updatedAt > b.updatedAt ? 1 : -1;
}
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(node: TreeNode<SidebarModel>): Record<string, string> {
const item = node.item;
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';
if (node.parent?.item.model === 'folder') {
fields.folder = node.parent.item.name;
}
return fields;
}
function getItemText(item: SidebarModel): string {
const segments = [];
if (item.model === 'http_request') {
segments.push(item.method);
}
segments.push(resolvedModelName(item));
return segments.join(' ');
}

View File

@@ -1,12 +1,11 @@
import type { EditorView } from '@codemirror/view';
import type { HttpRequest } from '@yaakapp-internal/models';
import classNames from 'classnames';
import type { FormEvent, ReactNode } from 'react';
import { memo, useRef, useState } from 'react';
import { useCallback, 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,15 @@ 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);
const handleInitInputRef = useCallback((h: InputHandle | null) => {
inputRef.current = h;
}, []);
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>) => {
@@ -64,7 +63,7 @@ export const UrlBar = memo(function UrlBar({
return (
<form onSubmit={handleSubmit} className={classNames('x-theme-urlBar', className)}>
<Input
ref={inputRef}
setRef={handleInitInputRef}
autocompleteFunctions
autocompleteVariables
stateKey={stateKey}

View File

@@ -1,7 +1,7 @@
import type { HttpRequest } from '@yaakapp-internal/models';
import { useRef } from 'react';
import { useCallback, useRef } from 'react';
import { useRequestEditor, useRequestEditorEvent } from '../hooks/useRequestEditor';
import type { PairEditorProps, PairEditorRef } from './core/PairEditor';
import type { PairEditorHandle, PairEditorProps } from './core/PairEditor';
import { PairOrBulkEditor } from './core/PairOrBulkEditor';
import { VStack } from './core/Stacks';
@@ -13,15 +13,19 @@ type Props = {
};
export function UrlParametersEditor({ pairs, forceUpdateKey, onChange, stateKey }: Props) {
const pairEditor = useRef<PairEditorRef>(null);
const pairEditorRef = useRef<PairEditorHandle>(null);
const handleInitPairEditorRef = useCallback((ref: PairEditorHandle) => {
return (pairEditorRef.current = ref);
}, []);
const [{ urlParametersKey }] = useRequestEditor();
useRequestEditorEvent(
'request_params.focus_value',
(name) => {
const pairIndex = pairs.findIndex((p) => p.name === name);
if (pairIndex >= 0) {
pairEditor.current?.focusValue(pairIndex);
const pair = pairs.find((p) => p.name === name);
if (pair?.id != null) {
pairEditorRef.current?.focusValue(pair.id);
} else {
console.log(`Couldn't find pair to focus`, { name, pairs });
}
@@ -32,7 +36,7 @@ export function UrlParametersEditor({ pairs, forceUpdateKey, onChange, stateKey
return (
<VStack className="h-full">
<PairOrBulkEditor
ref={pairEditor}
setRef={handleInitPairEditorRef}
allowMultilineValues
forceUpdateKey={forceUpdateKey + urlParametersKey}
nameAutocompleteFunctions

View File

@@ -229,7 +229,6 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
/>
</div>
<Tabs
key={activeRequest.id} // Freshen tabs on request change
value={activeTab}
label="Request"
onChangeValue={setActiveTab}

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>(
() => ({
@@ -156,15 +141,15 @@ export function Workspace() {
animate={{ opacity: 1, x: 0 }}
className={classNames(
'x-theme-sidebar',
'absolute top-0 left-0 bottom-0 bg-surface border-r border-border-subtle w-[14rem]',
'absolute top-0 left-0 bottom-0 bg-surface border-r border-border-subtle w-[20rem]',
'grid grid-rows-[auto_1fr]',
)}
>
<HeaderSize size="lg" className="border-transparent">
<HeaderSize hideControls size="lg" className="border-transparent flex items-center">
<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

@@ -26,6 +26,7 @@ interface Props {
export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEncryption }: Props) {
const [justEnabledEncryption, setJustEnabledEncryption] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const workspace = useAtomValue(activeWorkspaceAtom);
const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);
@@ -111,12 +112,22 @@ export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEn
color={expanded ? 'info' : 'secondary'}
size={size}
onClick={async () => {
setJustEnabledEncryption(true);
await enableEncryption(workspaceMeta.workspaceId);
setError(null);
try {
await enableEncryption(workspaceMeta.workspaceId);
setJustEnabledEncryption(true);
} catch (err) {
setError('Failed to enable encryption: ' + err);
}
}}
>
Enable Encryption
</Button>
{error && (
<Banner color="danger" className="mb-2">
{error}
</Banner>
)}
{expanded ? (
<Banner color="info" className="mb-6">
<EncryptionHelp />

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,9 +1,10 @@
import type { Color } from '@yaakapp-internal/plugins';
import type { FormEvent } from 'react';
import { useState } from 'react';
import { Button } from './Button';
import { PlainInput } from './PlainInput';
import { HStack } from './Stacks';
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";
export interface ConfirmProps {
onHide: () => void;
@@ -18,9 +19,9 @@ export function Confirm({
onResult,
confirmText,
requireTyping,
color = 'primary',
color = "primary",
}: ConfirmProps) {
const [confirm, setConfirm] = useState<string>('');
const [confirm, setConfirm] = useState<string>("");
const handleHide = () => {
onResult(false);
onHide();
@@ -42,16 +43,31 @@ export function Confirm({
<PlainInput
autoFocus
onChange={setConfirm}
placeholder={requireTyping}
labelRightSlot={
<CopyIconButton
tabIndex={-1}
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
</>
}
/>
)}
<HStack space={2} justifyContent="start" className="mt-2 mb-4 flex-row-reverse">
<HStack
space={2}
justifyContent="start"
className="mt-2 mb-4 flex-row-reverse"
>
<Button type="submit" color={color} disabled={!didConfirm}>
{confirmText ?? 'Confirm'}
{confirmText ?? "Confirm"}
</Button>
<Button onClick={handleHide} variant="border">
Cancel

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

@@ -19,15 +19,14 @@ import type { ReactNode, RefObject } from 'react';
import {
Children,
cloneElement,
forwardRef,
isValidElement,
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
} from 'react';
import { activeEnvironmentAtom } from '../../../hooks/useActiveEnvironment';
import { activeWorkspaceAtom } from '../../../hooks/useActiveWorkspace';
import type { WrappedEnvironmentVariable } from '../../../hooks/useEnvironmentVariables';
import { useEnvironmentVariables } from '../../../hooks/useEnvironmentVariables';
@@ -40,7 +39,6 @@ import { tryFormatJson, tryFormatXml } from '../../../lib/formatters';
import { jotaiStore } from '../../../lib/jotai';
import { withEncryptionEnabled } from '../../../lib/setupOrConfigureEncryption';
import { TemplateFunctionDialog } from '../../TemplateFunctionDialog';
import { TemplateVariableDialog } from '../../TemplateVariableDialog';
import { IconButton } from '../IconButton';
import { InlineCode } from '../InlineCode';
import { HStack } from '../Stacks';
@@ -75,14 +73,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;
@@ -97,6 +95,7 @@ export interface EditorProps {
tooltipContainer?: HTMLElement;
type?: 'text' | 'password';
wrapLines?: boolean;
setRef?: (view: EditorView | null) => void;
}
const stateFields = { history: historyField, folds: foldState };
@@ -104,41 +103,39 @@ const stateFields = { history: historyField, folds: foldState };
const emptyVariables: WrappedEnvironmentVariable[] = [];
const emptyExtension: Extension = [];
export const Editor = forwardRef<EditorView | undefined, EditorProps>(function Editor(
{
actions,
autoFocus,
autoSelect,
autocomplete,
autocompleteFunctions,
autocompleteVariables,
className,
defaultValue,
disableTabIndent,
disabled,
extraExtensions,
forcedEnvironmentId,
forceUpdateKey: forceUpdateKeyFromAbove,
format,
heightMode,
hideGutter,
graphQLSchema,
language,
onBlur,
onChange,
onFocus,
onKeyDown,
onPaste,
onPasteOverwrite,
placeholder,
readOnly,
singleLine,
stateKey,
type,
wrapLines,
}: EditorProps,
ref,
) {
export function Editor({
actions,
autoFocus,
autoSelect,
autocomplete,
autocompleteFunctions,
autocompleteVariables,
className,
defaultValue,
disableTabIndent,
disabled,
extraExtensions,
forcedEnvironmentId,
forceUpdateKey: forceUpdateKeyFromAbove,
format,
heightMode,
hideGutter,
graphQLSchema,
language,
onBlur,
onChange,
onFocus,
onKeyDown,
onPaste,
onPasteOverwrite,
placeholder,
readOnly,
singleLine,
stateKey,
type,
wrapLines,
setRef,
}: EditorProps) {
const settings = useAtomValue(settingsAtom);
const allEnvironmentVariables = useEnvironmentVariables(forcedEnvironmentId ?? null);
@@ -182,7 +179,6 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
}
const cm = useRef<{ view: EditorView; languageCompartment: Compartment } | null>(null);
useImperativeHandle(ref, () => cm.current?.view, []);
// Use ref so we can update the handler without re-initializing the editor
const handleChange = useRef<EditorProps['onChange']>(onChange);
@@ -324,33 +320,17 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
const onClickVariable = useCallback(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async (v: WrappedEnvironmentVariable, _tagValue: string, _startPos: number) => {
editEnvironment(v.environment);
await editEnvironment(v.environment, { addOrFocusVariable: v.variable });
},
[],
);
const onClickMissingVariable = useCallback(
async (_name: string, tagValue: string, startPos: number) => {
const initialTokens = parseTemplate(tagValue);
showDialog({
size: 'dynamic',
id: 'template-variable',
title: 'Configure Variable',
render: ({ hide }) => (
<TemplateVariableDialog
hide={hide}
initialTokens={initialTokens}
onChange={(insert) => {
cm.current?.view.dispatch({
changes: [{ from: startPos, to: startPos + tagValue.length, insert }],
});
}}
/>
),
});
},
[],
);
const onClickMissingVariable = useCallback(async (name: string) => {
const activeEnvironment = jotaiStore.get(activeEnvironmentAtom);
await editEnvironment(activeEnvironment, {
addOrFocusVariable: { name, value: '', enabled: true },
});
}, []);
const [, { focusParamValue }] = useRequestEditor();
const onClickPathParameter = useCallback(
@@ -439,7 +419,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);
@@ -465,6 +449,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
if (autoSelect) {
view.dispatch({ selection: { anchor: 0, head: view.state.doc.length } });
}
setRef?.(view);
} catch (e) {
console.log('Failed to initialize Codemirror', e);
}
@@ -576,7 +561,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
)}
</div>
);
});
}
function getExtensions({
stateKey,
@@ -636,15 +621,13 @@ function getExtensions({
// Things that must be last //
// ------------------------ //
// Fire onChange event
EditorView.updateListener.of((update) => {
if (update.startState === update.state) return;
if (onChange && update.docChanged) {
onChange.current?.(update.state.doc.toString());
}
}),
// Cache editor state
EditorView.updateListener.of((update) => {
saveCachedEditorState(stateKey, update.state);
}),
];

View File

@@ -1,13 +1,12 @@
import type { EditorView } from '@codemirror/view';
import { forwardRef, lazy, Suspense } from 'react';
import { lazy, Suspense } from 'react';
import type { EditorProps } from './Editor';
const Editor_ = lazy(() => import('./Editor').then((m) => ({ default: m.Editor })));
export const Editor = forwardRef<EditorView, EditorProps>(function LazyEditor(props, ref) {
export function Editor(props: EditorProps) {
return (
<Suspense>
<Editor_ ref={ref} {...props} />
<Editor_ {...props} />
</Suspense>
);
});
}

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,
@@ -61,7 +62,7 @@ export const syntaxHighlightStyle = HighlightStyle.define([
textDecoration: 'underline',
},
{
tag: [t.paren, t.bracket, t.squareBracket, t.brace, t.separator],
tag: [t.paren, t.bracket, t.squareBracket, t.brace, t.separator, t.punctuation],
color: 'var(--textSubtle)',
},
{
@@ -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,181 @@
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();
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,75 @@
@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
}
Term {
Word
}
@external propSource highlight from "./highlight"

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