Compare commits

...

10 Commits

Author SHA1 Message Date
Rahul Mishra
c4ce458f79 fix: pass down onClose properly (#376) 2026-01-31 07:34:40 -08:00
Gregory Schier
f02ae35634 Fix auth plugin dynamic form inputs broken after first request
The call_http_authentication_request handler was mutating auth.args with the result of applyDynamicFormInput(), which strips the dynamic callback functions. This permanently corrupted the plugin module's args, making all dynamic form controls (checkboxes, selects, etc.) unresponsive for that auth type after sending the first request.
2026-01-30 12:47:02 -08:00
Gregory Schier
c2f068970b Add external browser support for OAuth2 authorization (#375)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 10:29:49 -08:00
Gregory Schier
eec2d6bc38 Fix multipart tab value 2026-01-29 09:01:44 -08:00
Gregory Schier
efa22e470e Add diff viewer to git commit dialog (#374)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 08:50:56 -08:00
Gregory Schier
c00d2e981f Fix basic auth failing when password field is empty or unset
Handle undefined username/password values by defaulting to empty string,
preventing "undefined" from being encoded in the Authorization header.

Fixes https://feedback.yaak.app/p/strange-basic-auth-behaviour-in-202612

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 15:07:03 -08:00
Gregory Schier
9c45254952 Fix template tag theme colors 2026-01-28 13:08:22 -08:00
Gregory Schier
d031ff231a Bump plugin runtime types 2026-01-28 08:43:19 -08:00
Gregory Schier
f056894ddb Show full URL parts in Timeline debug view (#373)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 08:41:17 -08:00
dependabot[bot]
1b0315165f Bump hono from 4.11.4 to 4.11.7 (#372) 2026-01-28 08:37:10 -08:00
34 changed files with 1398 additions and 316 deletions

View File

@@ -6,6 +6,7 @@ import { useMemo } from 'react';
import { BranchDeleteResult, CloneResult, GitCommit, GitRemote, GitStatusSummary, PullResult, PushResult } from './bindings/gen_git';
export * from './bindings/gen_git';
export * from './bindings/gen_models';
export interface GitCredentials {
username: string;

View File

@@ -31,7 +31,14 @@ pub enum HttpResponseEvent {
},
SendUrl {
method: String,
scheme: String,
username: String,
password: String,
host: String,
port: u16,
path: String,
query: String,
fragment: String,
},
ReceiveUrl {
version: Version,
@@ -65,7 +72,16 @@ impl Display for HttpResponseEvent {
};
write!(f, "* Redirect {} -> {} ({})", status, url, behavior_str)
}
HttpResponseEvent::SendUrl { method, path } => write!(f, "> {} {}", method, path),
HttpResponseEvent::SendUrl { method, scheme, username, password, host, port, path, query, fragment } => {
let auth_str = if username.is_empty() && password.is_empty() {
String::new()
} else {
format!("{}:{}@", username, password)
};
let query_str = if query.is_empty() { String::new() } else { format!("?{}", query) };
let fragment_str = if fragment.is_empty() { String::new() } else { format!("#{}", fragment) };
write!(f, "> {} {}://{}{}:{}{}{}{}", method, scheme, auth_str, host, port, path, query_str, fragment_str)
}
HttpResponseEvent::ReceiveUrl { version, status } => {
write!(f, "< {} {}", version_to_str(version), status)
}
@@ -104,7 +120,9 @@ impl From<HttpResponseEvent> for yaak_models::models::HttpResponseEventData {
RedirectBehavior::DropBody => "drop_body".to_string(),
},
},
HttpResponseEvent::SendUrl { method, path } => D::SendUrl { method, path },
HttpResponseEvent::SendUrl { method, scheme, username, password, host, port, path, query, fragment } => {
D::SendUrl { method, scheme, username, password, host, port, path, query, fragment }
}
HttpResponseEvent::ReceiveUrl { version, status } => {
D::ReceiveUrl { version: format!("{:?}", version), status }
}
@@ -415,8 +433,15 @@ impl HttpSender for ReqwestSender {
));
send_event(HttpResponseEvent::SendUrl {
path: sendable_req.url().path().to_string(),
method: sendable_req.method().to_string(),
scheme: sendable_req.url().scheme().to_string(),
username: sendable_req.url().username().to_string(),
password: sendable_req.url().password().unwrap_or_default().to_string(),
host: sendable_req.url().host_str().unwrap_or_default().to_string(),
port: sendable_req.url().port_or_known_default().unwrap_or(0),
path: sendable_req.url().path().to_string(),
query: sendable_req.url().query().unwrap_or_default().to_string(),
fragment: sendable_req.url().fragment().unwrap_or_default().to_string(),
});
let mut request_headers = Vec::new();

View File

@@ -49,7 +49,7 @@ export type HttpResponseEvent = { model: "http_response_event", id: string, crea
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
* The `From` impl is in yaak-http to avoid circular dependencies.
*/
export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, path: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, };
export type HttpResponseEventData = { "type": "setting", name: string, value: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, } | { "type": "send_url", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, };
export type HttpResponseHeader = { name: string, value: string, };

View File

@@ -1495,7 +1495,21 @@ pub enum HttpResponseEventData {
},
SendUrl {
method: String,
#[serde(default)]
scheme: String,
#[serde(default)]
username: String,
#[serde(default)]
password: String,
#[serde(default)]
host: String,
#[serde(default)]
port: u16,
path: String,
#[serde(default)]
query: String,
#[serde(default)]
fragment: String,
},
ReceiveUrl {
version: String,

123
package-lock.json generated
View File

@@ -63,7 +63,7 @@
"src-web"
],
"devDependencies": {
"@biomejs/biome": "^2.3.10",
"@biomejs/biome": "^2.3.13",
"@tauri-apps/cli": "^2.9.6",
"@yaakapp/cli": "^0.3.4",
"dotenv-cli": "^11.0.0",
@@ -501,9 +501,9 @@
}
},
"node_modules/@biomejs/biome": {
"version": "2.3.11",
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.11.tgz",
"integrity": "sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ==",
"version": "2.3.13",
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.13.tgz",
"integrity": "sha512-Fw7UsV0UAtWIBIm0M7g5CRerpu1eKyKAXIazzxhbXYUyMkwNrkX/KLkGI7b+uVDQ5cLUMfOC9vR60q9IDYDstA==",
"dev": true,
"license": "MIT OR Apache-2.0",
"bin": {
@@ -517,20 +517,20 @@
"url": "https://opencollective.com/biome"
},
"optionalDependencies": {
"@biomejs/cli-darwin-arm64": "2.3.11",
"@biomejs/cli-darwin-x64": "2.3.11",
"@biomejs/cli-linux-arm64": "2.3.11",
"@biomejs/cli-linux-arm64-musl": "2.3.11",
"@biomejs/cli-linux-x64": "2.3.11",
"@biomejs/cli-linux-x64-musl": "2.3.11",
"@biomejs/cli-win32-arm64": "2.3.11",
"@biomejs/cli-win32-x64": "2.3.11"
"@biomejs/cli-darwin-arm64": "2.3.13",
"@biomejs/cli-darwin-x64": "2.3.13",
"@biomejs/cli-linux-arm64": "2.3.13",
"@biomejs/cli-linux-arm64-musl": "2.3.13",
"@biomejs/cli-linux-x64": "2.3.13",
"@biomejs/cli-linux-x64-musl": "2.3.13",
"@biomejs/cli-win32-arm64": "2.3.13",
"@biomejs/cli-win32-x64": "2.3.13"
}
},
"node_modules/@biomejs/cli-darwin-arm64": {
"version": "2.3.11",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.11.tgz",
"integrity": "sha512-/uXXkBcPKVQY7rc9Ys2CrlirBJYbpESEDme7RKiBD6MmqR2w3j0+ZZXRIL2xiaNPsIMMNhP1YnA+jRRxoOAFrA==",
"version": "2.3.13",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.13.tgz",
"integrity": "sha512-0OCwP0/BoKzyJHnFdaTk/i7hIP9JHH9oJJq6hrSCPmJPo8JWcJhprK4gQlhFzrwdTBAW4Bjt/RmCf3ZZe59gwQ==",
"cpu": [
"arm64"
],
@@ -545,9 +545,9 @@
}
},
"node_modules/@biomejs/cli-darwin-x64": {
"version": "2.3.11",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.11.tgz",
"integrity": "sha512-fh7nnvbweDPm2xEmFjfmq7zSUiox88plgdHF9OIW4i99WnXrAC3o2P3ag9judoUMv8FCSUnlwJCM1B64nO5Fbg==",
"version": "2.3.13",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.3.13.tgz",
"integrity": "sha512-AGr8OoemT/ejynbIu56qeil2+F2WLkIjn2d8jGK1JkchxnMUhYOfnqc9sVzcRxpG9Ycvw4weQ5sprRvtb7Yhcw==",
"cpu": [
"x64"
],
@@ -562,9 +562,9 @@
}
},
"node_modules/@biomejs/cli-linux-arm64": {
"version": "2.3.11",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.11.tgz",
"integrity": "sha512-l4xkGa9E7Uc0/05qU2lMYfN1H+fzzkHgaJoy98wO+b/7Gl78srbCRRgwYSW+BTLixTBrM6Ede5NSBwt7rd/i6g==",
"version": "2.3.13",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.3.13.tgz",
"integrity": "sha512-xvOiFkrDNu607MPMBUQ6huHmBG1PZLOrqhtK6pXJW3GjfVqJg0Z/qpTdhXfcqWdSZHcT+Nct2fOgewZvytESkw==",
"cpu": [
"arm64"
],
@@ -579,9 +579,9 @@
}
},
"node_modules/@biomejs/cli-linux-arm64-musl": {
"version": "2.3.11",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.11.tgz",
"integrity": "sha512-XPSQ+XIPZMLaZ6zveQdwNjbX+QdROEd1zPgMwD47zvHV+tCGB88VH+aynyGxAHdzL+Tm/+DtKST5SECs4iwCLg==",
"version": "2.3.13",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.3.13.tgz",
"integrity": "sha512-TUdDCSY+Eo/EHjhJz7P2GnWwfqet+lFxBZzGHldrvULr59AgahamLs/N85SC4+bdF86EhqDuuw9rYLvLFWWlXA==",
"cpu": [
"arm64"
],
@@ -596,9 +596,9 @@
}
},
"node_modules/@biomejs/cli-linux-x64": {
"version": "2.3.11",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.11.tgz",
"integrity": "sha512-/1s9V/H3cSe0r0Mv/Z8JryF5x9ywRxywomqZVLHAoa/uN0eY7F8gEngWKNS5vbbN/BsfpCG5yeBT5ENh50Frxg==",
"version": "2.3.13",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.3.13.tgz",
"integrity": "sha512-s+YsZlgiXNq8XkgHs6xdvKDFOj/bwTEevqEY6rC2I3cBHbxXYU1LOZstH3Ffw9hE5tE1sqT7U23C00MzkXztMw==",
"cpu": [
"x64"
],
@@ -613,9 +613,9 @@
}
},
"node_modules/@biomejs/cli-linux-x64-musl": {
"version": "2.3.11",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.11.tgz",
"integrity": "sha512-vU7a8wLs5C9yJ4CB8a44r12aXYb8yYgBn+WeyzbMjaCMklzCv1oXr8x+VEyWodgJt9bDmhiaW/I0RHbn7rsNmw==",
"version": "2.3.13",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.3.13.tgz",
"integrity": "sha512-0bdwFVSbbM//Sds6OjtnmQGp4eUjOTt6kHvR/1P0ieR9GcTUAlPNvPC3DiavTqq302W34Ae2T6u5VVNGuQtGlQ==",
"cpu": [
"x64"
],
@@ -630,9 +630,9 @@
}
},
"node_modules/@biomejs/cli-win32-arm64": {
"version": "2.3.11",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.11.tgz",
"integrity": "sha512-PZQ6ElCOnkYapSsysiTy0+fYX+agXPlWugh6+eQ6uPKI3vKAqNp6TnMhoM3oY2NltSB89hz59o8xIfOdyhi9Iw==",
"version": "2.3.13",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.3.13.tgz",
"integrity": "sha512-QweDxY89fq0VvrxME+wS/BXKmqMrOTZlN9SqQ79kQSIc3FrEwvW/PvUegQF6XIVaekncDykB5dzPqjbwSKs9DA==",
"cpu": [
"arm64"
],
@@ -647,9 +647,9 @@
}
},
"node_modules/@biomejs/cli-win32-x64": {
"version": "2.3.11",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.11.tgz",
"integrity": "sha512-43VrG813EW+b5+YbDbz31uUsheX+qFKCpXeY9kfdAx+ww3naKxeVkTD9zLIWxUPfJquANMHrmW3wbe/037G0Qg==",
"version": "2.3.13",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.3.13.tgz",
"integrity": "sha512-trDw2ogdM2lyav9WFQsdsfdVy1dvZALymRpgmWsvSez0BJzBjulhOT/t+wyKeh3pZWvwP3VMs1SoOKwO3wecMQ==",
"cpu": [
"x64"
],
@@ -807,6 +807,21 @@
"@lezer/xml": "^1.0.0"
}
},
"node_modules/@codemirror/lang-yaml": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.2.tgz",
"integrity": "sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.2.0",
"@lezer/lr": "^1.0.0",
"@lezer/yaml": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.12.1",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.1.tgz",
@@ -832,6 +847,19 @@
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/merge": {
"version": "6.11.2",
"resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.11.2.tgz",
"integrity": "sha512-NO5EJd2rLRbwVWLgMdhIntDIhfDtMOKYEZgqV5WnkNUS2oXOCVWLPjG/kgl/Jth2fGiOuG947bteqxP9nBXmMg==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/highlight": "^1.0.0",
"style-mod": "^4.1.0"
}
},
"node_modules/@codemirror/search": {
"version": "6.5.11",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz",
@@ -1614,6 +1642,17 @@
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/yaml": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.3.tgz",
"integrity": "sha512-GuBLekbw9jDBDhGur82nuwkxKQ+a3W5H0GfaAthDXcAu+XdpS43VlnxA9E9hllkpSP5ellRDKjLLj7Lu9Wr6xA==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.4.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
@@ -7811,9 +7850,9 @@
}
},
"node_modules/hono": {
"version": "4.11.4",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz",
"integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==",
"version": "4.11.7",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.7.tgz",
"integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
@@ -15721,7 +15760,7 @@
},
"packages/plugin-runtime-types": {
"name": "@yaakapp/api",
"version": "0.7.1",
"version": "0.8.0",
"dependencies": {
"@types/node": "^24.0.13"
},
@@ -15743,7 +15782,7 @@
"@hono/mcp": "^0.2.3",
"@hono/node-server": "^1.19.7",
"@modelcontextprotocol/sdk": "^1.25.2",
"hono": "^4.11.4",
"hono": "^4.11.7",
"zod": "^3.25.76"
},
"devDependencies": {
@@ -15984,7 +16023,9 @@
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-markdown": "^6.3.2",
"@codemirror/lang-xml": "^6.1.0",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.11.0",
"@codemirror/merge": "^6.11.2",
"@codemirror/search": "^6.5.11",
"@dnd-kit/core": "^6.3.1",
"@gilbarbara/deep-equal": "^0.3.1",

View File

@@ -95,7 +95,7 @@
"js-yaml": "^4.1.1"
},
"devDependencies": {
"@biomejs/biome": "^2.3.10",
"@biomejs/biome": "^2.3.13",
"@tauri-apps/cli": "^2.9.6",
"@yaakapp/cli": "^0.3.4",
"dotenv-cli": "^11.0.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@yaakapp/api",
"version": "0.7.1",
"version": "0.8.0",
"keywords": [
"api-client",
"insomnia-alternative",

View File

@@ -338,8 +338,8 @@ export class PluginInstance {
if (payload.type === 'call_http_authentication_request' && this.#mod?.authentication) {
const auth = this.#mod.authentication;
if (typeof auth?.onApply === 'function') {
auth.args = await applyDynamicFormInput(ctx, auth.args, payload);
payload.values = applyFormInputDefaults(auth.args, payload.values);
const resolvedArgs = await applyDynamicFormInput(ctx, auth.args, payload);
payload.values = applyFormInputDefaults(resolvedArgs, payload.values);
this.#sendPayload(
context,
{

View File

@@ -18,7 +18,7 @@
"@hono/mcp": "^0.2.3",
"@hono/node-server": "^1.19.7",
"@modelcontextprotocol/sdk": "^1.25.2",
"hono": "^4.11.4",
"hono": "^4.11.7",
"zod": "^3.25.76"
},
"devDependencies": {

View File

@@ -11,6 +11,7 @@
"version": "0.1.0",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev"
"dev": "yaakcli dev",
"test": "vitest --run tests"
}
}

View File

@@ -21,7 +21,8 @@ export const plugin: PluginDefinition = {
},
],
async onApply(_ctx, { values }) {
const { username, password } = values;
const username = values.username ?? '';
const password = values.password ?? '';
const value = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
return { setHeaders: [{ name: 'Authorization', value }] };
},

View File

@@ -0,0 +1,77 @@
import type { Context } from '@yaakapp/api';
import { describe, expect, test } from 'vitest';
import { plugin } from '../src';
const ctx = {} as Context;
describe('auth-basic', () => {
test('Both username and password', async () => {
expect(
await plugin.authentication?.onApply(ctx, {
values: { username: 'user', password: 'pass' },
headers: [],
url: 'https://yaak.app',
method: 'POST',
contextId: '111',
}),
).toEqual({
setHeaders: [{ name: 'Authorization', value: `Basic ${Buffer.from('user:pass').toString('base64')}` }],
});
});
test('Empty password', async () => {
expect(
await plugin.authentication?.onApply(ctx, {
values: { username: 'apikey', password: '' },
headers: [],
url: 'https://yaak.app',
method: 'POST',
contextId: '111',
}),
).toEqual({
setHeaders: [{ name: 'Authorization', value: `Basic ${Buffer.from('apikey:').toString('base64')}` }],
});
});
test('Missing password (undefined)', async () => {
expect(
await plugin.authentication?.onApply(ctx, {
values: { username: 'apikey' },
headers: [],
url: 'https://yaak.app',
method: 'POST',
contextId: '111',
}),
).toEqual({
setHeaders: [{ name: 'Authorization', value: `Basic ${Buffer.from('apikey:').toString('base64')}` }],
});
});
test('Missing username (undefined)', async () => {
expect(
await plugin.authentication?.onApply(ctx, {
values: { password: 'secret' },
headers: [],
url: 'https://yaak.app',
method: 'POST',
contextId: '111',
}),
).toEqual({
setHeaders: [{ name: 'Authorization', value: `Basic ${Buffer.from(':secret').toString('base64')}` }],
});
});
test('No values (both undefined)', async () => {
expect(
await plugin.authentication?.onApply(ctx, {
values: {},
headers: [],
url: 'https://yaak.app',
method: 'POST',
contextId: '111',
}),
).toEqual({
setHeaders: [{ name: 'Authorization', value: `Basic ${Buffer.from(':').toString('base64')}` }],
});
});
});

View File

@@ -0,0 +1,335 @@
import type { IncomingMessage, ServerResponse } from 'node:http';
import http from 'node:http';
import type { Context } from '@yaakapp/api';
export const HOSTED_CALLBACK_URL = 'https://oauth.yaak.app/redirect';
export const DEFAULT_LOCALHOST_PORT = 8765;
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
/** Singleton: only one callback server runs at a time across all OAuth flows. */
let activeServer: CallbackServerResult | null = null;
export interface CallbackServerResult {
/** The port the server is listening on */
port: number;
/** The full redirect URI to register with the OAuth provider */
redirectUri: string;
/** Promise that resolves with the callback URL when received */
waitForCallback: () => Promise<string>;
/** Stop the server */
stop: () => void;
}
/**
* Start a local HTTP server to receive OAuth callbacks.
* Only one server runs at a time — if a previous server is still active,
* it is stopped before starting the new one.
* Returns the port, redirect URI, and a promise that resolves when the callback is received.
*/
export function startCallbackServer(options: {
/** Specific port to use, or 0 for random available port */
port?: number;
/** Path for the callback endpoint */
path?: string;
/** Timeout in milliseconds (default 5 minutes) */
timeoutMs?: number;
}): Promise<CallbackServerResult> {
// Stop any previously active server before starting a new one
if (activeServer) {
console.log('[oauth2] Stopping previous callback server before starting new one');
activeServer.stop();
activeServer = null;
}
const { port = 0, path = '/callback', timeoutMs = CALLBACK_TIMEOUT_MS } = options;
return new Promise((resolve, reject) => {
let callbackResolve: ((url: string) => void) | null = null;
let callbackReject: ((err: Error) => void) | null = null;
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
let stopped = false;
const server = http.createServer((req: IncomingMessage, res: ServerResponse) => {
const reqUrl = new URL(req.url ?? '/', `http://${req.headers.host}`);
// Only handle the callback path
if (reqUrl.pathname !== path && reqUrl.pathname !== `${path}/`) {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not Found');
return;
}
if (req.method === 'POST') {
// POST: read JSON body with the final callback URL and resolve
let body = '';
req.on('data', (chunk: Buffer) => {
body += chunk.toString();
});
req.on('end', () => {
try {
const { url: callbackUrl } = JSON.parse(body);
if (!callbackUrl || typeof callbackUrl !== 'string') {
res.writeHead(400, { 'Content-Type': 'text/plain' });
res.end('Missing url in request body');
return;
}
// Send success response
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('OK');
// Resolve the callback promise
if (callbackResolve) {
callbackResolve(callbackUrl);
callbackResolve = null;
callbackReject = null;
}
// Stop the server after a short delay to ensure response is sent
setTimeout(() => stopServer(), 100);
} catch {
res.writeHead(400, { 'Content-Type': 'text/plain' });
res.end('Invalid JSON');
}
});
return;
}
// GET: serve intermediate page that reads the fragment and POSTs back
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(getFragmentForwardingHtml());
});
server.on('error', (err: Error) => {
if (!stopped) {
reject(err);
}
});
const stopServer = () => {
if (stopped) return;
stopped = true;
// Clear the singleton reference
if (activeServer?.stop === stopServer) {
activeServer = null;
}
if (timeoutHandle) {
clearTimeout(timeoutHandle);
timeoutHandle = null;
}
server.close();
if (callbackReject) {
callbackReject(new Error('Callback server stopped'));
callbackResolve = null;
callbackReject = null;
}
};
server.listen(port, '127.0.0.1', () => {
const address = server.address();
if (!address || typeof address === 'string') {
reject(new Error('Failed to get server address'));
return;
}
const actualPort = address.port;
const redirectUri = `http://127.0.0.1:${actualPort}${path}`;
console.log(`[oauth2] Callback server listening on ${redirectUri}`);
const result: CallbackServerResult = {
port: actualPort,
redirectUri,
waitForCallback: () => {
return new Promise<string>((res, rej) => {
if (stopped) {
rej(new Error('Callback server already stopped'));
return;
}
callbackResolve = res;
callbackReject = rej;
// Set timeout
timeoutHandle = setTimeout(() => {
if (callbackReject) {
callbackReject(new Error('Authorization timed out'));
callbackResolve = null;
callbackReject = null;
}
stopServer();
}, timeoutMs);
});
},
stop: stopServer,
};
activeServer = result;
resolve(result);
});
});
}
/**
* Build the redirect URI for the hosted callback page.
* The hosted page will redirect to the local server with the OAuth response.
*/
export function buildHostedCallbackRedirectUri(localPort: number, localPath: string): string {
const localRedirectUri = `http://127.0.0.1:${localPort}${localPath}`;
// The hosted callback page will read params and redirect to the local server
return `${HOSTED_CALLBACK_URL}?redirect_to=${encodeURIComponent(localRedirectUri)}`;
}
/**
* Open an authorization URL in the system browser, start a local callback server,
* and wait for the OAuth provider to redirect back.
*
* Returns the raw callback URL and the redirect URI that was registered with the
* OAuth provider (needed for token exchange).
*/
export async function getRedirectUrlViaExternalBrowser(
ctx: Context,
authorizationUrl: URL,
options: {
callbackType: 'localhost' | 'hosted';
callbackPort?: number;
},
): Promise<{ callbackUrl: string; redirectUri: string }> {
const { callbackType, callbackPort } = options;
// Determine port based on callback type:
// - localhost: use specified port or default stable port
// - hosted: use random port (0) since hosted page redirects to local
const port = callbackType === 'localhost' ? (callbackPort ?? DEFAULT_LOCALHOST_PORT) : 0;
console.log(
`[oauth2] Starting callback server (type: ${callbackType}, port: ${port || 'random'})`,
);
const server = await startCallbackServer({
port,
path: '/callback',
});
try {
// Determine the redirect URI to send to the OAuth provider
let oauthRedirectUri: string;
if (callbackType === 'hosted') {
oauthRedirectUri = buildHostedCallbackRedirectUri(server.port, '/callback');
console.log('[oauth2] Using hosted callback redirect:', oauthRedirectUri);
} else {
oauthRedirectUri = server.redirectUri;
console.log('[oauth2] Using localhost callback redirect:', oauthRedirectUri);
}
// Set the redirect URI on the authorization URL
authorizationUrl.searchParams.set('redirect_uri', oauthRedirectUri);
const authorizationUrlStr = authorizationUrl.toString();
console.log('[oauth2] Opening external browser:', authorizationUrlStr);
// Show toast to inform user
await ctx.toast.show({
message: 'Opening browser for authorization...',
icon: 'info',
timeout: 3000,
});
// Open the system browser
await ctx.window.openExternalUrl(authorizationUrlStr);
// Wait for the callback
console.log('[oauth2] Waiting for callback on', server.redirectUri);
const callbackUrl = await server.waitForCallback();
console.log('[oauth2] Received callback:', callbackUrl);
return { callbackUrl, redirectUri: oauthRedirectUri };
} finally {
server.stop();
}
}
/**
* Intermediate HTML page that reads the URL fragment and _fragment query param,
* reconstructs a proper OAuth callback URL, and POSTs it back to the server.
*
* Handles three cases:
* - Localhost implicit: fragment is in location.hash (e.g. #access_token=...)
* - Hosted implicit: fragment was converted to ?_fragment=... by the hosted redirect page
* - Auth code: no fragment, code is already in query params
*/
function getFragmentForwardingHtml(): string {
return `<!DOCTYPE html>
<html>
<head>
<title>Yaak</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: hsl(244,23%,14%);
color: hsl(245,23%,85%);
}
.container { text-align: center; }
.logo { display: block; width: 100px; height: 100px; margin: 0 auto 32px; border-radius: 50%; }
h1 { font-size: 28px; font-weight: 600; margin-bottom: 12px; }
p { font-size: 16px; color: hsl(245,18%,58%); }
</style>
</head>
<body>
<div class="container">
<svg class="logo" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(649.94,712.03,-712.03,649.94,179.25,220.59)"><stop offset="0" stop-color="#4cc48c"/><stop offset=".5" stop-color="#476cc9"/><stop offset="1" stop-color="#ba1ab7"/></linearGradient></defs><rect x="0" y="0" width="1024" height="1024" fill="url(#g)"/><g transform="matrix(0.822,0,0,0.822,91.26,91.26)"><path d="M766.775,105.176C902.046,190.129 992.031,340.639 992.031,512C992.031,706.357 876.274,873.892 710,949.361C684.748,838.221 632.417,791.074 538.602,758.96C536.859,790.593 545.561,854.983 522.327,856.611C477.951,859.719 321.557,782.368 310.75,710.135C300.443,641.237 302.536,535.834 294.475,482.283C86.974,483.114 245.65,303.256 245.65,303.256L261.925,368.357L294.475,368.357C294.475,368.357 298.094,296.03 310.75,286.981C326.511,275.713 366.457,254.592 473.502,254.431C519.506,190.629 692.164,133.645 766.775,105.176ZM603.703,352.082C598.577,358.301 614.243,384.787 623.39,401.682C639.967,432.299 672.34,459.32 760.231,456.739C780.796,456.135 808.649,456.743 831.555,448.316C919.689,369.191 665.548,260.941 652.528,270.706C629.157,288.235 677.433,340.481 685.079,352.082C663.595,350.818 630.521,352.121 603.703,352.082ZM515.817,516.822C491.026,516.822 470.898,536.949 470.898,561.741C470.898,586.532 491.026,606.66 515.817,606.66C540.609,606.66 560.736,586.532 560.736,561.741C560.736,536.949 540.609,516.822 515.817,516.822ZM656.608,969.83C610.979,984.25 562.391,992.031 512,992.031C247.063,992.031 31.969,776.937 31.969,512C31.969,247.063 247.063,31.969 512,31.969C581.652,31.969 647.859,46.835 707.634,73.574C674.574,86.913 627.224,104.986 620,103.081C343.573,30.201 98.64,283.528 98.64,511.993C98.64,761.842 376.244,989.043 627.831,910C637.21,907.053 645.743,936.753 656.608,969.83Z" fill="#fff"/></g></svg>
<h1 id="title">Authorizing...</h1>
<p id="message">Please wait</p>
</div>
<script>
(function() {
var title = document.getElementById('title');
var message = document.getElementById('message');
var url = new URL(window.location.href);
var fragment = window.location.hash;
var fragmentParam = url.searchParams.get('_fragment');
// Build the final callback URL:
// 1. If _fragment query param exists (from hosted redirect), convert it back to a real fragment
// 2. If location.hash exists (direct localhost implicit), use it as-is
// 3. Otherwise (auth code flow), use the URL as-is with query params
if (fragmentParam) {
url.searchParams.delete('_fragment');
url.hash = fragmentParam;
} else if (fragment && fragment.length > 1) {
url.hash = fragment;
}
// POST the final URL back to the callback server
fetch(url.pathname, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: url.toString() })
}).then(function(res) {
if (res.ok) {
title.textContent = 'Authorization Complete';
message.textContent = 'You may close this tab and return to Yaak';
} else {
title.textContent = 'Authorization Failed';
message.textContent = 'Something went wrong. Please try again.';
}
}).catch(function() {
title.textContent = 'Authorization Failed';
message.textContent = 'Something went wrong. Please try again.';
});
})();
</script>
</body>
</html>`;
}

View File

@@ -1,5 +1,6 @@
import { createHash, randomBytes } from 'node:crypto';
import type { Context } from '@yaakapp/api';
import { getRedirectUrlViaExternalBrowser } from '../callbackServer';
import { fetchAccessToken } from '../fetchAccessToken';
import { getOrRefreshAccessToken } from '../getOrRefreshAccessToken';
import type { AccessToken, TokenStoreArgs } from '../store';
@@ -10,6 +11,15 @@ export const PKCE_SHA256 = 'S256';
export const PKCE_PLAIN = 'plain';
export const DEFAULT_PKCE_METHOD = PKCE_SHA256;
export type CallbackType = 'localhost' | 'hosted';
export interface ExternalBrowserOptions {
useExternalBrowser: boolean;
callbackType: CallbackType;
/** Port for localhost callback (only used when callbackType is 'localhost') */
callbackPort?: number;
}
export async function getAuthorizationCode(
ctx: Context,
contextId: string,
@@ -25,6 +35,7 @@ export async function getAuthorizationCode(
credentialsInBody,
pkce,
tokenName,
externalBrowser,
}: {
authorizationUrl: string;
accessTokenUrl: string;
@@ -40,6 +51,7 @@ export async function getAuthorizationCode(
codeVerifier: string;
} | null;
tokenName: 'access_token' | 'id_token';
externalBrowser?: ExternalBrowserOptions;
},
): Promise<AccessToken> {
const tokenArgs: TokenStoreArgs = {
@@ -68,7 +80,6 @@ export async function getAuthorizationCode(
}
authorizationUrl.searchParams.set('response_type', 'code');
authorizationUrl.searchParams.set('client_id', clientId);
if (redirectUri) authorizationUrl.searchParams.set('redirect_uri', redirectUri);
if (scope) authorizationUrl.searchParams.set('scope', scope);
if (state) authorizationUrl.searchParams.set('state', state);
if (audience) authorizationUrl.searchParams.set('audience', audience);
@@ -80,12 +91,65 @@ export async function getAuthorizationCode(
authorizationUrl.searchParams.set('code_challenge_method', pkce.challengeMethod);
}
let code: string;
let actualRedirectUri: string | null = redirectUri;
// Use external browser flow if enabled
if (externalBrowser?.useExternalBrowser) {
const result = await getRedirectUrlViaExternalBrowser(ctx, authorizationUrl, {
callbackType: externalBrowser.callbackType,
callbackPort: externalBrowser.callbackPort,
});
// Pass null to skip redirect URI matching — the callback came from our own local server
const extractedCode = extractCode(result.callbackUrl, null);
if (!extractedCode) {
throw new Error('No authorization code found in callback URL');
}
code = extractedCode;
actualRedirectUri = result.redirectUri;
} else {
// Use embedded browser flow (original behavior)
if (redirectUri) {
authorizationUrl.searchParams.set('redirect_uri', redirectUri);
}
code = await getCodeViaEmbeddedBrowser(ctx, contextId, authorizationUrl, redirectUri);
}
console.log('[oauth2] Code found');
const response = await fetchAccessToken(ctx, {
grantType: 'authorization_code',
accessTokenUrl,
clientId,
clientSecret,
scope,
audience,
credentialsInBody,
params: [
{ name: 'code', value: code },
...(pkce ? [{ name: 'code_verifier', value: pkce.codeVerifier }] : []),
...(actualRedirectUri ? [{ name: 'redirect_uri', value: actualRedirectUri }] : []),
],
});
return storeToken(ctx, tokenArgs, response, tokenName);
}
/**
* Get authorization code using the embedded browser window.
* This is the original flow that monitors navigation events.
*/
async function getCodeViaEmbeddedBrowser(
ctx: Context,
contextId: string,
authorizationUrl: URL,
redirectUri: string | null,
): Promise<string> {
const dataDirKey = await getDataDirKey(ctx, contextId);
const authorizationUrlStr = authorizationUrl.toString();
console.log('[oauth2] Authorizing', authorizationUrlStr);
console.log('[oauth2] Authorizing via embedded browser', authorizationUrlStr);
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: none
const code = await new Promise<string>(async (resolve, reject) => {
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: Required for this pattern
return new Promise<string>(async (resolve, reject) => {
let foundCode = false;
const { close } = await ctx.window.openUrl({
dataDirKey,
@@ -110,31 +174,12 @@ export async function getAuthorizationCode(
return;
}
// Close the window here, because we don't need it anymore!
foundCode = true;
close();
resolve(code);
},
});
});
console.log('[oauth2] Code found');
const response = await fetchAccessToken(ctx, {
grantType: 'authorization_code',
accessTokenUrl,
clientId,
clientSecret,
scope,
audience,
credentialsInBody,
params: [
{ name: 'code', value: code },
...(pkce ? [{ name: 'code_verifier', value: pkce.codeVerifier }] : []),
...(redirectUri ? [{ name: 'redirect_uri', value: redirectUri }] : []),
],
});
return storeToken(ctx, tokenArgs, response, tokenName);
}
export function genPkceCodeVerifier() {

View File

@@ -1,7 +1,9 @@
import type { Context } from '@yaakapp/api';
import { getRedirectUrlViaExternalBrowser } from '../callbackServer';
import type { AccessToken, AccessTokenRawResponse } from '../store';
import { getDataDirKey, getToken, storeToken } from '../store';
import { isTokenExpired } from '../util';
import type { ExternalBrowserOptions } from './authorizationCode';
export async function getImplicit(
ctx: Context,
@@ -15,6 +17,7 @@ export async function getImplicit(
state,
audience,
tokenName,
externalBrowser,
}: {
authorizationUrl: string;
responseType: string;
@@ -24,6 +27,7 @@ export async function getImplicit(
state: string | null;
audience: string | null;
tokenName: 'access_token' | 'id_token';
externalBrowser?: ExternalBrowserOptions;
},
): Promise<AccessToken> {
const tokenArgs = {
@@ -43,9 +47,8 @@ export async function getImplicit(
} catch {
throw new Error(`Invalid authorization URL "${authorizationUrlRaw}"`);
}
authorizationUrl.searchParams.set('response_type', 'token');
authorizationUrl.searchParams.set('response_type', responseType);
authorizationUrl.searchParams.set('client_id', clientId);
if (redirectUri) authorizationUrl.searchParams.set('redirect_uri', redirectUri);
if (scope) authorizationUrl.searchParams.set('scope', scope);
if (state) authorizationUrl.searchParams.set('state', state);
if (audience) authorizationUrl.searchParams.set('audience', audience);
@@ -56,11 +59,55 @@ export async function getImplicit(
);
}
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: none
const newToken = await new Promise<AccessToken>(async (resolve, reject) => {
let newToken: AccessToken;
// Use external browser flow if enabled
if (externalBrowser?.useExternalBrowser) {
const result = await getRedirectUrlViaExternalBrowser(ctx, authorizationUrl, {
callbackType: externalBrowser.callbackType,
callbackPort: externalBrowser.callbackPort,
});
newToken = await extractImplicitToken(ctx, result.callbackUrl, tokenArgs, tokenName);
} else {
// Use embedded browser flow (original behavior)
if (redirectUri) {
authorizationUrl.searchParams.set('redirect_uri', redirectUri);
}
newToken = await getTokenViaEmbeddedBrowser(
ctx,
contextId,
authorizationUrl,
tokenArgs,
tokenName,
);
}
return newToken;
}
/**
* Get token using the embedded browser window.
* This is the original flow that monitors navigation events.
*/
async function getTokenViaEmbeddedBrowser(
ctx: Context,
contextId: string,
authorizationUrl: URL,
tokenArgs: {
contextId: string;
clientId: string;
accessTokenUrl: null;
authorizationUrl: string;
},
tokenName: 'access_token' | 'id_token',
): Promise<AccessToken> {
const dataDirKey = await getDataDirKey(ctx, contextId);
const authorizationUrlStr = authorizationUrl.toString();
console.log('[oauth2] Authorizing via embedded browser (implicit)', authorizationUrlStr);
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: Required for this pattern
return new Promise<AccessToken>(async (resolve, reject) => {
let foundAccessToken = false;
const authorizationUrlStr = authorizationUrl.toString();
const dataDirKey = await getDataDirKey(ctx, contextId);
const { close } = await ctx.window.openUrl({
dataDirKey,
url: authorizationUrlStr,
@@ -97,6 +144,56 @@ export async function getImplicit(
},
});
});
return newToken;
}
/**
* Extract the implicit grant token from a callback URL and store it.
*/
async function extractImplicitToken(
ctx: Context,
callbackUrl: string,
tokenArgs: {
contextId: string;
clientId: string;
accessTokenUrl: null;
authorizationUrl: string;
},
tokenName: 'access_token' | 'id_token',
): Promise<AccessToken> {
const url = new URL(callbackUrl);
// Check for errors
if (url.searchParams.has('error')) {
throw new Error(`Failed to authorize: ${url.searchParams.get('error')}`);
}
// Extract token from fragment
const hash = url.hash.slice(1);
const params = new URLSearchParams(hash);
// Also check query params (in case fragment was converted)
const accessToken = params.get(tokenName) ?? url.searchParams.get(tokenName);
if (!accessToken) {
throw new Error(`No ${tokenName} found in callback URL`);
}
// Build response from params (prefer fragment, fall back to query)
const response: AccessTokenRawResponse = {
access_token: params.get('access_token') ?? url.searchParams.get('access_token') ?? '',
token_type: params.get('token_type') ?? url.searchParams.get('token_type') ?? undefined,
expires_in: params.has('expires_in')
? parseInt(params.get('expires_in') ?? '0', 10)
: url.searchParams.has('expires_in')
? parseInt(url.searchParams.get('expires_in') ?? '0', 10)
: undefined,
scope: params.get('scope') ?? url.searchParams.get('scope') ?? undefined,
};
// Include id_token if present
const idToken = params.get('id_token') ?? url.searchParams.get('id_token');
if (idToken) {
response.id_token = idToken;
}
return storeToken(ctx, tokenArgs, response);
}

View File

@@ -5,7 +5,9 @@ import type {
JsonPrimitive,
PluginDefinition,
} from '@yaakapp/api';
import { DEFAULT_LOCALHOST_PORT, HOSTED_CALLBACK_URL } from './callbackServer';
import {
type CallbackType,
DEFAULT_PKCE_METHOD,
genPkceCodeVerifier,
getAuthorizationCode,
@@ -134,8 +136,6 @@ export const plugin: PluginDefinition = {
defaultValue: defaultGrantType,
options: grantTypes,
},
// Always-present fields
{
type: 'text',
name: 'clientId',
@@ -169,11 +169,105 @@ export const plugin: PluginDefinition = {
completionOptions: accessTokenUrls.map((url) => ({ label: url, value: url })),
},
{
type: 'text',
name: 'redirectUri',
label: 'Redirect URI',
optional: true,
dynamic: hiddenIfNot(['authorization_code', 'implicit']),
type: 'banner',
inputs: [
{
type: 'checkbox',
name: 'useExternalBrowser',
label: 'Use External Browser',
description:
'Open authorization URL in your system browser instead of the embedded browser. ' +
'Useful when the OAuth provider blocks embedded browsers or you need existing browser sessions.',
dynamic: hiddenIfNot(['authorization_code', 'implicit']),
},
{
type: 'text',
name: 'redirectUri',
label: 'Redirect URI',
description:
'URI the OAuth provider redirects to after authorization. Yaak intercepts this automatically in its embedded browser so any valid URI will work.',
optional: true,
dynamic: hiddenIfNot(
['authorization_code', 'implicit'],
({ useExternalBrowser }) => !useExternalBrowser,
),
},
{
type: 'h_stack',
inputs: [
{
type: 'select',
name: 'callbackType',
label: 'Callback Type',
description:
'"Hosted Redirect" uses an external Yaak-hosted endpoint. "Localhost" starts a local server to receive the callback.',
defaultValue: 'hosted',
options: [
{ label: 'Hosted Redirect', value: 'hosted' },
{ label: 'Localhost', value: 'localhost' },
],
dynamic: hiddenIfNot(
['authorization_code', 'implicit'],
({ useExternalBrowser }) => !!useExternalBrowser,
),
},
{
type: 'text',
name: 'callbackPort',
label: 'Callback Port',
placeholder: `${DEFAULT_LOCALHOST_PORT}`,
description:
'Port for the local callback server. Defaults to ' +
DEFAULT_LOCALHOST_PORT +
' if empty.',
optional: true,
dynamic: hiddenIfNot(
['authorization_code', 'implicit'],
({ useExternalBrowser, callbackType }) =>
!!useExternalBrowser && callbackType === 'localhost',
),
},
],
},
{
type: 'banner',
color: 'info',
inputs: [
{
type: 'markdown',
content: 'Redirect URI to Register',
async dynamic(_ctx, { values }) {
const grantType = String(values.grantType ?? defaultGrantType);
const useExternalBrowser = !!values.useExternalBrowser;
const callbackType = (stringArg(values, 'callbackType') ||
'localhost') as CallbackType;
// Only show for authorization_code and implicit with external browser enabled
if (
!['authorization_code', 'implicit'].includes(grantType) ||
!useExternalBrowser
) {
return { hidden: true };
}
// Compute the redirect URI based on callback type
let redirectUri: string;
if (callbackType === 'hosted') {
redirectUri = HOSTED_CALLBACK_URL;
} else {
const port = intArg(values, 'callbackPort') || DEFAULT_LOCALHOST_PORT;
redirectUri = `http://127.0.0.1:${port}/callback`;
}
return {
hidden: false,
content: `Register \`${redirectUri}\` as a redirect URI with your OAuth provider.`,
};
},
},
],
},
],
},
{
type: 'text',
@@ -182,12 +276,8 @@ export const plugin: PluginDefinition = {
optional: true,
dynamic: hiddenIfNot(['authorization_code', 'implicit']),
},
{
type: 'text',
name: 'audience',
label: 'Audience',
optional: true,
},
{ type: 'text', name: 'scope', label: 'Scope', optional: true },
{ type: 'text', name: 'audience', label: 'Audience', optional: true },
{
type: 'select',
name: 'tokenName',
@@ -203,44 +293,54 @@ export const plugin: PluginDefinition = {
dynamic: hiddenIfNot(['authorization_code', 'implicit']),
},
{
type: 'checkbox',
name: 'usePkce',
label: 'Use PKCE',
dynamic: hiddenIfNot(['authorization_code']),
},
{
type: 'select',
name: 'pkceChallengeMethod',
label: 'Code Challenge Method',
options: [
{ label: 'SHA-256', value: PKCE_SHA256 },
{ label: 'Plain', value: PKCE_PLAIN },
type: 'banner',
inputs: [
{
type: 'checkbox',
name: 'usePkce',
label: 'Use PKCE',
dynamic: hiddenIfNot(['authorization_code']),
},
{
type: 'select',
name: 'pkceChallengeMethod',
label: 'Code Challenge Method',
options: [
{ label: 'SHA-256', value: PKCE_SHA256 },
{ label: 'Plain', value: PKCE_PLAIN },
],
defaultValue: DEFAULT_PKCE_METHOD,
dynamic: hiddenIfNot(['authorization_code'], ({ usePkce }) => !!usePkce),
},
{
type: 'text',
name: 'pkceCodeChallenge',
label: 'Code Verifier',
placeholder: 'Automatically generated when not set',
optional: true,
dynamic: hiddenIfNot(['authorization_code'], ({ usePkce }) => !!usePkce),
},
],
defaultValue: DEFAULT_PKCE_METHOD,
dynamic: hiddenIfNot(['authorization_code'], ({ usePkce }) => !!usePkce),
},
{
type: 'text',
name: 'pkceCodeChallenge',
label: 'Code Verifier',
placeholder: 'Automatically generated when not set',
optional: true,
dynamic: hiddenIfNot(['authorization_code'], ({ usePkce }) => !!usePkce),
},
{
type: 'text',
name: 'username',
label: 'Username',
optional: true,
dynamic: hiddenIfNot(['password']),
},
{
type: 'text',
name: 'password',
label: 'Password',
password: true,
optional: true,
dynamic: hiddenIfNot(['password']),
type: 'h_stack',
inputs: [
{
type: 'text',
name: 'username',
label: 'Username',
optional: true,
dynamic: hiddenIfNot(['password']),
},
{
type: 'text',
name: 'password',
label: 'Password',
password: true,
optional: true,
dynamic: hiddenIfNot(['password']),
},
],
},
{
type: 'select',
@@ -258,7 +358,6 @@ export const plugin: PluginDefinition = {
type: 'accordion',
label: 'Advanced',
inputs: [
{ type: 'text', name: 'scope', label: 'Scope', optional: true },
{
type: 'text',
name: 'headerName',
@@ -321,6 +420,16 @@ export const plugin: PluginDefinition = {
const credentialsInBody = values.credentials === 'body';
const tokenName = values.tokenName === 'id_token' ? 'id_token' : 'access_token';
// Build external browser options if enabled
const useExternalBrowser = !!values.useExternalBrowser;
const externalBrowserOptions = useExternalBrowser
? {
useExternalBrowser: true,
callbackType: (stringArg(values, 'callbackType') || 'localhost') as CallbackType,
callbackPort: intArg(values, 'callbackPort') ?? undefined,
}
: undefined;
let token: AccessToken;
if (grantType === 'authorization_code') {
const authorizationUrl = stringArg(values, 'authorizationUrl');
@@ -348,6 +457,7 @@ export const plugin: PluginDefinition = {
}
: null,
tokenName: tokenName,
externalBrowser: externalBrowserOptions,
});
} else if (grantType === 'implicit') {
const authorizationUrl = stringArg(values, 'authorizationUrl');
@@ -362,6 +472,7 @@ export const plugin: PluginDefinition = {
audience: stringArgOrNull(values, 'audience'),
state: stringArgOrNull(values, 'state'),
tokenName: tokenName,
externalBrowser: externalBrowserOptions,
});
} else if (grantType === 'client_credentials') {
const accessTokenUrl = stringArg(values, 'accessTokenUrl');
@@ -414,3 +525,10 @@ function stringArg(values: Record<string, JsonPrimitive | undefined>, name: stri
if (!arg) return '';
return arg;
}
function intArg(values: Record<string, JsonPrimitive | undefined>, name: string): number | null {
const arg = values[name];
if (arg == null || arg === '') return null;
const num = parseInt(`${arg}`, 10);
return Number.isNaN(num) ? null : num;
}

View File

@@ -19,9 +19,6 @@ export const synthwave84: Theme = {
danger: 'hsl(340, 100%, 65%)',
},
components: {
dialog: {
surface: 'hsl(253, 45%, 12%)',
},
sidebar: {
surface: 'hsl(253, 42%, 18%)',
border: 'hsl(253, 40%, 22%)',

View File

@@ -83,7 +83,7 @@ export function DynamicForm<T extends Record<string, JsonPrimitive>>({
function FormInputsStack<T extends Record<string, JsonPrimitive>>({
className,
...props
}: FormInputsProps<T> & { className?: string}) {
}: FormInputsProps<T> & { className?: string }) {
return (
<VStack
space={3}
@@ -198,6 +198,9 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
/>
);
case 'accordion':
if (!hasVisibleInputs(input.inputs)) {
return null;
}
return (
<div key={i + stateKey}>
<DetailsBanner
@@ -219,6 +222,9 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
</div>
);
case 'h_stack':
if (!hasVisibleInputs(input.inputs)) {
return null;
}
return (
<div className="flex flex-wrap sm:flex-nowrap gap-3 items-end" key={i + stateKey}>
<FormInputs
@@ -233,6 +239,9 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
</div>
);
case 'banner':
if (!hasVisibleInputs(input.inputs)) {
return null;
}
return (
<Banner
key={i + stateKey}
@@ -603,3 +612,8 @@ function KeyValueArg({
</div>
);
}
function hasVisibleInputs(inputs: FormInput[] | undefined): boolean {
if (!inputs) return false;
return inputs.some((i) => !i.hidden);
}

View File

@@ -98,13 +98,14 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
renderRow={({ event, isActive, onClick }) => (
<GrpcEventRow event={event} isActive={isActive} onClick={onClick} />
)}
renderDetail={({ event }) => (
renderDetail={({ event, onClose }) => (
<GrpcEventDetail
event={event}
showLarge={showLarge}
showingLarge={showingLarge}
setShowLarge={setShowLarge}
setShowingLarge={setShowingLarge}
onClose={onClose}
/>
)}
/>
@@ -147,19 +148,26 @@ function GrpcEventDetail({
showingLarge,
setShowLarge,
setShowingLarge,
onClose,
}: {
event: GrpcEvent;
showLarge: boolean;
showingLarge: boolean;
setShowLarge: (v: boolean) => void;
setShowingLarge: (v: boolean) => void;
onClose: () => void;
}) {
if (event.eventType === 'client_message' || event.eventType === 'server_message') {
const title = `Message ${event.eventType === 'client_message' ? 'Sent' : 'Received'}`;
return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
<EventDetailHeader title={title} timestamp={event.createdAt} copyText={event.content} />
<EventDetailHeader
title={title}
timestamp={event.createdAt}
copyText={event.content}
onClose={onClose}
/>
{!showLarge && event.content.length > 1000 * 1000 ? (
<VStack space={2} className="italic text-text-subtlest">
Message previews larger than 1MB are hidden
@@ -197,7 +205,7 @@ function GrpcEventDetail({
// Error or connection_end - show metadata/trailers
return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
<EventDetailHeader title={event.content} timestamp={event.createdAt} />
<EventDetailHeader title={event.content} timestamp={event.createdAt} onClose={onClose} />
{event.error && (
<div className="select-text cursor-text text-sm font-mono py-1 text-warning">
{event.error}

View File

@@ -62,9 +62,7 @@ export function HttpAuthenticationEditor({ model }: Props) {
<p>
Apply auth to all requests in <strong>{resolvedModelName(model)}</strong>
</p>
<Link href="https://yaak.app/docs/using-yaak/request-inheritance">
Documentation
</Link>
<Link href="https://yaak.app/docs/using-yaak/request-inheritance">Documentation</Link>
</EmptyStateText>
);
}
@@ -140,7 +138,12 @@ export function HttpAuthenticationEditor({ model }: Props) {
}),
)}
>
<IconButton title="Authentication Actions" icon="settings" size="xs" />
<IconButton
title="Authentication Actions"
icon="settings"
size="xs"
className="!text-secondary"
/>
</Dropdown>
)}
</HStack>

View File

@@ -149,14 +149,27 @@ function EventDetails({
);
}
// Request URL - show method and path separately
// Request URL - show all URL parts separately
if (e.type === 'send_url') {
const auth = e.username || e.password ? `${e.username}:${e.password}@` : '';
const isDefaultPort =
(e.scheme === 'http' && e.port === 80) || (e.scheme === 'https' && e.port === 443);
const portStr = isDefaultPort ? '' : `:${e.port}`;
const query = e.query ? `?${e.query}` : '';
const fragment = e.fragment ? `#${e.fragment}` : '';
const fullUrl = `${e.scheme}://${auth}${e.host}${portStr}${e.path}${query}${fragment}`;
return (
<KeyValueRows>
<KeyValueRow label="Method">
<HttpMethodTagRaw forceColor method={e.method} />
</KeyValueRow>
<KeyValueRow label="URL">{fullUrl}</KeyValueRow>
<KeyValueRow label="Method">{e.method}</KeyValueRow>
<KeyValueRow label="Scheme">{e.scheme}</KeyValueRow>
{e.username ? <KeyValueRow label="Username">{e.username}</KeyValueRow> : null}
{e.password ? <KeyValueRow label="Password">{e.password}</KeyValueRow> : null}
<KeyValueRow label="Host">{e.host}</KeyValueRow>
{!isDefaultPort ? <KeyValueRow label="Port">{e.port}</KeyValueRow> : null}
<KeyValueRow label="Path">{e.path}</KeyValueRow>
{e.query ? <KeyValueRow label="Query">{e.query}</KeyValueRow> : null}
{e.fragment ? <KeyValueRow label="Fragment">{e.fragment}</KeyValueRow> : null}
</KeyValueRows>
);
}
@@ -244,7 +257,10 @@ type EventTextParts = { prefix: '>' | '<' | '*'; text: string };
function getEventTextParts(event: HttpResponseEventData): EventTextParts {
switch (event.type) {
case 'send_url':
return { prefix: '>', text: `${event.method} ${event.path}` };
return {
prefix: '>',
text: `${event.method} ${event.path}${event.query ? `?${event.query}` : ''}${event.fragment ? `#${event.fragment}` : ''}`,
};
case 'receive_url':
return { prefix: '<', text: `${event.version} ${event.status}` };
case 'header_up':
@@ -265,9 +281,15 @@ function getEventTextParts(event: HttpResponseEventData): EventTextParts {
return { prefix: '*', text: `[${formatBytes(event.bytes)} received]` };
case 'dns_resolved':
if (event.overridden) {
return { prefix: '*', text: `DNS override ${event.hostname} -> ${event.addresses.join(', ')}` };
return {
prefix: '*',
text: `DNS override ${event.hostname} -> ${event.addresses.join(', ')}`,
};
}
return { prefix: '*', text: `DNS resolved ${event.hostname} to ${event.addresses.join(', ')} (${event.duration}ms)` };
return {
prefix: '*',
text: `DNS resolved ${event.hostname} to ${event.addresses.join(', ')} (${event.duration}ms)`,
};
default:
return { prefix: '*', text: '[unknown event]' };
}
@@ -314,7 +336,7 @@ function getEventDisplay(event: HttpResponseEventData): EventDisplay {
icon: 'arrow_big_up_dash',
color: 'primary',
label: 'Request',
summary: `${event.method} ${event.path}`,
summary: `${event.method} ${event.path}${event.query ? `?${event.query}` : ''}${event.fragment ? `#${event.fragment}` : ''}`,
};
case 'receive_url':
return {

View File

@@ -13,7 +13,7 @@ import { useStateWithDeps } from '../hooks/useStateWithDeps';
import { languageFromContentType } from '../lib/contentType';
import { Button } from './core/Button';
import { Editor } from './core/Editor/LazyEditor';
import { EventDetailHeader, EventViewer, type EventDetailAction } from './core/EventViewer';
import { type EventDetailAction, EventDetailHeader, EventViewer } from './core/EventViewer';
import { EventViewerRow } from './core/EventViewerRow';
import { HotkeyList } from './core/HotkeyList';
import { Icon } from './core/Icon';
@@ -75,7 +75,7 @@ export function WebsocketResponsePane({ activeRequest }: Props) {
renderRow={({ event, isActive, onClick }) => (
<WebsocketEventRow event={event} isActive={isActive} onClick={onClick} />
)}
renderDetail={({ event, index }) => (
renderDetail={({ event, index, onClose }) => (
<WebsocketEventDetail
event={event}
hexDump={hexDumps[index] ?? event.messageType === 'binary'}
@@ -84,6 +84,7 @@ export function WebsocketResponsePane({ activeRequest }: Props) {
showingLarge={showingLarge}
setShowLarge={setShowLarge}
setShowingLarge={setShowingLarge}
onClose={onClose}
/>
)}
/>
@@ -145,6 +146,7 @@ function WebsocketEventDetail({
showingLarge,
setShowLarge,
setShowingLarge,
onClose,
}: {
event: WebsocketEvent;
hexDump: boolean;
@@ -153,6 +155,7 @@ function WebsocketEventDetail({
showingLarge: boolean;
setShowLarge: (v: boolean) => void;
setShowingLarge: (v: boolean) => void;
onClose: () => void;
}) {
const message = useMemo(() => {
if (hexDump) {
@@ -185,11 +188,12 @@ function WebsocketEventDetail({
return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
<EventDetailHeader
title={title}
timestamp={event.createdAt}
actions={actions}
copyText={formattedMessage || undefined}
/>
title={title}
timestamp={event.createdAt}
actions={actions}
copyText={formattedMessage || undefined}
onClose={onClose}
/>
{!showLarge && event.message.length > 1000 * 1000 ? (
<VStack space={2} className="italic text-text-subtlest">
Message previews larger than 1MB are hidden

View File

@@ -0,0 +1,39 @@
.cm-wrapper.cm-multiline .cm-mergeView {
@apply h-full w-full overflow-auto pr-0.5;
.cm-mergeViewEditors {
@apply w-full min-h-full;
}
.cm-mergeViewEditor {
@apply w-full min-h-full relative;
.cm-collapsedLines {
@apply bg-none bg-surface border border-border py-1 mx-0.5 text-text opacity-80 hover:opacity-100 rounded cursor-default;
}
}
.cm-line {
@apply pl-1.5;
}
.cm-changedLine {
/* Round top corners only if previous line is not a changed line */
&:not(.cm-changedLine + &) {
@apply rounded-t;
}
/* Round bottom corners only if next line is not a changed line */
&:not(:has(+ .cm-changedLine)) {
@apply rounded-b;
}
}
/* Let content grow and disable individual scrolling for sync */
.cm-editor {
@apply h-auto relative !important;
position: relative !important;
}
.cm-scroller {
@apply overflow-visible !important;
}
}

View File

@@ -0,0 +1,64 @@
import { yaml } from '@codemirror/lang-yaml';
import { syntaxHighlighting } from '@codemirror/language';
import { MergeView } from '@codemirror/merge';
import { EditorView } from '@codemirror/view';
import classNames from 'classnames';
import { useEffect, useRef } from 'react';
import './DiffViewer.css';
import { readonlyExtensions, syntaxHighlightStyle } from './extensions';
interface Props {
/** Original/previous version (left side) */
original: string;
/** Modified/current version (right side) */
modified: string;
className?: string;
}
export function DiffViewer({ original, modified, className }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<MergeView | null>(null);
useEffect(() => {
if (!containerRef.current) return;
// Clean up previous instance
viewRef.current?.destroy();
const sharedExtensions = [
yaml(),
syntaxHighlighting(syntaxHighlightStyle),
...readonlyExtensions,
EditorView.lineWrapping,
];
viewRef.current = new MergeView({
a: {
doc: original,
extensions: sharedExtensions,
},
b: {
doc: modified,
extensions: sharedExtensions,
},
parent: containerRef.current,
collapseUnchanged: { margin: 2, minSize: 3 },
highlightChanges: false,
gutter: true,
orientation: 'a-b',
revertControls: undefined,
});
return () => {
viewRef.current?.destroy();
viewRef.current = null;
};
}, [original, modified]);
return (
<div
ref={containerRef}
className={classNames('cm-wrapper cm-multiline h-full w-full', className)}
/>
);
}

View File

@@ -101,8 +101,8 @@
.template-tag {
/* Colors */
@apply bg-surface text-text border-border-subtle whitespace-nowrap cursor-default;
@apply hover:border-border-subtle hover:text-text hover:bg-surface-highlight;
@apply bg-surface text-text-subtle border-border-subtle whitespace-nowrap cursor-default;
@apply hover:border-border hover:text-text hover:bg-surface-highlight;
@apply inline border px-1 mx-[0.5px] rounded dark:shadow;

View File

@@ -44,6 +44,7 @@ import {
CookieIcon,
CopyCheck,
CopyIcon,
CornerRightDownIcon,
CornerRightUpIcon,
CreditCardIcon,
CrosshairIcon,
@@ -63,6 +64,7 @@ import {
FlaskConicalIcon,
FolderCodeIcon,
FolderCogIcon,
FolderDownIcon,
FolderGitIcon,
FolderIcon,
FolderInputIcon,
@@ -179,6 +181,7 @@ const icons = {
cookie: CookieIcon,
copy: CopyIcon,
copy_check: CopyCheck,
corner_right_down: CornerRightDownIcon,
corner_right_up: CornerRightUpIcon,
credit_card: CreditCardIcon,
crosshair: CrosshairIcon,
@@ -205,6 +208,7 @@ const icons = {
folder_output: FolderOutputIcon,
folder_symlink: FolderSymlinkIcon,
folder_sync: FolderSyncIcon,
folder_down: FolderDownIcon,
folder_up: FolderUpIcon,
gift: GiftIcon,
git_branch: GitBranchIcon,

View File

@@ -1,3 +1,4 @@
import { useCachedNode } from '@dnd-kit/core/dist/hooks/utilities';
import type { GitStatusEntry } from '@yaakapp-internal/git';
import { useGit } from '@yaakapp-internal/git';
import type {
@@ -9,14 +10,16 @@ import type {
Workspace,
} from '@yaakapp-internal/models';
import classNames from 'classnames';
import { useMemo, useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { modelToYaml } from '../../lib/diffYaml';
import { isSubEnvironment } from '../../lib/model_util';
import { resolvedModelName } from '../../lib/resolvedModelName';
import { showErrorToast } from '../../lib/toast';
import { Banner } from '../core/Banner';
import { Button } from '../core/Button';
import type { CheckboxProps } from '../core/Checkbox';
import { Checkbox } from '../core/Checkbox';
import { DiffViewer } from '../core/Editor/DiffViewer';
import { Icon } from '../core/Icon';
import { InlineCode } from '../core/InlineCode';
import { Input } from '../core/Input';
@@ -48,6 +51,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
const [isPushing, setIsPushing] = useState(false);
const [commitError, setCommitError] = useState<string | null>(null);
const [message, setMessage] = useState<string>('');
const [selectedEntry, setSelectedEntry] = useState<GitStatusEntry | null>(null);
const handleCreateCommit = async () => {
setCommitError(null);
@@ -138,6 +142,35 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
return next(workspace, []);
}, [workspace, internalEntries]);
const checkNode = useCallback(
(treeNode: CommitTreeNode) => {
const checked = nodeCheckedStatus(treeNode);
const newChecked = checked === 'indeterminate' ? true : !checked;
setCheckedAndChildren(treeNode, newChecked, unstage.mutate, add.mutate);
// TODO: Also ensure parents are added properly
},
[add.mutate, unstage.mutate],
);
const checkEntry = useCallback(
(entry: GitStatusEntry) => {
if (entry.staged) unstage.mutate({ relaPaths: [entry.relaPath] });
else add.mutate({ relaPaths: [entry.relaPath] });
},
[add.mutate, unstage.mutate],
);
const handleSelectChild = useCallback(
(entry: GitStatusEntry) => {
if (entry === selectedEntry) {
setSelectedEntry(null);
} else {
setSelectedEntry(entry);
}
},
[selectedEntry],
);
if (tree == null) {
return null;
}
@@ -146,77 +179,92 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
return <EmptyStateText>No changes since last commit</EmptyStateText>;
}
const checkNode = (treeNode: CommitTreeNode) => {
const checked = nodeCheckedStatus(treeNode);
const newChecked = checked === 'indeterminate' ? true : !checked;
setCheckedAndChildren(treeNode, newChecked, unstage.mutate, add.mutate);
// TODO: Also ensure parents are added properly
};
const checkEntry = (entry: GitStatusEntry) => {
if (entry.staged) unstage.mutate({ relaPaths: [entry.relaPath] });
else add.mutate({ relaPaths: [entry.relaPath] });
};
return (
<div className="grid grid-rows-1 h-full">
<div className="h-full px-2 pb-4">
<SplitLayout
name="commit"
layout="vertical"
defaultRatio={0.3}
name="commit-horizontal"
layout="horizontal"
defaultRatio={0.6}
firstSlot={({ style }) => (
<div style={style} className="h-full overflow-y-auto pb-3">
<TreeNodeChildren node={tree} depth={0} onCheck={checkNode} />
{externalEntries.find((e) => e.status !== 'current') && (
<>
<Separator className="mt-3 mb-1">External file changes</Separator>
{externalEntries.map((entry) => (
<ExternalTreeNode
key={entry.relaPath + entry.status}
entry={entry}
onCheck={checkEntry}
<div style={style} className="h-full px-4">
<SplitLayout
name="commit-vertical"
layout="vertical"
defaultRatio={0.35}
firstSlot={({ style: innerStyle }) => (
<div
style={innerStyle}
className="h-full overflow-y-auto pb-3 pr-0.5 transform-cpu"
>
<TreeNodeChildren
node={tree}
depth={0}
onCheck={checkNode}
onSelect={handleSelectChild}
selectedPath={selectedEntry?.relaPath ?? null}
/>
))}
</>
)}
{externalEntries.find((e) => e.status !== 'current') && (
<>
<Separator className="mt-3 mb-1">External file changes</Separator>
{externalEntries.map((entry) => (
<ExternalTreeNode
key={entry.relaPath + entry.status}
entry={entry}
onCheck={checkEntry}
/>
))}
</>
)}
</div>
)}
secondSlot={({ style: innerStyle }) => (
<div style={innerStyle} className="grid grid-rows-[minmax(0,1fr)_auto] gap-3 pb-2">
<Input
className="!text-base font-sans rounded-md"
placeholder="Commit message..."
onChange={setMessage}
stateKey={null}
label="Commit message"
fullHeight
multiLine
hideLabel
/>
{commitError && <Banner color="danger">{commitError}</Banner>}
<HStack alignItems="center">
<InlineCode>{status.data?.headRefShorthand}</InlineCode>
<HStack space={2} className="ml-auto">
<Button
color="secondary"
size="sm"
onClick={handleCreateCommit}
disabled={!hasAddedAnything}
isLoading={isPushing}
>
Commit
</Button>
<Button
color="primary"
size="sm"
disabled={!hasAddedAnything}
onClick={handleCreateCommitAndPush}
isLoading={isPushing}
>
Commit and Push
</Button>
</HStack>
</HStack>
</div>
)}
/>
</div>
)}
secondSlot={({ style }) => (
<div style={style} className="grid grid-rows-[minmax(0,1fr)_auto] gap-3 pb-2">
<Input
className="!text-base font-sans rounded-md"
placeholder="Commit message..."
onChange={setMessage}
stateKey={null}
label="Commit message"
fullHeight
multiLine
hideLabel
/>
{commitError && <Banner color="danger">{commitError}</Banner>}
<HStack alignItems="center">
<InlineCode>{status.data?.headRefShorthand}</InlineCode>
<HStack space={2} className="ml-auto">
<Button
color="secondary"
size="sm"
onClick={handleCreateCommit}
disabled={!hasAddedAnything}
isLoading={isPushing}
>
Commit
</Button>
<Button
color="primary"
size="sm"
disabled={!hasAddedAnything}
onClick={handleCreateCommitAndPush}
isLoading={isPushing}
>
Commit and Push
</Button>
</HStack>
</HStack>
<div style={style} className="h-full px-4 border-l border-l-border-subtle">
{selectedEntry ? (
<DiffPanel entry={selectedEntry} />
) : (
<EmptyStateText>Select a change to view diff</EmptyStateText>
)}
</div>
)}
/>
@@ -228,61 +276,77 @@ function TreeNodeChildren({
node,
depth,
onCheck,
onSelect,
selectedPath,
}: {
node: CommitTreeNode | null;
depth: number;
onCheck: (node: CommitTreeNode, checked: boolean) => void;
onSelect: (entry: GitStatusEntry) => void;
selectedPath: string | null;
}) {
if (node === null) return null;
if (!isNodeRelevant(node)) return null;
const checked = nodeCheckedStatus(node);
const isSelected = selectedPath === node.status.relaPath;
return (
<div
className={classNames(
depth > 0 && 'pl-1 ml-[10px] border-l border-dashed border-border-subtle',
depth > 0 && 'pl-4 ml-2 border-l border-dashed border-border-subtle relative',
)}
>
<div className="flex gap-3 w-full h-xs">
<div
className={classNames(
'relative flex gap-1 w-full h-xs items-center',
isSelected ? 'text-text' : 'text-text-subtle',
)}
>
{isSelected && (
<div className="absolute -left-[100vw] right-0 top-0 bottom-0 bg-surface-active opacity-30 -z-10" />
)}
<Checkbox
fullWidth
className="w-full hover:bg-surface-highlight rounded px-1 group"
checked={checked}
title={checked ? 'Unstage change' : 'Stage change'}
hideLabel
onChange={(checked) => onCheck(node, checked)}
title={
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] gap-1 w-full items-center">
{node.model.model !== 'http_request' &&
node.model.model !== 'grpc_request' &&
node.model.model !== 'websocket_request' ? (
<Icon
color="secondary"
icon={
node.model.model === 'folder'
? 'folder'
: node.model.model === 'environment'
? 'variable'
: 'house'
}
/>
) : (
<span aria-hidden />
)}
<div className="truncate">{resolvedModelName(node.model)}</div>
{node.status.status !== 'current' && (
<InlineCode
className={classNames(
'py-0 ml-auto bg-transparent w-[6rem] text-center',
node.status.status === 'modified' && 'text-info',
node.status.status === 'untracked' && 'text-success',
node.status.status === 'removed' && 'text-danger',
)}
>
{node.status.status}
</InlineCode>
)}
</div>
}
/>
<button
type="button"
className={classNames('flex-1 min-w-0 flex items-center gap-1 px-1 py-0.5 text-left')}
onClick={() => node.status.status !== 'current' && onSelect(node.status)}
>
{node.model.model !== 'http_request' &&
node.model.model !== 'grpc_request' &&
node.model.model !== 'websocket_request' ? (
<Icon
color="secondary"
icon={
node.model.model === 'folder'
? 'folder'
: node.model.model === 'environment'
? 'variable'
: 'house'
}
/>
) : (
<span aria-hidden className="w-4" />
)}
<div className="truncate flex-1">{resolvedModelName(node.model)}</div>
{node.status.status !== 'current' && (
<InlineCode
className={classNames(
'py-0 bg-transparent w-[6rem] text-center shrink-0',
node.status.status === 'modified' && 'text-info',
node.status.status === 'untracked' && 'text-success',
node.status.status === 'removed' && 'text-danger',
)}
>
{node.status.status}
</InlineCode>
)}
</button>
</div>
{node.children.map((childNode) => {
@@ -292,6 +356,8 @@ function TreeNodeChildren({
node={childNode}
depth={depth + 1}
onCheck={onCheck}
onSelect={onSelect}
selectedPath={selectedPath}
/>
);
})}
@@ -401,3 +467,17 @@ function isNodeRelevant(node: CommitTreeNode): boolean {
// Recursively check children
return node.children.some((c) => isNodeRelevant(c));
}
function DiffPanel({ entry }: { entry: GitStatusEntry }) {
const prevYaml = modelToYaml(entry.prev);
const nextYaml = modelToYaml(entry.next);
return (
<div className="h-full flex flex-col">
<div className="text-sm text-text-subtle mb-2 px-1">
{resolvedModelName(entry.next ?? entry.prev)} ({entry.status})
</div>
<DiffViewer original={prevYaml ?? ''} modified={nextYaml ?? ''} className="flex-1 min-h-0" />
</div>
);
}

View File

@@ -211,7 +211,7 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
id: 'commit',
title: 'Commit Changes',
size: 'full',
className: '!max-h-[min(80vh,40rem)] !max-w-[min(50rem,90vw)]',
noPadding: true,
render: ({ hide }) => (
<GitCommitDialog syncDir={syncDir} onDone={hide} workspace={workspace} />
),

View File

@@ -51,10 +51,9 @@ function ActualEventStreamViewer({ response }: Props) {
<span className="truncate text-xs">{event.data.slice(0, 1000)}</span>
</HStack>
}
/>
)}
renderDetail={({ event, index }) => (
renderDetail={({ event, index, onClose }) => (
<EventDetail
event={event}
index={index}
@@ -62,6 +61,7 @@ function ActualEventStreamViewer({ response }: Props) {
showingLarge={showingLarge}
setShowLarge={setShowLarge}
setShowingLarge={setShowingLarge}
onClose={onClose}
/>
)}
/>
@@ -75,6 +75,7 @@ function EventDetail({
showingLarge,
setShowLarge,
setShowingLarge,
onClose,
}: {
event: ServerSentEvent;
index: number;
@@ -82,6 +83,7 @@ function EventDetail({
showingLarge: boolean;
setShowLarge: (v: boolean) => void;
setShowingLarge: (v: boolean) => void;
onClose: () => void;
}) {
const language = useMemo<'text' | 'json'>(() => {
if (!event?.data) return 'text';
@@ -90,7 +92,11 @@ function EventDetail({
return (
<div className="flex flex-col h-full">
<EventDetailHeader title="Message Received" prefix={<EventLabels event={event} index={index} />} />
<EventDetailHeader
title="Message Received"
prefix={<EventLabels event={event} index={index} />}
onClose={onClose}
/>
{!showLarge && event.data.length > 1000 * 1000 ? (
<VStack space={2} className="italic text-text-subtlest">
Message previews larger than 1MB are hidden

View File

@@ -56,10 +56,10 @@ export function MultipartViewer({ data, boundary, idPrefix = 'multipart' }: Prop
addBorders
label="Multipart"
layout="horizontal"
tabListClassName="border-r border-r-border"
tabs={parts.map((part) => ({
tabListClassName="border-r border-r-border -ml-3"
tabs={parts.map((part, i) => ({
label: part.name ?? '',
value: part.name ?? '',
value: tabValue(part, i),
rightSlot:
part.filename && part.headers.contentType.mediaType?.startsWith('image/') ? (
<div className="h-5 w-5 overflow-auto flex items-center justify-end">
@@ -77,7 +77,7 @@ export function MultipartViewer({ data, boundary, idPrefix = 'multipart' }: Prop
<TabContent
// biome-ignore lint/suspicious/noArrayIndexKey: Nothing else to key on
key={idPrefix + part.name + i}
value={part.name ?? ''}
value={tabValue(part, i)}
className="pl-3 !pt-0"
>
<Part part={part} />
@@ -115,7 +115,7 @@ function Part({ part }: { part: MultipartPart }) {
}
if (mimeType?.match(/csv|tab-separated/i)) {
return <CsvViewer text={content} />;
return <CsvViewer text={content} className="bg-primary h-10 w-10" />;
}
if (mimeType?.match(/^text\/html/i) || detectedLanguage === 'html') {
@@ -132,3 +132,7 @@ function Part({ part }: { part: MultipartPart }) {
return <TextViewer text={content} language={detectedLanguage} stateKey={null} />;
}
function tabValue(part: MultipartPart, i: number) {
return `${part.name ?? ''}::${i}`;
}

View File

@@ -1,5 +1,5 @@
import type { Folder } from '@yaakapp-internal/models';
import { patchModel } from '@yaakapp-internal/models';
import { modelTypeLabel, patchModel } from '@yaakapp-internal/models';
import { useMemo } from 'react';
import { openFolderSettings } from '../commands/openFolderSettings';
import { openWorkspaceSettings } from '../commands/openWorkspaceSettings';
@@ -57,49 +57,103 @@ export function useAuthTab<T extends string>(tabValue: T, model: AuthenticatedMo
},
{ label: 'No Auth', shortLabel: 'No Auth', value: 'none' },
],
itemsAfter:
parentModel &&
model.authenticationType &&
model.authenticationType !== 'none' &&
(parentModel.authenticationType == null || parentModel.authenticationType === 'none')
? [
{ type: 'separator', label: 'Actions' },
{
label: `Promote to ${capitalize(parentModel.model)}`,
leftSlot: (
<Icon
icon={parentModel.model === 'workspace' ? 'corner_right_up' : 'folder_up'}
/>
),
onSelect: async () => {
const confirmed = await showConfirm({
id: 'promote-auth-confirm',
title: 'Promote Authentication',
confirmText: 'Promote',
description: (
<>
Move authentication config to{' '}
<InlineCode>{resolvedModelName(parentModel)}</InlineCode>?
</>
),
});
if (confirmed) {
await patchModel(model, { authentication: {}, authenticationType: null });
await patchModel(parentModel, {
authentication: model.authentication,
authenticationType: model.authenticationType,
});
itemsAfter: (() => {
const actions: (
| { type: 'separator'; label: string }
| { label: string; leftSlot: React.ReactNode; onSelect: () => Promise<void> }
)[] = [];
if (parentModel.model === 'folder') {
openFolderSettings(parentModel.id, 'auth');
} else {
openWorkspaceSettings('auth');
}
// Promote: move auth from current model up to parent
if (
parentModel &&
model.authenticationType &&
model.authenticationType !== 'none' &&
(parentModel.authenticationType == null || parentModel.authenticationType === 'none')
) {
actions.push(
{ type: 'separator', label: 'Actions' },
{
label: `Promote to ${capitalize(parentModel.model)}`,
leftSlot: (
<Icon
icon={parentModel.model === 'workspace' ? 'corner_right_up' : 'folder_up'}
/>
),
onSelect: async () => {
const confirmed = await showConfirm({
id: 'promote-auth-confirm',
title: 'Promote Authentication',
confirmText: 'Promote',
description: (
<>
Move authentication config to{' '}
<InlineCode>{resolvedModelName(parentModel)}</InlineCode>?
</>
),
});
if (confirmed) {
await patchModel(model, { authentication: {}, authenticationType: null });
await patchModel(parentModel, {
authentication: model.authentication,
authenticationType: model.authenticationType,
});
if (parentModel.model === 'folder') {
openFolderSettings(parentModel.id, 'auth');
} else {
openWorkspaceSettings('auth');
}
},
}
},
]
: undefined,
},
);
}
// Copy from ancestor: copy auth config down to current model
const ancestorWithAuth = ancestors.find(
(a) => a.authenticationType != null && a.authenticationType !== 'none',
);
if (ancestorWithAuth) {
if (actions.length === 0) {
actions.push({ type: 'separator', label: 'Actions' });
}
actions.push({
label: `Copy from ${modelTypeLabel(ancestorWithAuth)}`,
leftSlot: (
<Icon
icon={
ancestorWithAuth.model === 'workspace' ? 'corner_right_down' : 'folder_down'
}
/>
),
onSelect: async () => {
const confirmed = await showConfirm({
id: 'copy-auth-confirm',
title: 'Copy Authentication',
confirmText: 'Copy',
description: (
<>
Copy{' '}
{authentication.find((a) => a.name === ancestorWithAuth.authenticationType)
?.label ?? 'authentication'}{' '}
config from <InlineCode>{resolvedModelName(ancestorWithAuth)}</InlineCode>?
This will override the current authentication but will not affect the{' '}
{modelTypeLabel(ancestorWithAuth).toLowerCase()}.
</>
),
});
if (confirmed) {
await patchModel(model, {
authentication: { ...ancestorWithAuth.authentication },
authenticationType: ancestorWithAuth.authenticationType,
});
}
},
});
}
return actions.length > 0 ? actions : undefined;
})(),
onChange: async (authenticationType) => {
let authentication: Folder['authentication'] = model.authentication;
if (model.authenticationType !== authenticationType) {
@@ -113,5 +167,5 @@ export function useAuthTab<T extends string>(tabValue: T, model: AuthenticatedMo
};
return [tab];
}, [authentication, inheritedAuth, model, parentModel, tabValue]);
}, [authentication, inheritedAuth, model, parentModel, tabValue, ancestors]);
}

15
src-web/lib/diffYaml.ts Normal file
View File

@@ -0,0 +1,15 @@
import type { SyncModel } from '@yaakapp-internal/git';
import { stringify } from 'yaml';
/**
* Convert a SyncModel to a clean YAML string for diffing.
* Removes noisy fields like updatedAt that change on every edit.
*/
export function modelToYaml(model: SyncModel | null): string {
if (!model) return '';
return stringify(model, {
indent: 2,
lineWidth: 0,
});
}

View File

@@ -104,11 +104,12 @@ function templateTagColorVariables(color: YaakColor | null): Partial<CSSVariable
if (color == null) return {};
return {
text: color.lift(0.6).css(),
text: color.lift(0.7).css(),
textSubtle: color.lift(0.4).css(),
textSubtlest: color.css(),
surface: color.lower(0.2).translucify(0.8).css(),
border: color.lower(0.2).translucify(0.2).css(),
border: color.translucify(0.6).css(),
borderSubtle: color.translucify(0.8).css(),
surfaceHighlight: color.lower(0.1).translucify(0.7).css(),
};
}
@@ -137,6 +138,16 @@ function bannerColorVariables(color: YaakColor | null): Partial<CSSVariables> {
};
}
function inputCSS(color: YaakColor | null): Partial<CSSVariables> {
if (color == null) return {};
const theme: Partial<ThemeComponentColors> = {
border: color.css(),
};
return theme;
}
function buttonSolidColorVariables(
color: YaakColor | null,
isDefault = false,

View File

@@ -14,7 +14,9 @@
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-markdown": "^6.3.2",
"@codemirror/lang-xml": "^6.1.0",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.11.0",
"@codemirror/merge": "^6.11.2",
"@codemirror/search": "^6.5.11",
"@dnd-kit/core": "^6.3.1",
"@gilbarbara/deep-equal": "^0.3.1",