From eb2a2dd775d8e7dd674eacfb2fc1e43cdb7ca90f Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Sat, 4 Jul 2026 22:22:35 -0700 Subject: [PATCH 1/8] Convert request bodies when changing type (#499) --- .../components/HttpRequestPane.tsx | 10 +- .../components/graphql/GraphQLEditor.tsx | 340 +++++++++++------- .../lib/graphqlOperationNames.test.ts | 37 ++ apps/yaak-client/lib/graphqlOperationNames.ts | 26 ++ .../lib/requestBodyConversion.test.ts | 152 ++++++++ apps/yaak-client/lib/requestBodyConversion.ts | 199 ++++++++++ crates/yaak-http/src/types.rs | 91 ++++- plugins/action-copy-curl/src/index.ts | 1 + plugins/action-copy-curl/tests/index.test.ts | 19 + plugins/importer-curl/src/graphql.ts | 117 ++++++ plugins/importer-curl/src/index.ts | 31 +- plugins/importer-curl/tests/graphql.test.ts | 146 ++++++++ plugins/importer-curl/tests/index.test.ts | 47 +++ 13 files changed, 1065 insertions(+), 151 deletions(-) create mode 100644 apps/yaak-client/lib/graphqlOperationNames.test.ts create mode 100644 apps/yaak-client/lib/graphqlOperationNames.ts create mode 100644 apps/yaak-client/lib/requestBodyConversion.test.ts create mode 100644 apps/yaak-client/lib/requestBodyConversion.ts create mode 100644 plugins/importer-curl/src/graphql.ts create mode 100644 plugins/importer-curl/tests/graphql.test.ts diff --git a/apps/yaak-client/components/HttpRequestPane.tsx b/apps/yaak-client/components/HttpRequestPane.tsx index cd5668ba..944c4904 100644 --- a/apps/yaak-client/components/HttpRequestPane.tsx +++ b/apps/yaak-client/components/HttpRequestPane.tsx @@ -20,6 +20,7 @@ import { deepEqualAtom } from "../lib/atoms"; import { languageFromContentType } from "../lib/contentType"; import { generateId } from "../lib/generateId"; import { extractPathPlaceholders } from "../lib/pathPlaceholders"; +import { convertRequestBody } from "../lib/requestBodyConversion"; import { BODY_TYPE_BINARY, BODY_TYPE_FORM_MULTIPART, @@ -195,7 +196,14 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }: }); }; - const patch: Partial = { bodyType }; + const patch: Partial = { + bodyType, + body: convertRequestBody({ + body: activeRequest.body, + fromBodyType: activeRequest.bodyType, + toBodyType: bodyType, + }), + }; let newContentType: string | null | undefined; if (bodyType === BODY_TYPE_NONE) { newContentType = null; diff --git a/apps/yaak-client/components/graphql/GraphQLEditor.tsx b/apps/yaak-client/components/graphql/GraphQLEditor.tsx index e66fc41e..94395a0c 100644 --- a/apps/yaak-client/components/graphql/GraphQLEditor.tsx +++ b/apps/yaak-client/components/graphql/GraphQLEditor.tsx @@ -1,7 +1,7 @@ import type { HttpRequest } from "@yaakapp-internal/models"; import { useAtom } from "jotai"; -import { useCallback, useMemo } from "react"; +import { useCallback, useEffect, useMemo } from "react"; import { useLocalStorage } from "react-use"; import { useIntrospectGraphQL } from "../../hooks/useIntrospectGraphQL"; import { useStateWithDeps } from "../../hooks/useStateWithDeps"; @@ -11,9 +11,13 @@ import type { DropdownItem } from "../core/Dropdown"; import { Dropdown } from "../core/Dropdown"; import type { EditorProps } from "../core/Editor/Editor"; import { Editor } from "../core/Editor/LazyEditor"; +import type { RadioDropdownItem } from "../core/RadioDropdown"; +import { RadioDropdown } from "../core/RadioDropdown"; import { Banner, FormattedError, Icon } from "@yaakapp-internal/ui"; import { Separator } from "../core/Separator"; import { tryFormatGraphql } from "../../lib/formatters"; +import { parseGraphQLOperationNames } from "../../lib/graphqlOperationNames"; +import { normalizeGraphQLBody } from "../../lib/requestBodyConversion"; import { showGraphQLDocExplorerAtom } from "./graphqlAtoms"; type Props = Pick & { @@ -22,6 +26,8 @@ type Props = Pick & request: HttpRequest; }; +const OPERATION_NAME_NOT_SPECIFIED = ""; + export function GraphQLEditor(props: Props) { // There's some weirdness with stale onChange being called when switching requests, so we'll // key on the request ID as a workaround for now. @@ -38,25 +44,25 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp const [currentBody, setCurrentBody] = useStateWithDeps<{ query: string; variables: string | undefined; + operationName?: string; }>(() => { // Migrate text bodies to GraphQL format // NOTE: This is how GraphQL used to be stored - if ("text" in request.body) { - const b = tryParseJson(request.body.text, {}); - const variables = JSON.stringify(b.variables || undefined, null, 2); - return { query: b.query ?? "", variables }; - } - - return { query: request.body.query ?? "", variables: request.body.variables ?? "" }; + return normalizeGraphQLBody(request.body); }, [extraEditorProps.forceUpdateKey]); const [isDocOpenRecord, setGraphqlDocStateAtomValue] = useAtom(showGraphQLDocExplorerAtom); const isDocOpen = isDocOpenRecord[request.id] !== undefined; + const parsedOperationNames = useMemo( + () => parseGraphQLOperationNames(currentBody.query), + [currentBody.query], + ); + const operationNames = useMemo(() => parsedOperationNames ?? [], [parsedOperationNames]); const handleChangeQuery = useCallback( (query: string) => { - setCurrentBody(({ variables }) => { - const newBody = { query, variables }; + setCurrentBody(({ variables, operationName }) => { + const newBody = buildGraphQLBody({ query, variables, operationName }); onChange(newBody); return newBody; }); @@ -66,8 +72,8 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp const handleChangeVariables = useCallback( (variables: string) => { - setCurrentBody(({ query }) => { - const newBody = { query, variables: variables || undefined }; + setCurrentBody(({ query, operationName }) => { + const newBody = buildGraphQLBody({ query, variables, operationName }); onChange(newBody); return newBody; }); @@ -75,125 +81,196 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp [onChange, setCurrentBody], ); + const handleChangeOperationName = useCallback( + (operationName: string) => { + setCurrentBody(({ query, variables }) => { + const newBody = buildGraphQLBody({ query, variables, operationName }); + onChange(newBody); + return newBody; + }); + }, + [onChange, setCurrentBody], + ); + + useEffect(() => { + if (parsedOperationNames == null) { + return; + } + + if (currentBody.operationName === OPERATION_NAME_NOT_SPECIFIED) { + return; + } + + if (currentBody.operationName && operationNames.includes(currentBody.operationName)) { + return; + } + + // Keep the saved body aligned with the visible default, so send/copy use the selected operation. + const operationName = operationNames[0]; + if (currentBody.operationName === operationName) { + return; + } + + setCurrentBody(({ query, variables }) => { + const newBody = buildGraphQLBody({ query, variables, operationName }); + onChange(newBody); + return newBody; + }); + }, [ + currentBody.operationName, + onChange, + operationNames, + parsedOperationNames, + setCurrentBody, + ]); + const actions = useMemo( () => [ -
-
- {schema === undefined ? null /* Initializing */ : ( - , - }, - { type: "separator" }, - ] - : []) satisfies DropdownItem[]), - { - hidden: !error, - label: ( - -

Schema introspection failed

- -
- - ), - }); - }} - > - View Error - - - ), - type: "content", - }, - { - hidden: schema == null, - label: `${isDocOpen ? "Hide" : "Show"} Documentation`, - leftSlot: , - onSelect: () => { - setGraphqlDocStateAtomValue((v) => ({ - ...v, - [request.id]: isDocOpen ? undefined : null, - })); - }, - }, - { - label: "Introspect Schema", - leftSlot: , - keepOpenOnSelect: true, - onSelect: refetch, - }, - { type: "separator", label: "Setting" }, - { - label: "Automatic Introspection", - keepOpenOnSelect: true, - onSelect: () => { - setAutoIntrospectDisabled({ - ...autoIntrospectDisabled, - [baseRequest.id]: !autoIntrospectDisabled?.[baseRequest.id], - }); - }, - leftSlot: ( - - ), - }, - ]} - > - - - )} + operationNames.length > 0 ? ( +
+ Not specified, + value: OPERATION_NAME_NOT_SPECIFIED, + }, + ...operationNames.map((operationName) => ({ + label: operationName, + value: operationName, + })), + ] satisfies RadioDropdownItem[]} + > + +
+ ) : null, +
+ {schema === undefined ? null /* Initializing */ : ( + , + }, + { type: "separator" }, + ] + : []) satisfies DropdownItem[]), + { + hidden: !error, + label: ( + +

Schema introspection failed

+ +
+ + ), + }); + }} + > + View Error + + + ), + type: "content", + }, + { + hidden: schema == null, + label: `${isDocOpen ? "Hide" : "Show"} Documentation`, + leftSlot: , + onSelect: () => { + setGraphqlDocStateAtomValue((v) => ({ + ...v, + [request.id]: isDocOpen ? undefined : null, + })); + }, + }, + { + label: "Introspect Schema", + leftSlot: , + keepOpenOnSelect: true, + onSelect: refetch, + }, + { type: "separator", label: "Setting" }, + { + label: "Automatic Introspection", + keepOpenOnSelect: true, + onSelect: () => { + setAutoIntrospectDisabled({ + ...autoIntrospectDisabled, + [baseRequest.id]: !autoIntrospectDisabled?.[baseRequest.id], + }); + }, + leftSlot: ( + + ), + }, + ]} + > + + + )}
, ], [ schema, clear, error, + currentBody.operationName, + handleChangeOperationName, isDocOpen, isLoading, + operationNames, refetch, autoIntrospectDisabled, baseRequest.id, @@ -237,10 +314,23 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp ); } -function tryParseJson(text: string, fallback: unknown) { - try { - return JSON.parse(text); - } catch { - return fallback; +function buildGraphQLBody(body: { + query: string; + variables: string | undefined; + operationName?: string; +}) { + const result: { + query: string; + variables: string | undefined; + operationName?: string; + } = { + query: body.query, + variables: body.variables || undefined, + }; + + if (typeof body.operationName === "string") { + result.operationName = body.operationName; } + + return result; } diff --git a/apps/yaak-client/lib/graphqlOperationNames.test.ts b/apps/yaak-client/lib/graphqlOperationNames.test.ts new file mode 100644 index 00000000..f6b04cee --- /dev/null +++ b/apps/yaak-client/lib/graphqlOperationNames.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "vite-plus/test"; +import { getGraphQLOperationNames, parseGraphQLOperationNames } from "./graphqlOperationNames"; + +describe("getGraphQLOperationNames", () => { + test("returns named operations from a GraphQL document", () => { + expect( + getGraphQLOperationNames(` + query GetUser { user { id } } + mutation UpdateUser { updateUser { id } } + subscription UserChanged { userChanged { id } } + fragment UserFields on User { id } + `), + ).toEqual(["GetUser", "UpdateUser", "UserChanged"]); + }); + + test("ignores anonymous operations", () => { + expect(getGraphQLOperationNames(`{ user { id } }`)).toEqual([]); + }); + + test("returns unique operation names in document order", () => { + expect( + getGraphQLOperationNames(` + query GetUser { user { id } } + query GetUser { user { name } } + query ListUsers { users { id } } + `), + ).toEqual(["GetUser", "ListUsers"]); + }); + + test("returns no operations for invalid in-progress documents", () => { + expect(getGraphQLOperationNames(`query GetUser { user {`)).toEqual([]); + }); + + test("returns null when parsing invalid in-progress documents", () => { + expect(parseGraphQLOperationNames(`query GetUser { user {`)).toBeNull(); + }); +}); diff --git a/apps/yaak-client/lib/graphqlOperationNames.ts b/apps/yaak-client/lib/graphqlOperationNames.ts new file mode 100644 index 00000000..7ae9e36e --- /dev/null +++ b/apps/yaak-client/lib/graphqlOperationNames.ts @@ -0,0 +1,26 @@ +import { Kind, parse } from "graphql"; + +export function getGraphQLOperationNames(query: string): string[] { + return parseGraphQLOperationNames(query) ?? []; +} + +export function parseGraphQLOperationNames(query: string): string[] | null { + try { + const names: string[] = []; + + for (const definition of parse(query).definitions) { + if (definition.kind !== Kind.OPERATION_DEFINITION || definition.name == null) { + continue; + } + + const name = definition.name.value; + if (!names.includes(name)) { + names.push(name); + } + } + + return names; + } catch { + return null; + } +} diff --git a/apps/yaak-client/lib/requestBodyConversion.test.ts b/apps/yaak-client/lib/requestBodyConversion.test.ts new file mode 100644 index 00000000..aeebda3c --- /dev/null +++ b/apps/yaak-client/lib/requestBodyConversion.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, test } from "vite-plus/test"; +import { + BODY_TYPE_BINARY, + BODY_TYPE_FORM_URLENCODED, + BODY_TYPE_GRAPHQL, + BODY_TYPE_JSON, + BODY_TYPE_NONE, + BODY_TYPE_OTHER, + BODY_TYPE_XML, +} from "./model_util"; +import { convertRequestBody } from "./requestBodyConversion"; + +describe("convertRequestBody", () => { + test("converts imported JSON GraphQL bodies to GraphQL shape", () => { + const body = convertRequestBody({ + fromBodyType: BODY_TYPE_JSON, + toBodyType: BODY_TYPE_GRAPHQL, + body: { + text: JSON.stringify({ + query: "query GetUser($id: ID!) { user(id: $id) { name } }", + variables: { id: "123" }, + operationName: "GetUser", + }), + }, + }); + + expect(body).toEqual({ + query: "query GetUser($id: ID!) { user(id: $id) { name } }", + variables: '{\n "id": "123"\n}', + operationName: "GetUser", + }); + }); + + test("converts GraphQL bodies to JSON text", () => { + const body = convertRequestBody({ + fromBodyType: BODY_TYPE_GRAPHQL, + toBodyType: BODY_TYPE_JSON, + body: { + query: "query GetUser($id: ID!) { user(id: $id) { name } }", + variables: '{ "id": "123" }', + operationName: "GetUser", + }, + }); + + expect(body).toEqual({ + text: JSON.stringify( + { + query: "query GetUser($id: ID!) { user(id: $id) { name } }", + variables: { id: "123" }, + operationName: "GetUser", + }, + null, + 2, + ), + }); + }); + + test("converts urlencoded forms to urlencoded text for text-like bodies", () => { + const body = convertRequestBody({ + fromBodyType: BODY_TYPE_FORM_URLENCODED, + toBodyType: BODY_TYPE_OTHER, + body: { + form: [ + { enabled: true, name: "basic", value: "aaa" }, + { enabled: true, name: "funky stuff", value: "*)%&#$)@ *$#)@&" }, + { enabled: false, name: "disabled", value: "hidden" }, + { enabled: true, name: "", value: "unnamed" }, + ], + }, + }); + + expect(body).toEqual({ + text: "basic=aaa&funky+stuff=*%29%25%26%23%24%29%40+*%24%23%29%40%26", + }); + }); + + test("converts urlencoded forms to JSON text for JSON bodies", () => { + const body = convertRequestBody({ + fromBodyType: BODY_TYPE_FORM_URLENCODED, + toBodyType: BODY_TYPE_JSON, + body: { + form: [ + { enabled: true, name: "tag", value: "one" }, + { enabled: true, name: "tag", value: "two" }, + { enabled: true, name: "limit", value: "10" }, + ], + }, + }); + + expect(body).toEqual({ + text: JSON.stringify({ tag: ["one", "two"], limit: "10" }, null, 2), + }); + }); + + test("preserves text when converting to form bodies cannot build form pairs", () => { + const body = convertRequestBody({ + fromBodyType: BODY_TYPE_XML, + toBodyType: BODY_TYPE_FORM_URLENCODED, + body: { text: "a=1&b=two+words" }, + }); + + expect(body).toEqual({ + text: "a=1&b=two+words", + }); + }); + + test("preserves JSON text that is not a GraphQL envelope", () => { + const body = convertRequestBody({ + fromBodyType: BODY_TYPE_JSON, + toBodyType: BODY_TYPE_GRAPHQL, + body: { text: JSON.stringify({ name: "Yaak" }) }, + }); + + expect(body).toEqual({ + text: JSON.stringify({ name: "Yaak" }), + }); + }); + + test("preserves JSON arrays and primitives when converting to GraphQL", () => { + for (const text of [JSON.stringify([1, 2, 3]), JSON.stringify("query"), "123", "null"]) { + const body = convertRequestBody({ + fromBodyType: BODY_TYPE_JSON, + toBodyType: BODY_TYPE_GRAPHQL, + body: { text }, + }); + + expect(body).toEqual({ text }); + } + }); + + test("preserves text when converting to binary cannot build a file body", () => { + const body = convertRequestBody({ + fromBodyType: BODY_TYPE_JSON, + toBodyType: BODY_TYPE_BINARY, + body: { text: '{ "name": "Yaak" }' }, + }); + + expect(body).toEqual({ + text: '{ "name": "Yaak" }', + }); + }); + + test("clears body when converting to no body", () => { + const body = convertRequestBody({ + fromBodyType: BODY_TYPE_JSON, + toBodyType: BODY_TYPE_NONE, + body: { text: '{ "name": "Yaak" }' }, + }); + + expect(body).toEqual({}); + }); +}); diff --git a/apps/yaak-client/lib/requestBodyConversion.ts b/apps/yaak-client/lib/requestBodyConversion.ts new file mode 100644 index 00000000..26cff993 --- /dev/null +++ b/apps/yaak-client/lib/requestBodyConversion.ts @@ -0,0 +1,199 @@ +import type { HttpRequest } from "@yaakapp-internal/models"; +import { + BODY_TYPE_BINARY, + BODY_TYPE_FORM_MULTIPART, + BODY_TYPE_FORM_URLENCODED, + BODY_TYPE_GRAPHQL, + BODY_TYPE_JSON, + BODY_TYPE_NONE, +} from "./model_util"; + +type Body = HttpRequest["body"]; +type BodyType = HttpRequest["bodyType"]; +type GraphQLBody = { + query: string; + variables: string | undefined; + operationName?: string; +}; + +export function convertRequestBody({ + body, + fromBodyType, + toBodyType, +}: { + body: Body; + fromBodyType: BodyType; + toBodyType: BodyType; +}): Body { + if (toBodyType === BODY_TYPE_NONE) { + return {}; + } + + if (toBodyType === BODY_TYPE_GRAPHQL) { + return toGraphQLBody(body) ?? body; + } + + if (toBodyType === BODY_TYPE_FORM_URLENCODED || toBodyType === BODY_TYPE_FORM_MULTIPART) { + return toFormBody(body) ?? body; + } + + if (toBodyType === BODY_TYPE_BINARY) { + return typeof body.filePath === "string" ? { filePath: body.filePath } : body; + } + + return toTextBody(body, fromBodyType, toBodyType) ?? body; +} + +export function normalizeGraphQLBody(body: Body): GraphQLBody { + return toGraphQLBody(body) ?? { query: "", variables: undefined }; +} + +function toGraphQLBody(body: Body): GraphQLBody | null { + if (typeof body.query === "string") { + const result: GraphQLBody = { + query: body.query, + variables: typeof body.variables === "string" ? body.variables : undefined, + }; + if (typeof body.operationName === "string") { + result.operationName = body.operationName; + } + + return result; + } + + if (typeof body.text === "string") { + try { + const parsed: unknown = JSON.parse(body.text); + if (!isRecord(parsed)) { + return null; + } + + if (typeof parsed.query !== "string") { + return null; + } + + const query = parsed.query; + const variables = + parsed.variables == null ? undefined : JSON.stringify(parsed.variables, null, 2); + + const result: GraphQLBody = { query, variables }; + if (typeof parsed.operationName === "string") { + result.operationName = parsed.operationName; + } + + return result; + } catch { + return { query: body.text, variables: undefined }; + } + } + + return null; +} + +function toFormBody(body: Body): Body | null { + if (Array.isArray(body.form)) { + return { + form: body.form.map((p) => ({ + enabled: p.enabled !== false, + name: typeof p.name === "string" ? p.name : "", + value: stringifyFormValue(p.value ?? p.file), + contentType: typeof p.contentType === "string" ? p.contentType : undefined, + filename: typeof p.filename === "string" ? p.filename : undefined, + file: typeof p.file === "string" ? p.file : undefined, + id: typeof p.id === "string" ? p.id : undefined, + })), + }; + } + + return null; +} + +function toTextBody(body: Body, fromBodyType: BodyType, toBodyType: BodyType): Body | null { + const sendJsonComments = + typeof body.sendJsonComments === "boolean" ? { sendJsonComments: body.sendJsonComments } : {}; + + if (typeof body.text === "string") { + return { text: body.text, ...sendJsonComments }; + } + + if (Array.isArray(body.form)) { + if (toBodyType === BODY_TYPE_JSON) { + return { text: JSON.stringify(formBodyToObject(body.form), null, 2) }; + } + + return { text: formBodyToUrlEncodedText(body.form) }; + } + + if (typeof body.query === "string") { + if (toBodyType === BODY_TYPE_JSON || fromBodyType === BODY_TYPE_GRAPHQL) { + const value: Record = { query: body.query }; + if (typeof body.variables === "string" && body.variables.trim() !== "") { + value.variables = parseJson(body.variables) ?? body.variables; + } + if (typeof body.operationName === "string" && body.operationName.trim() !== "") { + value.operationName = body.operationName; + } + + return { text: JSON.stringify(value, null, 2) }; + } + + return { text: body.query }; + } + + if (typeof body.filePath === "string") { + return { text: body.filePath }; + } + + return null; +} + +function formBodyToUrlEncodedText(form: unknown[]): string { + const params = new URLSearchParams(); + + for (const pair of form) { + if (!isRecord(pair)) continue; + if (pair.enabled === false) continue; + if (typeof pair.name !== "string" || pair.name === "") continue; + params.append(pair.name, stringifyFormValue(pair.value)); + } + + return params.toString(); +} + +function formBodyToObject(form: unknown[]) { + const result: Record = {}; + + for (const pair of form) { + if (!isRecord(pair)) continue; + if (pair.enabled === false) continue; + if (typeof pair.name !== "string" || pair.name === "") continue; + + const value = stringifyFormValue(pair.value); + if (pair.name in result) { + const existing = result[pair.name]; + result[pair.name] = Array.isArray(existing) ? [...existing, value] : [existing, value]; + } else { + result[pair.name] = value; + } + } + + return result; +} + +function stringifyFormValue(value: unknown): string { + if (value == null) return ""; + if (typeof value === "string") return value; + return JSON.stringify(value); +} + +function parseJson(text: string): unknown | null { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +function isRecord(value: unknown): value is Record { + return value != null && typeof value === "object" && !Array.isArray(value); +} diff --git a/crates/yaak-http/src/types.rs b/crates/yaak-http/src/types.rs index 0794df21..d7931684 100644 --- a/crates/yaak-http/src/types.rs +++ b/crates/yaak-http/src/types.rs @@ -191,12 +191,16 @@ fn build_url(r: &HttpRequest) -> String { fn append_graphql_query_params(url: &str, body: &BTreeMap) -> String { let query = get_str_map(body, "query").to_string(); let variables = strip_json_comments(&get_str_map(body, "variables")); + let operation_name = get_str_map(body, "operationName").to_string(); let mut params = vec![("query".to_string(), query)]; if !variables.trim().is_empty() { params.push(("variables".to_string(), variables)); } + if !operation_name.trim().is_empty() { + params.push(("operationName".to_string(), operation_name)); + } // Strip existing query/variables params to avoid duplicates - let url = strip_query_params(url, &["query", "variables"]); + let url = strip_query_params(url, &["query", "variables", "operationName"]); append_query_params(&url, params) } @@ -329,23 +333,30 @@ fn build_graphql_body( ) -> Option { let query = get_str_map(body, "query"); let variables = strip_json_comments(&get_str_map(body, "variables")); + let operation_name = get_str_map(body, "operationName"); if method.to_lowercase() == "get" { // GraphQL GET requests use query parameters, not a body return None; } - let body = if variables.trim().is_empty() { - format!(r#"{{"query":{}}}"#, serde_json::to_string(&query).unwrap_or_default()) - } else { - format!( - r#"{{"query":{},"variables":{}}}"#, - serde_json::to_string(&query).unwrap_or_default(), - variables - ) - }; + let mut body = serde_json::Map::new(); + body.insert("query".to_string(), serde_json::Value::String(query.to_string())); + if !variables.trim().is_empty() { + body.insert( + "variables".to_string(), + serde_json::from_str(&variables) + .unwrap_or_else(|_| serde_json::Value::String(variables)), + ); + } + if !operation_name.trim().is_empty() { + body.insert( + "operationName".to_string(), + serde_json::Value::String(operation_name.to_string()), + ); + } - Some(SendableBodyWithMeta::Bytes(Bytes::from(body))) + Some(SendableBodyWithMeta::Bytes(Bytes::from(serde_json::to_string(&body).unwrap_or_default()))) } async fn build_multipart_body( @@ -522,6 +533,33 @@ mod tests { assert_eq!(result, "https://example.com/api?foo=bar&baz=qux"); } + #[test] + fn test_build_url_replaces_graphql_operation_name_from_body() { + let mut body = BTreeMap::new(); + body.insert("query".to_string(), json!("query Foo { foo } query Bar { bar }")); + body.insert("operationName".to_string(), json!("Bar")); + + let r = HttpRequest { + method: "GET".to_string(), + body_type: Some("graphql".to_string()), + body, + url: "https://example.com/graphql".to_string(), + url_parameters: vec![HttpUrlParameter { + enabled: true, + name: "operationName".to_string(), + value: "Foo".to_string(), + id: None, + }], + ..Default::default() + }; + + let result = build_url(&r); + assert_eq!( + result, + "https://example.com/graphql?query=query%20Foo%20%7B%20foo%20%7D%20query%20Bar%20%7B%20bar%20%7D&operationName=Bar", + ); + } + #[test] fn test_build_url_with_disabled_params() { let r = HttpRequest { @@ -880,9 +918,34 @@ mod tests { let result = build_graphql_body("POST", &body); match result { Some(SendableBodyWithMeta::Bytes(bytes)) => { - let expected = - r#"{"query":"{ user(id: $id) { name } }","variables":{"id": "123"}}"#; - assert_eq!(bytes, Bytes::from(expected)); + assert_eq!( + serde_json::from_slice::(&bytes).unwrap(), + json!({ + "query": "{ user(id: $id) { name } }", + "variables": { "id": "123" }, + }), + ); + } + _ => panic!("Expected Some(SendableBody::Bytes)"), + } + } + + #[tokio::test] + async fn test_graphql_body_with_operation_name() { + let mut body = BTreeMap::new(); + body.insert("query".to_string(), json!("query Search { viewer { id } }")); + body.insert("operationName".to_string(), json!("Search")); + + let result = build_graphql_body("POST", &body); + match result { + Some(SendableBodyWithMeta::Bytes(bytes)) => { + assert_eq!( + serde_json::from_slice::(&bytes).unwrap(), + json!({ + "query": "query Search { viewer { id } }", + "operationName": "Search", + }), + ); } _ => panic!("Expected Some(SendableBody::Bytes)"), } diff --git a/plugins/action-copy-curl/src/index.ts b/plugins/action-copy-curl/src/index.ts index 87733100..4f02afa7 100644 --- a/plugins/action-copy-curl/src/index.ts +++ b/plugins/action-copy-curl/src/index.ts @@ -93,6 +93,7 @@ export async function convertToCurl(request: Partial) { const body = { query: request.body.query || "", variables: maybeParseJSON(request.body.variables, undefined), + operationName: request.body.operationName || undefined, }; xs.push("--data", quote(JSON.stringify(body))); xs.push(NEWLINE); diff --git a/plugins/action-copy-curl/tests/index.test.ts b/plugins/action-copy-curl/tests/index.test.ts index 86fb301d..824cf718 100644 --- a/plugins/action-copy-curl/tests/index.test.ts +++ b/plugins/action-copy-curl/tests/index.test.ts @@ -66,6 +66,25 @@ describe("exporter-curl", () => { ); }); + test("Exports POST with GraphQL operation name", async () => { + expect( + await convertToCurl({ + url: "https://yaak.app", + method: "POST", + bodyType: "graphql", + body: { + query: "query Foo { foo } query Bar { bar }", + operationName: "Foo", + }, + }), + ).toEqual( + [ + `curl -X POST 'https://yaak.app'`, + `--data '{"query":"query Foo { foo } query Bar { bar }","operationName":"Foo"}'`, + ].join(" \\\n "), + ); + }); + test("Exports POST with GraphQL data no variables", async () => { expect( await convertToCurl({ diff --git a/plugins/importer-curl/src/graphql.ts b/plugins/importer-curl/src/graphql.ts new file mode 100644 index 00000000..21743b59 --- /dev/null +++ b/plugins/importer-curl/src/graphql.ts @@ -0,0 +1,117 @@ +type GraphQLDetectionSignal = { + score: number; + requiresGraphQLDocument?: boolean; +}; + +export type GraphQLJsonBody = { + query: string; + variables?: string; + operationName?: string; +}; + +type GraphQLJsonBodyArgs = { + mimeType: string | null; + text: string; + url: string; +}; + +export function isGraphQLJsonBody(args: GraphQLJsonBodyArgs): boolean { + return parseGraphQLJsonBody(args) != null; +} + +export function parseGraphQLJsonBody({ + mimeType, + text, + url, +}: GraphQLJsonBodyArgs): GraphQLJsonBody | null { + if (mimeType !== "application/json") { + return null; + } + + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return null; + } + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + + const body = parsed as Record; + if (typeof body.query !== "string") { + return null; + } + + if (hasExtraGraphQLEnvelopeFields(body)) { + return null; + } + + const signals = getGraphQLDetectionSignals(body, url); + const score = signals.reduce((total, signal) => total + signal.score, 0); + const hasGraphQLDocument = signals.some((signal) => signal.requiresGraphQLDocument); + if (!hasGraphQLDocument || score < 4) { + return null; + } + + const result: GraphQLJsonBody = { query: body.query }; + if (body.variables != null) { + result.variables = + typeof body.variables === "string" ? body.variables : JSON.stringify(body.variables, null, 2); + } + if (typeof body.operationName === "string") { + result.operationName = body.operationName; + } + + return result; +} + +function hasExtraGraphQLEnvelopeFields(body: Record): boolean { + const allowedKeys = new Set(["query", "variables", "operationName"]); + return Object.keys(body).some((key) => !allowedKeys.has(key)); +} + +function getGraphQLDetectionSignals( + body: Record, + url: string, +): GraphQLDetectionSignal[] { + const signals: GraphQLDetectionSignal[] = []; + const query = body.query as string; + const urlPath = getUrlPath(url).toLowerCase(); + + if (/\b(graphql|gql)\b/.test(urlPath)) { + signals.push({ score: 2 }); + } + + if (/^(query|mutation|subscription|fragment)\b/.test(query.trim())) { + signals.push({ score: 3 }); + } else if (/^\{[\s\S]*\}$/.test(query.trim())) { + signals.push({ score: 3, requiresGraphQLDocument: true }); + } + + if (/\{[\s\S]*\}/.test(query)) { + signals.push({ score: 1, requiresGraphQLDocument: true }); + } + + if (typeof body.operationName === "string" && body.operationName.trim() !== "") { + signals.push({ score: 1 }); + } + + if ( + body.variables != null && + (typeof body.variables === "object" || typeof body.variables === "string") + ) { + signals.push({ score: 1 }); + } + + return signals; +} + +function getUrlPath(url: string): string { + try { + return new URL(url).pathname; + } catch { + return url; + } +} diff --git a/plugins/importer-curl/src/index.ts b/plugins/importer-curl/src/index.ts index ca7d5867..d03e31a2 100644 --- a/plugins/importer-curl/src/index.ts +++ b/plugins/importer-curl/src/index.ts @@ -8,6 +8,7 @@ import type { Workspace, } from "@yaakapp/api"; import { split } from "shlex"; +import { parseGraphQLJsonBody } from "./graphql"; type AtLeast = Partial & Pick; @@ -464,6 +465,8 @@ function importCommand(parseEntries: string[], workspaceId: string) { let body = {}; let bodyType: string | null = null; const bodyAsGET = getPairValue(flagsByName, false, ["G", "get"]); + const hasDataBody = dataParameters.length > 0 && !bodyAsGET; + const hasFormBody = multipartFormDataFromRaw != null || formDataParams.length > 0; if (multipartFormDataFromRaw) { // Handle multipart form data parsed from --data-raw (Chrome DevTools format) @@ -491,15 +494,21 @@ function importCommand(parseEntries: string[], workspaceId: string) { enabled: true, }); } else if (dataParameters.length > 0) { - bodyType = - mimeType === "application/json" || mimeType === "text/xml" || mimeType === "text/plain" - ? mimeType - : "other"; - body = { - text: dataParameters - .map(({ name, value }) => (name && value ? `${name}=${value}` : name || value)) - .join("&"), - }; + const text = dataParameters + .map(({ name, value }) => (name && value ? `${name}=${value}` : name || value)) + .join("&"); + const graphqlBody = parseGraphQLJsonBody({ mimeType, text, url }); + + if (graphqlBody != null) { + bodyType = "graphql"; + body = graphqlBody; + } else if (mimeType === "application/json" || mimeType === "text/xml" || mimeType === "text/plain") { + bodyType = mimeType; + body = { text }; + } else { + bodyType = "other"; + body = { text }; + } } else if (formDataParams.length) { bodyType = mimeType ?? "multipart/form-data"; body = { @@ -517,8 +526,8 @@ function importCommand(parseEntries: string[], workspaceId: string) { // Method let method = getPairValue(flagsByName, "", ["X", "request"]).toUpperCase(); - if (method === "" && body) { - method = "text" in body || "form" in body ? "POST" : "GET"; + if (method === "") { + method = hasDataBody || hasFormBody ? "POST" : "GET"; } const request: ExportResources["httpRequests"][0] = { diff --git a/plugins/importer-curl/tests/graphql.test.ts b/plugins/importer-curl/tests/graphql.test.ts new file mode 100644 index 00000000..756ae619 --- /dev/null +++ b/plugins/importer-curl/tests/graphql.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from "vite-plus/test"; +import { isGraphQLJsonBody, parseGraphQLJsonBody } from "../src/graphql"; + +describe("isGraphQLJsonBody", () => { + test("detects named query documents without a GraphQL URL", () => { + const args = { + mimeType: "application/json", + text: JSON.stringify({ + query: "query Search($id: ID!) { node(id: $id) { id } }", + variables: { id: "123" }, + operationName: "Search", + }), + url: "https://api.example.com/search", + }; + + expect(isGraphQLJsonBody(args)).toBe(true); + expect(parseGraphQLJsonBody(args)).toEqual({ + query: "query Search($id: ID!) { node(id: $id) { id } }", + variables: '{\n "id": "123"\n}', + operationName: "Search", + }); + }); + + test("detects mutation documents", () => { + expect( + isGraphQLJsonBody({ + mimeType: "application/json", + text: JSON.stringify({ query: "mutation Save { saveThing { id } }" }), + url: "https://api.example.com", + }), + ).toBe(true); + }); + + test("detects anonymous selection set documents", () => { + expect( + isGraphQLJsonBody({ + mimeType: "application/json", + text: JSON.stringify({ query: "{ viewer { id email } }" }), + url: "https://api.example.com", + }), + ).toBe(true); + }); + + test("detects document bodies on GraphQL-looking paths", () => { + expect( + isGraphQLJsonBody({ + mimeType: "application/json", + text: JSON.stringify({ query: "query Search { viewer { id } }", operationName: "Search" }), + url: "https://api.example.com/v1/graphql", + }), + ).toBe(true); + }); + + test("does not detect incomplete operation documents even on GraphQL-looking paths", () => { + expect( + isGraphQLJsonBody({ + mimeType: "application/json", + text: JSON.stringify({ query: "query Search", operationName: "Search" }), + url: "https://api.example.com/graphql", + }), + ).toBe(false); + }); + + test("does not detect plain JSON query fields even on GraphQL-looking paths", () => { + expect( + isGraphQLJsonBody({ + mimeType: "application/json", + text: JSON.stringify({ query: "SearchQueryInput!" }), + url: "https://api.example.com/graphql", + }), + ).toBe(false); + }); + + test("does not use variables and operationName alone as enough evidence", () => { + expect( + isGraphQLJsonBody({ + mimeType: "application/json", + text: JSON.stringify({ + query: "SearchQueryInput!", + variables: { id: "123" }, + operationName: "Search", + }), + url: "https://api.example.com", + }), + ).toBe(false); + }); + + test("detects bodies with string variables without parsing them", () => { + const args = { + mimeType: "application/json", + text: JSON.stringify({ + query: "query Search($id: ID!) { node(id: $id) { id } }", + variables: '{ "id": "123" }', + }), + url: "https://api.example.com", + }; + + expect(isGraphQLJsonBody(args)).toBe(true); + expect(parseGraphQLJsonBody(args)).toEqual({ + query: "query Search($id: ID!) { node(id: $id) { id } }", + variables: '{ "id": "123" }', + }); + }); + + test("does not detect GraphQL envelopes with extra fields", () => { + const args = { + mimeType: "application/json", + text: JSON.stringify({ + query: "query Search($id: ID!) { node(id: $id) { id } }", + variables: { id: "123" }, + extensions: { persistedQuery: { version: 1, sha256Hash: "abc123" } }, + }), + url: "https://api.example.com/graphql", + }; + + expect(isGraphQLJsonBody(args)).toBe(false); + expect(parseGraphQLJsonBody(args)).toBeNull(); + }); + + test("ignores invalid JSON and non-object JSON", () => { + expect( + isGraphQLJsonBody({ + mimeType: "application/json", + text: "not json", + url: "https://api.example.com/graphql", + }), + ).toBe(false); + expect( + isGraphQLJsonBody({ + mimeType: "application/json", + text: "[]", + url: "https://api.example.com/graphql", + }), + ).toBe(false); + }); + + test("ignores non-JSON MIME types", () => { + expect( + isGraphQLJsonBody({ + mimeType: "text/plain", + text: JSON.stringify({ query: "query Search { viewer { id } }" }), + url: "https://api.example.com/graphql", + }), + ).toBe(false); + }); +}); diff --git a/plugins/importer-curl/tests/index.test.ts b/plugins/importer-curl/tests/index.test.ts index ab25b1e9..6852ab09 100644 --- a/plugins/importer-curl/tests/index.test.ts +++ b/plugins/importer-curl/tests/index.test.ts @@ -562,6 +562,53 @@ describe("importer-curl", () => { }); }); + test("Imports GraphQL JSON data as a GraphQL request", () => { + expect( + convertCurl( + `curl 'https://yaak.app/graphql' -H 'Content-Type: application/json' --data-raw $'{"query":"query Search($id: ID\\u0021) { node(id: $id) { id } }","variables":{"id":"123"}}'`, + ), + ).toEqual({ + resources: { + workspaces: [baseWorkspace()], + httpRequests: [ + baseRequest({ + url: "https://yaak.app/graphql", + method: "POST", + headers: [{ name: "Content-Type", value: "application/json", enabled: true }], + bodyType: "graphql", + body: { + query: "query Search($id: ID!) { node(id: $id) { id } }", + variables: '{\n "id": "123"\n}', + }, + }), + ], + }, + }); + }); + + test("Imports GraphQL JSON with extensions as JSON", () => { + expect( + convertCurl( + `curl 'https://yaak.app/graphql' -H 'Content-Type: application/json' --data-raw $'{"query":"query Search($id: ID\\u0021) { node(id: $id) { id } }","extensions":{"persistedQuery":{"version":1,"sha256Hash":"abc123"}}}'`, + ), + ).toEqual({ + resources: { + workspaces: [baseWorkspace()], + httpRequests: [ + baseRequest({ + url: "https://yaak.app/graphql", + method: "POST", + headers: [{ name: "Content-Type", value: "application/json", enabled: true }], + bodyType: "application/json", + body: { + text: '{"query":"query Search($id: ID!) { node(id: $id) { id } }","extensions":{"persistedQuery":{"version":1,"sha256Hash":"abc123"}}}', + }, + }), + ], + }, + }); + }); + test("Imports data with multiple escape sequences", () => { expect( convertCurl( From c833aeba789cf4d76daddb0517478e1ec930b04d Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Sat, 4 Jul 2026 15:13:34 -0700 Subject: [PATCH 2/8] Fix commit dialog banner layout --- apps/yaak-client/components/CommercialUseBanner.tsx | 3 +-- apps/yaak-client/components/git/GitCommitDialog.tsx | 8 ++++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/yaak-client/components/CommercialUseBanner.tsx b/apps/yaak-client/components/CommercialUseBanner.tsx index aa7265ee..f1719f28 100644 --- a/apps/yaak-client/components/CommercialUseBanner.tsx +++ b/apps/yaak-client/components/CommercialUseBanner.tsx @@ -10,7 +10,6 @@ import { DismissibleBanner } from "./core/DismissibleBanner"; const COMMERCIAL_USE_SNOOZE_MS = 7 * 24 * 60 * 60 * 1000; const COMMERCIAL_USE_BANNER_MESSAGE = "Personal use of Yaak is free. If you’re using Yaak at work, please purchase a license."; -const hiddenBanner = ; export function CommercialUseBanner({ source, @@ -56,7 +55,7 @@ export function CommercialUseBanner({ }, [setSnoozedAt, snoozed, source]); if (!visible || isSnoozeLoading || (snoozed && !snoozeStartedRef.current)) { - return hiddenBanner; + return null; } return ( diff --git a/apps/yaak-client/components/git/GitCommitDialog.tsx b/apps/yaak-client/components/git/GitCommitDialog.tsx index 799ad98f..bf875d9b 100644 --- a/apps/yaak-client/components/git/GitCommitDialog.tsx +++ b/apps/yaak-client/components/git/GitCommitDialog.tsx @@ -206,9 +206,10 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) { layout="horizontal" defaultRatio={0.6} firstSlot={({ style }) => ( -
+
)} secondSlot={({ style: innerStyle }) => ( -
+
Date: Sat, 4 Jul 2026 23:21:53 -0700 Subject: [PATCH 3/8] Add in-app micro-feedback prompts (#497) --- apps/yaak-client/components/FeedbackToast.tsx | 76 +++++++++++++ .../RecentHttpResponsesDropdown.tsx | 4 +- .../components/Settings/SettingsGeneral.tsx | 11 ++ apps/yaak-client/components/core/Input.tsx | 1 + apps/yaak-client/components/core/Toast.tsx | 80 ++++++++++++-- .../components/git/GitCommitDialog.tsx | 3 + .../responseViewers/EventStreamViewer.tsx | 1 + apps/yaak-client/lib/featureFeedback.tsx | 103 ++++++++++++++++++ .../lib/featureFeedbackConstants.ts | 8 ++ apps/yaak-client/lib/tauri.ts | 1 + apps/yaak-client/lib/toast.tsx | 8 +- crates-tauri/yaak-app-client/src/feedback.rs | 67 ++++++++++++ crates-tauri/yaak-app-client/src/lib.rs | 12 ++ .../yaak-system-appearance/src/lib.rs | 9 +- crates/yaak-models/bindings/gen_models.ts | 1 + .../20260704000000_prompt-feedback.sql | 3 + crates/yaak-models/src/models.rs | 4 + crates/yaak-models/src/queries/settings.rs | 1 + 18 files changed, 377 insertions(+), 16 deletions(-) create mode 100644 apps/yaak-client/components/FeedbackToast.tsx create mode 100644 apps/yaak-client/lib/featureFeedback.tsx create mode 100644 apps/yaak-client/lib/featureFeedbackConstants.ts create mode 100644 crates-tauri/yaak-app-client/src/feedback.rs create mode 100644 crates/yaak-models/migrations/20260704000000_prompt-feedback.sql diff --git a/apps/yaak-client/components/FeedbackToast.tsx b/apps/yaak-client/components/FeedbackToast.tsx new file mode 100644 index 00000000..e80e75c3 --- /dev/null +++ b/apps/yaak-client/components/FeedbackToast.tsx @@ -0,0 +1,76 @@ +import { HStack, VStack } from "@yaakapp-internal/ui"; +import { useRef, useState } from "react"; +import type { FeedbackFeature } from "../lib/featureFeedbackConstants"; +import { FEEDBACK_FEATURES } from "../lib/featureFeedbackConstants"; +import { invokeCmd } from "../lib/tauri"; +import { hideToastById, showToast } from "../lib/toast"; +import { Button } from "./core/Button"; +import { Input } from "./core/Input"; + +interface Props { + feature: FeedbackFeature; + onDone: () => void; +} + +export function FeedbackToast({ feature, onDone }: Props) { + const [text, setText] = useState(""); + const [sent, setSent] = useState(false); + const sentRef = useRef(false); + + const handleDismiss = () => { + onDone(); + hideToastById(`feature-feedback-${feature}`); + }; + + const handleSend = () => { + const trimmedText = text.trim(); + if (sentRef.current || trimmedText.length === 0) return; + + sentRef.current = true; + setSent(true); + onDone(); + + // Fire-and-forget; failures are intentionally ignored + invokeCmd("cmd_send_feedback", { feature, text: trimmedText }).catch(() => {}); + showToast({ + id: `feature-feedback-${feature}`, + timeout: 3000, + color: "success", + message: "Thanks for the feedback!", + }); + }; + + return ( + +

{FEEDBACK_FEATURES[feature]}

+
+ +
+ + + + +
+ ); +} diff --git a/apps/yaak-client/components/RecentHttpResponsesDropdown.tsx b/apps/yaak-client/components/RecentHttpResponsesDropdown.tsx index 6978a26b..4db7a0f5 100644 --- a/apps/yaak-client/components/RecentHttpResponsesDropdown.tsx +++ b/apps/yaak-client/components/RecentHttpResponsesDropdown.tsx @@ -92,7 +92,9 @@ export const RecentHttpResponsesDropdown = function ResponsePane({ ), leftSlot: activeResponse?.id === r.id ? : , - onSelect: () => onPinnedResponseId(r.id), + onSelect: () => { + onPinnedResponseId(r.id); + }, }); } diff --git a/apps/yaak-client/components/Settings/SettingsGeneral.tsx b/apps/yaak-client/components/Settings/SettingsGeneral.tsx index 4ec6804a..bd8a0957 100644 --- a/apps/yaak-client/components/Settings/SettingsGeneral.tsx +++ b/apps/yaak-client/components/Settings/SettingsGeneral.tsx @@ -112,6 +112,17 @@ export function SettingsGeneral() { + + + patchModel(settings, { promptFeedback })} + /> + + + {showWorkspaceSettingsMovedBanner && ( void }) => ReactNode; icon?: ShowToastRequest["icon"] | null; color?: ShowToastRequest["color"]; + // Grow with the content (up to the viewport) instead of scrolling internally + // past the default max height + dynamicHeight?: boolean; + // Hide the close button, for toasts that render their own dismiss action. + // Escape still closes the toast + hideDismiss?: boolean; } const ICONS: Record, IconProps["icon"] | null> = { @@ -28,7 +35,47 @@ const ICONS: Record, IconProps["icon warning: "alert_triangle", }; -export function Toast({ children, open, onClose, timeout, action, icon, color }: ToastProps) { +export function Toast({ + children, + open, + onClose, + timeout, + action, + icon, + color, + dynamicHeight, + hideDismiss, +}: ToastProps) { + const onCloseRef = useRef(onClose); + const timeoutRef = useRef | null>(null); + const [autoHideCanceled, setAutoHideCanceled] = useState(false); + + useEffect(() => { + onCloseRef.current = onClose; + }, [onClose]); + + const cancelAutoHide = useCallback(() => { + if (timeoutRef.current == null) return; + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + setAutoHideCanceled(true); + }, []); + + useEffect(() => { + if (!open || timeout == null || autoHideCanceled) return; + + timeoutRef.current = setTimeout(() => { + timeoutRef.current = null; + onCloseRef.current(); + }, timeout); + + return () => { + if (timeoutRef.current == null) return; + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + }; + }, [autoHideCanceled, open, timeout]); + useKey( "Escape", () => { @@ -56,8 +103,17 @@ export function Toast({ children, open, onClose, timeout, action, icon, color }: "relative pointer-events-auto bg-surface text-text rounded-lg", "border border-border shadow-lg w-100", )} + onFocusCapture={cancelAutoHide} + onKeyDownCapture={cancelAutoHide} + onPointerDownCapture={cancelAutoHide} > -
+
{toastIcon && }
{children}
@@ -65,16 +121,18 @@ export function Toast({ children, open, onClose, timeout, action, icon, color }:
- + {!hideDismiss && ( + + )} - {timeout != null && ( + {timeout != null && !autoHideCanceled && (
> = {}; +const FEATURE_USE_DEBOUNCE_MS = 10_000; + +const kvArgs = (feature: FeedbackFeature) => ({ + namespace: "global", + key: ["feature-feedback", feature], +}); + +function getFeatureFeedbackState(feature: FeedbackFeature): FeatureFeedbackState { + return getKeyValue({ + ...kvArgs(feature), + fallback: { uses: 0, done: false }, + }); +} + +function patchFeatureFeedbackState(feature: FeedbackFeature, patch: Partial) { + const value = { ...getFeatureFeedbackState(feature), ...patch }; + setKeyValue({ ...kvArgs(feature), value }).catch(console.error); +} + +function markFeatureFeedbackDone(feature: FeedbackFeature) { + patchFeatureFeedbackState(feature, { done: true }); +} + +function showFeedbackToast(feature: FeedbackFeature) { + if (!jotaiStore.get(settingsAtom).promptFeedback) return; + + showToast({ + id: `feature-feedback-${feature}`, + timeout: FEEDBACK_PROMPT_TIMEOUT_MS, + dynamicHeight: true, + hideDismiss: true, + message: ( + markFeatureFeedbackDone(feature)} /> + ), + }); +} + +function showFeedbackToastWhenReady(feature: FeedbackFeature) { + setTimeout(() => { + if (!jotaiStore.get(settingsAtom).promptFeedback) return; + + if (jotaiStore.get(dialogsAtom).length === 0) { + showFeedbackToast(feature); + return; + } + + const unsubscribe = jotaiStore.sub(dialogsAtom, () => { + if (jotaiStore.get(dialogsAtom).length > 0) return; + + unsubscribe(); + showFeedbackToast(feature); + }); + }, FEEDBACK_PROMPT_DELAY_MS); +} + +// Record a successful use of a feature, and prompt for feedback on the Nth use. +// Nothing is ever sent to the server from here; showing the toast is local-only +// and a submission only happens when the user clicks Send in it. +export function trackFeatureUsage(feature: FeedbackFeature) { + if (appInfo.featureLicense !== true || !jotaiStore.get(settingsAtom).promptFeedback) return; + + const now = Date.now(); + if (lastTrackedAt[feature] != null && now - lastTrackedAt[feature] < FEATURE_USE_DEBOUNCE_MS) { + return; + } + lastTrackedAt[feature] = now; + + const state = getFeatureFeedbackState(feature); + if (state.done) return; + + const uses = state.uses + 1; + const shouldPrompt = uses >= PROMPT_AFTER_USES && !promptedThisSession; + + patchFeatureFeedbackState(feature, { uses }); + if (!shouldPrompt) return; + + promptedThisSession = true; + showFeedbackToastWhenReady(feature); +} diff --git a/apps/yaak-client/lib/featureFeedbackConstants.ts b/apps/yaak-client/lib/featureFeedbackConstants.ts new file mode 100644 index 00000000..51289f37 --- /dev/null +++ b/apps/yaak-client/lib/featureFeedbackConstants.ts @@ -0,0 +1,8 @@ +// Feature keys are sent to the server and used to group feedback for analysis. +// NEVER rename a key once it has shipped, or historical feedback will be split +// across the old and new names. +export const FEEDBACK_FEATURES = { + "git-sync": "How is Git sync working for you?", +} as const; + +export type FeedbackFeature = keyof typeof FEEDBACK_FEATURES; diff --git a/apps/yaak-client/lib/tauri.ts b/apps/yaak-client/lib/tauri.ts index dc875b37..df9ad069 100644 --- a/apps/yaak-client/lib/tauri.ts +++ b/apps/yaak-client/lib/tauri.ts @@ -48,6 +48,7 @@ type TauriCmd = | "cmd_save_response" | "cmd_secure_template" | "cmd_send_ephemeral_request" + | "cmd_send_feedback" | "cmd_send_http_request" | "cmd_template_function_summaries" | "cmd_template_function_config" diff --git a/apps/yaak-client/lib/toast.tsx b/apps/yaak-client/lib/toast.tsx index e02d86b3..613f6581 100644 --- a/apps/yaak-client/lib/toast.tsx +++ b/apps/yaak-client/lib/toast.tsx @@ -28,15 +28,17 @@ export function showToast({ setTimeout(() => { const newToast: ToastInstance = { id, uniqueKey, timeout, ...props }; - if (timeout != null) { - setTimeout(() => hideToast(newToast), timeout); - } jotaiStore.set(toastsAtom, (prev) => [...prev, newToast]); }, delay); return id; } +export function hideToastById(id: string) { + const toast = jotaiStore.get(toastsAtom).find((t) => t.id === id); + if (toast) hideToast(toast); +} + export function hideToast(toHide: ToastInstance) { jotaiStore.set(toastsAtom, (all) => { const t = all.find((t) => t.uniqueKey === toHide.uniqueKey); diff --git a/crates-tauri/yaak-app-client/src/feedback.rs b/crates-tauri/yaak-app-client/src/feedback.rs new file mode 100644 index 00000000..f436d050 --- /dev/null +++ b/crates-tauri/yaak-app-client/src/feedback.rs @@ -0,0 +1,67 @@ +use log::{debug, warn}; +use serde::Serialize; +use tauri::{AppHandle, Runtime, is_dev}; +use yaak_api::{ApiClientKind, yaak_api_client}; +use yaak_common::platform::get_os_str; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct FeedbackPayload { + feature: String, + text: String, + app_version: String, + os: String, +} + +/// Send explicit user feedback for a feature. Fire-and-forget: errors are +/// logged and swallowed so a failed send never surfaces to the user. +pub async fn send_feedback(app_handle: &AppHandle, feature: String, text: String) { + let app_version = app_handle.package_info().version.to_string(); + let payload = FeedbackPayload { + feature, + text, + app_version: app_version.clone(), + os: get_os_str().to_string(), + }; + + let client = match yaak_api_client(ApiClientKind::App, &app_version) { + Ok(c) => c, + Err(e) => { + debug!("Failed to build feedback client: {e:?}"); + return; + } + }; + + let url = build_url("/app-feedback"); + debug!( + "Sending feature feedback to {url}: feature={}, app_version={}, os={}, text_len={}", + payload.feature, + payload.app_version, + payload.os, + payload.text.len() + ); + + match client.post(&url).json(&payload).send().await { + Ok(resp) => { + let status = resp.status(); + if status.is_success() { + debug!("Sent feature feedback with status {status}"); + } else { + let body = resp + .text() + .await + .unwrap_or_else(|e| format!("")); + warn!("Failed to send feature feedback with status {status}: {body}"); + } + } + Err(e) => warn!("Failed to send feature feedback: {e:?}"), + } +} + +fn build_url(path: &str) -> String { + if is_dev() { + format!("http://localhost:9444/api/v1{path}") + } else { + format!("https://api.yaak.app/api/v1{path}") + } +} diff --git a/crates-tauri/yaak-app-client/src/lib.rs b/crates-tauri/yaak-app-client/src/lib.rs index 87bb07eb..ea43bced 100644 --- a/crates-tauri/yaak-app-client/src/lib.rs +++ b/crates-tauri/yaak-app-client/src/lib.rs @@ -65,6 +65,7 @@ use yaak_tls::find_client_certificate; mod commands; mod encoding; mod error; +mod feedback; mod git_ext; mod git_watcher; mod grpc; @@ -292,6 +293,16 @@ async fn cmd_render_template( Ok(result) } +#[tauri::command] +async fn cmd_send_feedback( + app_handle: AppHandle, + feature: String, + text: String, +) -> YaakResult<()> { + feedback::send_feedback(&app_handle, feature, text).await; + Ok(()) +} + #[tauri::command] async fn cmd_dismiss_notification( window: WebviewWindow, @@ -1819,6 +1830,7 @@ pub fn run() { cmd_delete_send_history, cmd_dismiss_notification, cmd_export_data, + cmd_send_feedback, cmd_http_request_body, cmd_http_response_body, cmd_format_json, diff --git a/crates-tauri/yaak-system-appearance/src/lib.rs b/crates-tauri/yaak-system-appearance/src/lib.rs index cd644390..b6f7466e 100644 --- a/crates-tauri/yaak-system-appearance/src/lib.rs +++ b/crates-tauri/yaak-system-appearance/src/lib.rs @@ -1,13 +1,18 @@ use std::sync::{Arc, Mutex}; +#[cfg(target_os = "linux")] use std::time::Duration; +#[cfg(target_os = "linux")] use log::{debug, warn}; -use tauri::{AppHandle, Emitter, Runtime}; +#[cfg(target_os = "linux")] +use tauri::Emitter; +use tauri::{AppHandle, Runtime}; pub const INITIAL_APPEARANCE_GLOBAL: &str = "__YAAK_INITIAL_APPEARANCE__"; pub const INITIAL_APPEARANCE_SOURCE_GLOBAL: &str = "__YAAK_INITIAL_APPEARANCE_SOURCE__"; pub const SYSTEM_APPEARANCE_CHANGE_EVENT: &str = "system_appearance_change"; +#[cfg(target_os = "linux")] const SYSTEM_APPEARANCE_POLL_INTERVAL: Duration = Duration::from_secs(1); #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -42,6 +47,8 @@ impl InitialAppearanceSource { #[derive(Clone)] pub struct SystemAppearanceState { + // Only read by the Linux polling thread + #[cfg_attr(not(target_os = "linux"), allow(dead_code))] last_appearance: Arc>>, } diff --git a/crates/yaak-models/bindings/gen_models.ts b/crates/yaak-models/bindings/gen_models.ts index 287e9b4e..6a606587 100644 --- a/crates/yaak-models/bindings/gen_models.ts +++ b/crates/yaak-models/bindings/gen_models.ts @@ -402,6 +402,7 @@ export type Settings = { themeLight: string; updateChannel: string; hideLicenseBadge: boolean; + promptFeedback: boolean; autoupdate: boolean; autoDownloadUpdates: boolean; checkNotifications: boolean; diff --git a/crates/yaak-models/migrations/20260704000000_prompt-feedback.sql b/crates/yaak-models/migrations/20260704000000_prompt-feedback.sql new file mode 100644 index 00000000..448c894f --- /dev/null +++ b/crates/yaak-models/migrations/20260704000000_prompt-feedback.sql @@ -0,0 +1,3 @@ +-- Add a setting to enable in-app feature feedback prompts +ALTER TABLE settings + ADD COLUMN prompt_feedback BOOLEAN DEFAULT TRUE NOT NULL; diff --git a/crates/yaak-models/src/models.rs b/crates/yaak-models/src/models.rs index 4dd59248..2148650e 100644 --- a/crates/yaak-models/src/models.rs +++ b/crates/yaak-models/src/models.rs @@ -246,6 +246,7 @@ pub struct Settings { pub theme_light: String, pub update_channel: String, pub hide_license_badge: bool, + pub prompt_feedback: bool, pub autoupdate: bool, pub auto_download_updates: bool, pub check_notifications: bool, @@ -303,6 +304,7 @@ impl UpsertModelInfo for Settings { (ThemeLight, self.theme_light.as_str().into()), (UpdateChannel, self.update_channel.into()), (HideLicenseBadge, self.hide_license_badge.into()), + (PromptFeedback, self.prompt_feedback.into()), (Autoupdate, self.autoupdate.into()), (AutoDownloadUpdates, self.auto_download_updates.into()), (ColoredMethods, self.colored_methods.into()), @@ -332,6 +334,7 @@ impl UpsertModelInfo for Settings { SettingsIden::ThemeLight, SettingsIden::UpdateChannel, SettingsIden::HideLicenseBadge, + SettingsIden::PromptFeedback, SettingsIden::Autoupdate, SettingsIden::AutoDownloadUpdates, SettingsIden::ColoredMethods, @@ -372,6 +375,7 @@ impl UpsertModelInfo for Settings { autoupdate: row.get("autoupdate")?, auto_download_updates: row.get("auto_download_updates")?, hide_license_badge: row.get("hide_license_badge")?, + prompt_feedback: row.get("prompt_feedback")?, colored_methods: row.get("colored_methods")?, check_notifications: row.get("check_notifications")?, hotkeys: serde_json::from_str(&hotkeys).unwrap_or_default(), diff --git a/crates/yaak-models/src/queries/settings.rs b/crates/yaak-models/src/queries/settings.rs index 660e6e42..b2d70832 100644 --- a/crates/yaak-models/src/queries/settings.rs +++ b/crates/yaak-models/src/queries/settings.rs @@ -38,6 +38,7 @@ impl<'a> ClientDb<'a> { autoupdate: true, colored_methods: false, hide_license_badge: false, + prompt_feedback: true, auto_download_updates: true, check_notifications: true, hotkeys: HashMap::new(), From ea33261e08f02dd87b6e7f91ca18bf369b5dae95 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Sun, 5 Jul 2026 06:55:21 -0700 Subject: [PATCH 4/8] Update release docs for generated notes and version-locked CLI The release command no longer teaches hand-writing GitHub notes: they are generated from the yaak.app changelog items by release_ship_beta and release_publish. AGENTS.md tag rule updated for the CLI being version-locked to app tags. Co-Authored-By: Claude Fable 5 --- .../release/generate-release-notes.md | 69 ++++++++----------- AGENTS.md | 2 +- 2 files changed, 30 insertions(+), 41 deletions(-) diff --git a/.claude/commands/release/generate-release-notes.md b/.claude/commands/release/generate-release-notes.md index a2e98b4a..4f45d76d 100644 --- a/.claude/commands/release/generate-release-notes.md +++ b/.claude/commands/release/generate-release-notes.md @@ -1,50 +1,39 @@ --- -description: Generate formatted release notes for Yaak releases -allowed-tools: Bash(git tag:*) +description: Ship a Yaak release (notes are generated from changelog items) --- -Generate formatted release notes for Yaak releases by analyzing git history and pull request descriptions. +Release notes are NOT written by hand anymore. They are generated from the +changelog release items on yaak.app when a release ships. Never edit GitHub +release bodies directly — the changelog items are the single source of truth. -## What to do +Terminology: a "changelog release" is the draft on yaak.app (managed via the +yaak MCP release_* tools). A "GitHub release" is the artifact record CI +creates; you only ever publish its draft, never write its notes. -1. Identifies the version tag and previous version -2. Retrieves all commits between versions - - If the version is a beta version, it retrieves commits between the beta version and previous beta version - - If the version is a stable version, it retrieves commits between the stable version and the previous stable version -3. Fetches PR descriptions for linked issues to find: - - Feedback URLs (feedback.yaak.app) - - Additional context and descriptions - - Installation links for plugins -4. Formats the release notes using the standard Yaak format: - - Changelog badge at the top - - Bulleted list of changes with PR links - - Feedback links where available - - Full changelog comparison link at the bottom +## Release process -## Output Format +1. **Keep the draft changelog release current** (yaak MCP): as user-facing PRs + merge, add items with `release_add_item` — link the feedback post and/or PR + and titles derive automatically. Only MERGED changes; open PRs must wait. + CLI changes are included with a "CLI:" title prefix. Internal tooling and + dependency bumps never get items. Check state with `release_get ` + (items show which beta they shipped in, or "pending"). -The skill generates markdown-formatted release notes following this structure: +2. **Tag the release** (`v` or `v-beta.N`). CI builds + artifacts, creates a DRAFT GitHub release, and publishes the CLI to npm at + the same version. -```markdown -[![Changelog](https://img.shields.io/badge/Changelog-VERSION-blue)](https://yaak.app/changelog/VERSION) +3. **Publish the draft GitHub release** (the only GitHub step — this is when + binaries go public). Leave the body empty and the prerelease flag as-is; + both are handled next. -- Feature/fix description in by @username [#123](https://github.com/mountain-loop/yaak/pull/123) -- [Linked feedback item](https://feedback.yaak.app/p/item) by @username in [#456](https://github.com/mountain-loop/yaak/pull/456) -- A simple item that doesn't have a feedback or PR link +4. **Ship via the yaak MCP**: + - Beta: `release_ship_beta ` — writes the GitHub pre-release notes + from that beta's items, sets the prerelease flag, moves linked feedback + posts to released_beta with featured comments, and stamps items. + - Stable: `release_publish ` — writes full notes with the + changelog link, marks the GitHub release as a full release, makes the + yaak.app changelog page live, and moves linked posts to released. -**Full Changelog**: https://github.com/mountain-loop/yaak/compare/vPREV...vCURRENT -``` - -**IMPORTANT**: Always add a blank lines around the markdown code fence and output the markdown code block last -**IMPORTANT**: PRs by `@gschier` should not mention the @username -**IMPORTANT**: These are app release notes. Exclude CLI-only changes (commits prefixed with `cli:` or only touching `crates-cli/`) since the CLI has its own release process. - -## After Generating Release Notes - -After outputting the release notes, ask the user if they would like to create a draft GitHub release with these notes. If they confirm, create the release using: - -```bash -gh release create --draft --prerelease --title "Release " --notes '' -``` - -**IMPORTANT**: The release title format is "Release XXXX" where XXXX is the version WITHOUT the `v` prefix. For example, tag `v2026.2.1-beta.1` gets title "Release 2026.2.1-beta.1". +Both ship commands verify the GitHub release exists first and are safe to +re-run (already-shipped posts and items are skipped). diff --git a/AGENTS.md b/AGENTS.md index 6d9a7968..f0ed1857 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,2 +1,2 @@ -- Tag safety: app releases use `v*` tags and CLI releases use `yaak-cli-*` tags; always confirm which one is requested before retagging. +- Tag safety: app AND CLI releases both ship from `v*` tags (the CLI is version-locked to the app and publishes to npm on every app tag); `@yaakapp/api` uses `yaak-api-*` tags. Always confirm which is requested before retagging. - Do not commit, push, or tag without explicit approval From 4cad67130521832602903739431e2734ab5f87d2 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Sun, 5 Jul 2026 06:56:58 -0700 Subject: [PATCH 5/8] Remove release-notes command The yaak.app MCP server carries the release workflow in its tool descriptions and instructions; notes are generated from changelog items on ship. Co-Authored-By: Claude Fable 5 --- .../release/generate-release-notes.md | 39 ------------------- 1 file changed, 39 deletions(-) delete mode 100644 .claude/commands/release/generate-release-notes.md diff --git a/.claude/commands/release/generate-release-notes.md b/.claude/commands/release/generate-release-notes.md deleted file mode 100644 index 4f45d76d..00000000 --- a/.claude/commands/release/generate-release-notes.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -description: Ship a Yaak release (notes are generated from changelog items) ---- - -Release notes are NOT written by hand anymore. They are generated from the -changelog release items on yaak.app when a release ships. Never edit GitHub -release bodies directly — the changelog items are the single source of truth. - -Terminology: a "changelog release" is the draft on yaak.app (managed via the -yaak MCP release_* tools). A "GitHub release" is the artifact record CI -creates; you only ever publish its draft, never write its notes. - -## Release process - -1. **Keep the draft changelog release current** (yaak MCP): as user-facing PRs - merge, add items with `release_add_item` — link the feedback post and/or PR - and titles derive automatically. Only MERGED changes; open PRs must wait. - CLI changes are included with a "CLI:" title prefix. Internal tooling and - dependency bumps never get items. Check state with `release_get ` - (items show which beta they shipped in, or "pending"). - -2. **Tag the release** (`v` or `v-beta.N`). CI builds - artifacts, creates a DRAFT GitHub release, and publishes the CLI to npm at - the same version. - -3. **Publish the draft GitHub release** (the only GitHub step — this is when - binaries go public). Leave the body empty and the prerelease flag as-is; - both are handled next. - -4. **Ship via the yaak MCP**: - - Beta: `release_ship_beta ` — writes the GitHub pre-release notes - from that beta's items, sets the prerelease flag, moves linked feedback - posts to released_beta with featured comments, and stamps items. - - Stable: `release_publish ` — writes full notes with the - changelog link, marks the GitHub release as a full release, makes the - yaak.app changelog page live, and moves linked posts to released. - -Both ship commands verify the GitHub release exists first and are safe to -re-run (already-shipped posts and items are skipped). From f2972ee5347b86c07fd023aad7c57e91943afb6d Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Sun, 5 Jul 2026 08:36:23 -0700 Subject: [PATCH 6/8] Clarify PR template checkboxes that must always be checked The "when reasonable" checkboxes read as optional, but the contribution policy workflow requires all boxes checked. Reword them as either/or statements and accept the legacy wording in the policy script so existing PRs keep validating. Co-Authored-By: Claude Fable 5 --- .github/pull_request_template.md | 6 ++-- .github/scripts/check-contribution-policy.js | 32 +++++++++++++------- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index f2376d6c..49d7b7ed 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -4,12 +4,14 @@ ## Submission + + - [ ] This PR is a bug fix. - [ ] If this PR is not a bug fix, I linked the feedback item where @gschier explicitly gave me permission to work on it. - [ ] I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md). - [ ] I tested this change locally. -- [ ] I added or updated tests when reasonable. -- [ ] I added screenshots or recordings for UI changes when reasonable. +- [ ] I added or updated tests, or tests are not reasonable for this change. +- [ ] I added screenshots or recordings, or this change does not affect the UI. Explicit permission feedback item (required if not a bug fix): diff --git a/.github/scripts/check-contribution-policy.js b/.github/scripts/check-contribution-policy.js index 43512752..9f9b0be4 100644 --- a/.github/scripts/check-contribution-policy.js +++ b/.github/scripts/check-contribution-policy.js @@ -53,16 +53,25 @@ const MANAGED_LABEL_NAMES = [ ...new Set(Object.values(LABELS).map((label) => label.name)), ]; +// Each checkbox lists its current label first, followed by legacy labels still +// accepted from PRs opened against older versions of the template. const CHECKBOXES = { - bugFix: "This PR is a bug fix.", - explicitPermission: + bugFix: ["This PR is a bug fix."], + explicitPermission: [ "If this PR is not a bug fix, I linked the feedback item where @gschier explicitly gave me permission to work on it.", - readContributing: + ], + readContributing: [ "I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).", - testedLocally: "I tested this change locally.", - testsUpdated: "I added or updated tests when reasonable.", - screenshotsAdded: + ], + testedLocally: ["I tested this change locally."], + testsUpdated: [ + "I added or updated tests, or tests are not reasonable for this change.", + "I added or updated tests when reasonable.", + ], + screenshotsAdded: [ + "I added screenshots or recordings, or this change does not affect the UI.", "I added screenshots or recordings for UI changes when reasonable.", + ], }; function escapeRegExp(value) { @@ -102,8 +111,8 @@ function normalizeCheckboxLabel(label) { .trim(); } -function checkboxState(body, label) { - const expectedLabel = normalizeCheckboxLabel(label); +function checkboxState(body, labels) { + const expectedLabels = new Set(labels.map(normalizeCheckboxLabel)); for (const line of body.split("\n")) { const match = line.match(/^\s*[-*]\s*\[([ xX])\]\s*(.*?)\s*$/i); @@ -112,7 +121,7 @@ function checkboxState(body, label) { continue; } - if (normalizeCheckboxLabel(match[2]) === expectedLabel) { + if (expectedLabels.has(normalizeCheckboxLabel(match[2]))) { return match[1].toLowerCase() === "x"; } } @@ -255,7 +264,8 @@ function analyzePullRequest(pr) { if (states.testsUpdated !== true) { blockers.push({ label: LABELS.policyUnmet.name, - message: "Confirm that tests were added or updated when reasonable.", + message: + "Confirm that tests were added or updated, or that tests are not reasonable for this change. Check the box either way.", }); } @@ -263,7 +273,7 @@ function analyzePullRequest(pr) { blockers.push({ label: LABELS.policyUnmet.name, message: - "Confirm that screenshots or recordings were added for UI changes when reasonable.", + "Confirm that screenshots or recordings were added, or that this change does not affect the UI. Check the box either way.", }); } } From b332a0eba9f7317a89cf93a692d82933b3e48425 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Sun, 5 Jul 2026 08:49:23 -0700 Subject: [PATCH 7/8] Fix startup failure from fd exhaustion when launched via Finder (#500) Co-authored-by: Claude Fable 5 --- Cargo.lock | 14 ++++++++++-- crates-tauri/yaak-app-client/Cargo.toml | 3 +++ crates-tauri/yaak-app-client/src/lib.rs | 8 +++++++ crates/yaak-models/src/blob_manager.rs | 15 +++++-------- crates/yaak-models/src/lib.rs | 11 ++++++--- crates/yaak-models/src/query_manager.rs | 30 ++++++++----------------- 6 files changed, 46 insertions(+), 35 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9b28c4c2..6315ae03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3885,9 +3885,9 @@ checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" [[package]] name = "libc" -version = "0.2.172" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libdbus-sys" @@ -6649,6 +6649,15 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "rlimit" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f35ee2729c56bb610f6dba436bf78135f728b7373bdffae2ec815b2d3eb98cc3" +dependencies = [ + "libc", +] + [[package]] name = "rolldown" version = "0.1.0" @@ -10932,6 +10941,7 @@ dependencies = [ "r2d2_sqlite", "rand 0.9.1", "reqwest 0.12.20", + "rlimit", "serde", "serde_json", "tauri", diff --git a/crates-tauri/yaak-app-client/Cargo.toml b/crates-tauri/yaak-app-client/Cargo.toml index 4bdb1c8c..ee20039f 100644 --- a/crates-tauri/yaak-app-client/Cargo.toml +++ b/crates-tauri/yaak-app-client/Cargo.toml @@ -24,6 +24,9 @@ tauri-build = { version = "2.6.1", features = [] } [target.'cfg(target_os = "linux")'.dependencies] openssl-sys = { version = "0.9.105", features = ["vendored"] } # For Ubuntu installation to work +[target.'cfg(any(target_os = "macos", target_os = "linux"))'.dependencies] +rlimit = "0.11" # Raise the launchd 256 open-file soft limit at startup + [dependencies] charset = "0.1.5" chrono = { workspace = true, features = ["serde"] } diff --git a/crates-tauri/yaak-app-client/src/lib.rs b/crates-tauri/yaak-app-client/src/lib.rs index ea43bced..696ecc2a 100644 --- a/crates-tauri/yaak-app-client/src/lib.rs +++ b/crates-tauri/yaak-app-client/src/lib.rs @@ -1676,6 +1676,14 @@ async fn cmd_check_for_updates( #[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(feature = "cef", tauri::cef_entry_point)] pub fn run() { + // GUI apps launched via Finder/launchd inherit a 256 open-file soft limit on macOS + // (1024 on most Linux desktops). SQLite WAL connections hold ~3 fds each, so raise + // the limit toward the hard cap before opening any DB pools. + #[cfg(any(target_os = "macos", target_os = "linux"))] + if let Err(e) = rlimit::increase_nofile_limit(10240) { + eprintln!("Failed to raise open-file limit: {e}"); + } + let mut builder = tauri::Builder::::default().plugin( Builder::default() .targets([ diff --git a/crates/yaak-models/src/blob_manager.rs b/crates/yaak-models/src/blob_manager.rs index 2f2fc2ef..31d4b155 100644 --- a/crates/yaak-models/src/blob_manager.rs +++ b/crates/yaak-models/src/blob_manager.rs @@ -5,7 +5,6 @@ use log::{debug, info}; use r2d2::Pool; use r2d2_sqlite::SqliteConnectionManager; use rusqlite::{OptionalExtension, params}; -use std::sync::{Arc, Mutex}; static BLOB_MIGRATIONS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/blob_migrations"); @@ -25,23 +24,21 @@ impl BodyChunk { } /// Manages the blob database connection pool. +// Pool is internally synchronized — don't wrap it in a Mutex. A Mutex held across the +// blocking `get()` serializes every blob access behind the slowest waiter, freezing the +// whole app whenever the pool is exhausted. #[derive(Debug, Clone)] pub struct BlobManager { - pool: Arc>>, + pool: Pool, } impl BlobManager { pub fn new(pool: Pool) -> Self { - Self { pool: Arc::new(Mutex::new(pool)) } + Self { pool } } pub fn connect(&self) -> BlobContext { - let conn = self - .pool - .lock() - .expect("Failed to gain lock on blob DB") - .get() - .expect("Failed to get blob DB connection from pool"); + let conn = self.pool.get().expect("Failed to get blob DB connection from pool"); BlobContext { conn } } } diff --git a/crates/yaak-models/src/lib.rs b/crates/yaak-models/src/lib.rs index d5a546b4..a642eaee 100644 --- a/crates/yaak-models/src/lib.rs +++ b/crates/yaak-models/src/lib.rs @@ -54,11 +54,15 @@ pub fn init_standalone( create_dir_all(parent)?; } - // Main database pool + // Main database pool. Sized for concurrent in-flight queries, not concurrent app + // features — connections are held per-statement, so even heavy fan-out (e.g. many + // gRPC streams) only needs a handful at once. Keep max_size modest: WAL connections + // hold ~3 file descriptors each, and macOS GUI apps get a 256 fd soft limit. info!("Initializing app database {db_path:?}"); let manager = sqlite_file_manager(db_path); let pool = Pool::builder() - .max_size(100) + .max_size(20) + .min_idle(Some(2)) .connection_timeout(Duration::from_secs(10)) .build(manager) .map_err(|e| Error::Database(e.to_string()))?; @@ -70,7 +74,8 @@ pub fn init_standalone( // Blob database pool let blob_manager = sqlite_file_manager(blob_path); let blob_pool = Pool::builder() - .max_size(50) + .max_size(10) + .min_idle(Some(1)) .connection_timeout(Duration::from_secs(10)) .build(blob_manager) .map_err(|e| Error::Database(e.to_string()))?; diff --git a/crates/yaak-models/src/query_manager.rs b/crates/yaak-models/src/query_manager.rs index ebd7f87d..b570f153 100644 --- a/crates/yaak-models/src/query_manager.rs +++ b/crates/yaak-models/src/query_manager.rs @@ -4,27 +4,25 @@ use crate::util::ModelPayload; use r2d2::Pool; use r2d2_sqlite::SqliteConnectionManager; use rusqlite::TransactionBehavior; -use std::sync::{Arc, Mutex, mpsc}; +use std::sync::mpsc; use yaak_database::{ConnectionOrTx, DbContext}; +// Pool is internally synchronized — don't wrap it in a Mutex. A Mutex held across the +// blocking `get()` serializes every DB access behind the slowest waiter, freezing the +// whole app whenever the pool is exhausted. #[derive(Debug, Clone)] pub struct QueryManager { - pool: Arc>>, + pool: Pool, events_tx: mpsc::Sender, } impl QueryManager { pub fn new(pool: Pool, events_tx: mpsc::Sender) -> Self { - QueryManager { pool: Arc::new(Mutex::new(pool)), events_tx } + QueryManager { pool, events_tx } } pub fn connect(&self) -> ClientDb<'_> { - let conn = self - .pool - .lock() - .expect("Failed to gain lock on DB") - .get() - .expect("Failed to get a new DB connection from the pool"); + let conn = self.pool.get().expect("Failed to get a new DB connection from the pool"); let ctx = DbContext::new(ConnectionOrTx::Connection(conn)); ClientDb::new(ctx, self.events_tx.clone()) } @@ -33,12 +31,7 @@ impl QueryManager { where F: FnOnce(&ClientDb) -> T, { - let conn = self - .pool - .lock() - .expect("Failed to gain lock on DB for transaction") - .get() - .expect("Failed to get new DB connection from the pool"); + let conn = self.pool.get().expect("Failed to get new DB connection from the pool"); let ctx = DbContext::new(ConnectionOrTx::Connection(conn)); let db = ClientDb::new(ctx, self.events_tx.clone()); @@ -53,12 +46,7 @@ impl QueryManager { where E: From, { - let mut conn = self - .pool - .lock() - .expect("Failed to gain lock on DB for transaction") - .get() - .expect("Failed to get new DB connection from the pool"); + let mut conn = self.pool.get().expect("Failed to get new DB connection from the pool"); let tx = conn .transaction_with_behavior(TransactionBehavior::Immediate) .expect("Failed to start DB transaction"); From 2c1cf5a13c66522ea22015760985d822ac2a08e2 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Sun, 5 Jul 2026 09:41:14 -0700 Subject: [PATCH 8/8] Restrict privileged workflows to official repo --- .github/workflows/claude.yml | 49 ----------------------- .github/workflows/contribution-policy.yml | 1 + .github/workflows/flathub.yml | 1 + .github/workflows/release-api-npm.yml | 1 + .github/workflows/release-app.yml | 1 + .github/workflows/release-cli-npm.yml | 1 + .github/workflows/sponsors.yml | 1 + 7 files changed, 6 insertions(+), 49 deletions(-) delete mode 100644 .github/workflows/claude.yml diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml deleted file mode 100644 index 9471a059..00000000 --- a/.github/workflows/claude.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: Claude Code - -on: - issue_comment: - types: [created] - pull_request_review_comment: - types: [created] - issues: - types: [opened, assigned] - pull_request_review: - types: [submitted] - -jobs: - claude: - if: | - (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || - (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || - (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read - issues: read - id-token: write - actions: read # Required for Claude to read CI results on PRs - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Run Claude Code - id: claude - uses: anthropics/claude-code-action@v1 - with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - - # This is an optional setting that allows Claude to read CI results on PRs - additional_permissions: | - actions: read - - # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. - # prompt: 'Update the pull request description to include a summary of changes.' - - # Optional: Add claude_args to customize behavior and configuration - # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md - # or https://code.claude.com/docs/en/cli-reference for available options - # claude_args: '--allowed-tools Bash(gh pr:*)' diff --git a/.github/workflows/contribution-policy.yml b/.github/workflows/contribution-policy.yml index 029d8aeb..0567c9fe 100644 --- a/.github/workflows/contribution-policy.yml +++ b/.github/workflows/contribution-policy.yml @@ -30,6 +30,7 @@ permissions: jobs: check: + if: github.repository == 'mountain-loop/yaak' name: Check contribution policy runs-on: ubuntu-latest steps: diff --git a/.github/workflows/flathub.yml b/.github/workflows/flathub.yml index 50408223..8af49cf0 100644 --- a/.github/workflows/flathub.yml +++ b/.github/workflows/flathub.yml @@ -13,6 +13,7 @@ permissions: jobs: update-flathub: + if: github.repository == 'mountain-loop/yaak' name: Update Flathub manifest runs-on: ubuntu-latest steps: diff --git a/.github/workflows/release-api-npm.yml b/.github/workflows/release-api-npm.yml index 0d243551..cc0f5c30 100644 --- a/.github/workflows/release-api-npm.yml +++ b/.github/workflows/release-api-npm.yml @@ -15,6 +15,7 @@ permissions: jobs: publish-npm: + if: github.repository == 'mountain-loop/yaak' name: Publish @yaakapp/api runs-on: ubuntu-latest permissions: diff --git a/.github/workflows/release-app.yml b/.github/workflows/release-app.yml index b19d94b8..f3ed4595 100644 --- a/.github/workflows/release-app.yml +++ b/.github/workflows/release-app.yml @@ -5,6 +5,7 @@ on: jobs: build-artifacts: + if: github.repository == 'mountain-loop/yaak' permissions: contents: write diff --git a/.github/workflows/release-cli-npm.yml b/.github/workflows/release-cli-npm.yml index ad7052de..8c457397 100644 --- a/.github/workflows/release-cli-npm.yml +++ b/.github/workflows/release-cli-npm.yml @@ -15,6 +15,7 @@ permissions: jobs: prepare-vendored-assets: + if: github.repository == 'mountain-loop/yaak' name: Prepare vendored plugin assets runs-on: ubuntu-latest diff --git a/.github/workflows/sponsors.yml b/.github/workflows/sponsors.yml index 008b34c8..a68a0054 100644 --- a/.github/workflows/sponsors.yml +++ b/.github/workflows/sponsors.yml @@ -7,6 +7,7 @@ permissions: contents: write jobs: deploy: + if: github.repository == 'mountain-loop/yaak' runs-on: ubuntu-latest steps: - name: Checkout 🛎️