Merge branch 'main' into wip/yaak-proxy-foundation

# Conflicts:
#	apps/yaak-client/components/JsonBodyEditor.tsx
#	apps/yaak-client/lib/jsonComments.ts
#	package-lock.json
#	packages/theme/src/window.ts
#	packages/ui/src/components/HeaderSize.tsx
This commit is contained in:
Gregory Schier
2026-03-11 15:36:57 -07:00
40 changed files with 1187 additions and 518 deletions

View File

@@ -1,4 +1,4 @@
import { jsonLanguage } from '@codemirror/lang-json';
import { jsoncLanguage } from '@shopify/lang-jsonc';
import { linter } from '@codemirror/lint';
import type { EditorView } from '@codemirror/view';
import type { GrpcRequest } from '@yaakapp-internal/models';
@@ -115,7 +115,7 @@ export function GrpcEditor({
delay: 200,
needsRefresh: handleRefresh,
}),
jsonLanguage.data.of({
jsoncLanguage.data.of({
autocomplete: jsonCompletion(),
}),
stateExtensions({}),

View File

@@ -48,6 +48,7 @@ import { FormMultipartEditor } from './FormMultipartEditor';
import { FormUrlencodedEditor } from './FormUrlencodedEditor';
import { HeadersEditor } from './HeadersEditor';
import { HttpAuthenticationEditor } from './HttpAuthenticationEditor';
import { JsonBodyEditor } from './JsonBodyEditor';
import { MarkdownEditor } from './MarkdownEditor';
import { RequestMethodDropdown } from './RequestMethodDropdown';
import { UrlBar } from './UrlBar';
@@ -257,7 +258,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
);
const handleBodyTextChange = useCallback(
(text: string) => patchModel(activeRequest, { body: { text } }),
(text: string) => patchModel(activeRequest, { body: { ...activeRequest.body, text } }),
[activeRequest],
);
@@ -370,16 +371,10 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
<TabContent value={TAB_BODY}>
<ConfirmLargeRequestBody request={activeRequest}>
{activeRequest.bodyType === BODY_TYPE_JSON ? (
<Editor
<JsonBodyEditor
forceUpdateKey={forceUpdateKey}
autocompleteFunctions
autocompleteVariables
placeholder="..."
heightMode={fullHeight ? 'full' : 'auto'}
defaultValue={`${activeRequest.body?.text ?? ''}`}
language="json"
onChange={handleBodyTextChange}
stateKey={`json.${activeRequest.id}`}
request={activeRequest}
/>
) : activeRequest.bodyType === BODY_TYPE_XML ? (
<Editor

View File

@@ -0,0 +1,122 @@
import { linter } from '@codemirror/lint';
import type { HttpRequest } from '@yaakapp-internal/models';
import { patchModel } from '@yaakapp-internal/models';
import { useCallback, useMemo } from 'react';
import { useKeyValue } from '../hooks/useKeyValue';
import { textLikelyContainsJsonComments } from '../lib/jsonComments';
import { Banner } from './core/Banner';
import type { DropdownItem } from './core/Dropdown';
import { Dropdown } from './core/Dropdown';
import type { EditorProps } from './core/Editor/Editor';
import { jsonParseLinter } from './core/Editor/json-lint';
import { Editor } from './core/Editor/LazyEditor';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { IconTooltip } from './core/IconTooltip';
interface Props {
forceUpdateKey: string;
heightMode: EditorProps['heightMode'];
request: HttpRequest;
}
export function JsonBodyEditor({ forceUpdateKey, heightMode, request }: Props) {
const handleChange = useCallback(
(text: string) => patchModel(request, { body: { ...request.body, text } }),
[request],
);
const autoFix = request.body?.sendJsonComments !== true;
const lintExtension = useMemo(
() =>
linter(
jsonParseLinter(
autoFix
? { allowComments: true, allowTrailingCommas: true }
: { allowComments: false, allowTrailingCommas: false },
),
),
[autoFix],
);
const hasComments = useMemo(
() => textLikelyContainsJsonComments(request.body?.text ?? ''),
[request.body?.text],
);
const { value: bannerDismissed, set: setBannerDismissed } = useKeyValue<boolean>({
namespace: 'no_sync',
key: ['json-fix-3', request.workspaceId],
fallback: false,
});
const handleToggleAutoFix = useCallback(() => {
const newBody = { ...request.body };
if (autoFix) {
newBody.sendJsonComments = true;
} else {
delete newBody.sendJsonComments;
}
patchModel(request, { body: newBody });
}, [request, autoFix]);
const handleDropdownOpen = useCallback(() => {
if (!bannerDismissed) {
setBannerDismissed(true);
}
}, [bannerDismissed, setBannerDismissed]);
const showBanner = hasComments && autoFix && !bannerDismissed;
const stripMessage = 'Automatically strip comments and trailing commas before sending';
const actions = useMemo<EditorProps['actions']>(
() => [
showBanner && (
<Banner color="notice" className="!opacity-100 h-sm !py-0 !px-2 flex items-center text-xs">
<p className="inline-flex items-center gap-1 min-w-0">
<span className="truncate">Auto-fix enabled</span>
<Icon icon="arrow_right" size="sm" className="opacity-disabled" />
</p>
</Banner>
),
<div key="settings" className="!opacity-100 !shadow">
<Dropdown
onOpen={handleDropdownOpen}
items={
[
{
label: 'Automatically Fix JSON',
keepOpenOnSelect: true,
onSelect: handleToggleAutoFix,
rightSlot: <IconTooltip content={stripMessage} />,
leftSlot: (
<Icon icon={autoFix ? 'check_square_checked' : 'check_square_unchecked'} />
),
},
] satisfies DropdownItem[]
}
>
<IconButton size="sm" variant="border" icon="settings" title="JSON Settings" />
</Dropdown>
</div>,
],
[handleDropdownOpen, handleToggleAutoFix, autoFix, showBanner],
);
return (
<Editor
forceUpdateKey={forceUpdateKey}
autocompleteFunctions
autocompleteVariables
placeholder="..."
heightMode={heightMode}
defaultValue={`${request.body?.text ?? ''}`}
language="json"
onChange={handleChange}
stateKey={`json.${request.id}`}
actions={actions}
lintExtension={lintExtension}
/>
);
}

View File

@@ -13,7 +13,6 @@ import { Link } from '../core/Link';
import { PlainInput } from '../core/PlainInput';
import { Separator } from '../core/Separator';
import { HStack, VStack } from '../core/Stacks';
import { LocalImage } from '../LocalImage';
export function SettingsLicense() {
return (

View File

@@ -5,7 +5,7 @@ import { useMemo } from 'react';
import { Overlay } from '../Overlay';
import { Heading } from './Heading';
import { IconButton } from './IconButton';
import { DialogSize } from '@yaakapp-internal/plugins';
import type { DialogSize } from '@yaakapp-internal/plugins';
export interface DialogProps {
children: ReactNode;

View File

@@ -78,6 +78,7 @@ export interface EditorProps {
hideGutter?: boolean;
id?: string;
language?: EditorLanguage | 'pairs' | 'url' | 'timeline' | null;
lintExtension?: Extension;
graphQLSchema?: GraphQLSchema | null;
onBlur?: () => void;
onChange?: (value: string) => void;
@@ -124,6 +125,7 @@ function EditorInner({
hideGutter,
graphQLSchema,
language,
lintExtension,
onBlur,
onChange,
onFocus,
@@ -325,13 +327,14 @@ function EditorInner({
);
// Update the language extension when the language changes
// biome-ignore lint/correctness/useExhaustiveDependencies: none
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally limited deps
useEffect(() => {
if (cm.current === null) return;
const { view, languageCompartment } = cm.current;
const ext = getLanguageExtension({
useTemplating,
language,
lintExtension,
hideGutter,
environmentVariables,
autocomplete,
@@ -344,6 +347,7 @@ function EditorInner({
view.dispatch({ effects: languageCompartment.reconfigure(ext) });
}, [
language,
lintExtension,
autocomplete,
environmentVariables,
onClickFunction,
@@ -357,7 +361,7 @@ function EditorInner({
]);
// Initialize the editor when ref mounts
// biome-ignore lint/correctness/useExhaustiveDependencies: Only reinitialize when necessary
// biome-ignore lint/correctness/useExhaustiveDependencies: only reinitialize when necessary
const initEditorRef = useCallback(
function initEditorRef(container: HTMLDivElement | null) {
if (container === null) {
@@ -371,6 +375,7 @@ function EditorInner({
const langExt = getLanguageExtension({
useTemplating,
language,
lintExtension,
completionOptions,
autocomplete,
environmentVariables,

View File

@@ -8,7 +8,6 @@ import { history, historyKeymap } from '@codemirror/commands';
import { go } from '@codemirror/lang-go';
import { java } from '@codemirror/lang-java';
import { javascript } from '@codemirror/lang-javascript';
import { json } from '@codemirror/lang-json';
import { markdown } from '@codemirror/lang-markdown';
import { php } from '@codemirror/lang-php';
import { python } from '@codemirror/lang-python';
@@ -34,7 +33,6 @@ import { ruby } from '@codemirror/legacy-modes/mode/ruby';
import { shell } from '@codemirror/legacy-modes/mode/shell';
import { swift } from '@codemirror/legacy-modes/mode/swift';
import { linter, lintGutter, lintKeymap } from '@codemirror/lint';
import { search, searchKeymap } from '@codemirror/search';
import type { Extension } from '@codemirror/state';
import { EditorState } from '@codemirror/state';
@@ -50,6 +48,7 @@ import {
rectangularSelection,
} from '@codemirror/view';
import { tags as t } from '@lezer/highlight';
import { jsonc, jsoncLanguage } from '@shopify/lang-jsonc';
import { graphql } from 'cm6-graphql';
import type { GraphQLSchema } from 'graphql';
import { activeRequestIdAtom } from '../../../hooks/useActiveRequestId';
@@ -61,13 +60,13 @@ import { showGraphQLDocExplorerAtom } from '../../graphql/graphqlAtoms';
import type { EditorProps } from './Editor';
import { jsonParseLinter } from './json-lint';
import { pairs } from './pairs/extension';
import { searchMatchCount } from './searchMatchCount';
import { text } from './text/extension';
import { timeline } from './timeline/extension';
import type { TwigCompletionOption } from './twig/completion';
import { twig } from './twig/extension';
import { pathParametersPlugin } from './twig/pathParameters';
import { url } from './url/extension';
import { searchMatchCount } from './searchMatchCount';
export const syntaxHighlightStyle = HighlightStyle.define([
{
@@ -107,7 +106,7 @@ const syntaxExtensions: Record<
null | (() => LanguageSupport)
> = {
graphql: null,
json: json,
json: jsonc,
javascript: javascript,
// HTML as XML because HTML is oddly slow
html: xml,
@@ -140,6 +139,7 @@ const closeBracketsFor: (keyof typeof syntaxExtensions)[] = ['json', 'javascript
export function getLanguageExtension({
useTemplating,
language = 'text',
lintExtension,
environmentVariables,
autocomplete,
hideGutter,
@@ -156,7 +156,7 @@ export function getLanguageExtension({
onClickPathParameter: (name: string) => void;
completionOptions: TwigCompletionOption[];
graphQLSchema: GraphQLSchema | null;
} & Pick<EditorProps, 'language' | 'autocomplete' | 'hideGutter'>) {
} & Pick<EditorProps, 'language' | 'autocomplete' | 'hideGutter' | 'lintExtension'>) {
const extraExtensions: Extension[] = [];
if (language === 'url') {
@@ -193,7 +193,12 @@ export function getLanguageExtension({
}
if (language === 'json') {
extraExtensions.push(linter(jsonParseLinter()));
extraExtensions.push(lintExtension ?? linter(jsonParseLinter()));
extraExtensions.push(
jsoncLanguage.data.of({
commentTokens: { line: '//', block: { open: '/*', close: '*/' } },
}),
);
if (!hideGutter) {
extraExtensions.push(lintGutter());
}

View File

@@ -4,14 +4,22 @@ import { parse as jsonLintParse } from '@prantlf/jsonlint';
const TEMPLATE_SYNTAX_REGEX = /\$\{\[[\s\S]*?]}/g;
export function jsonParseLinter() {
interface JsonLintOptions {
allowComments?: boolean;
allowTrailingCommas?: boolean;
}
export function jsonParseLinter(options?: JsonLintOptions) {
return (view: EditorView): Diagnostic[] => {
try {
const doc = view.state.doc.toString();
// We need lint to not break on stuff like {"foo:" ${[ ... ]}} so we'll replace all template
// syntax with repeating `1` characters, so it's valid JSON and the position is still correct.
const escapedDoc = doc.replace(TEMPLATE_SYNTAX_REGEX, (m) => '1'.repeat(m.length));
jsonLintParse(escapedDoc);
jsonLintParse(escapedDoc, {
mode: (options?.allowComments ?? true) ? 'cjson' : 'json',
ignoreTrailingCommas: options?.allowTrailingCommas ?? false,
});
// biome-ignore lint/suspicious/noExplicitAny: none
} catch (err: any) {
if (!('location' in err)) {

View File

@@ -1,4 +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 {
@@ -12,7 +12,6 @@ import type {
import classNames from 'classnames';
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';

View File

@@ -75,7 +75,7 @@ function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) {
const currentBranch = status.data.headRefShorthand;
const hasChanges = status.data.entries.some((e) => e.status !== 'current');
const hasRemotes = (status.data.origins ?? []).length > 0;
const _hasRemotes = (status.data.origins ?? []).length > 0;
const { ahead, behind } = status.data;
const tryCheckout = (branch: string, force: boolean) => {

View File

@@ -1,6 +1,5 @@
import type { HttpRequest } from '@yaakapp-internal/models';
import { formatSdl } from 'format-graphql';
import { useAtom } from 'jotai';
import { useCallback, useMemo } from 'react';
import { useLocalStorage } from 'react-use';
@@ -16,6 +15,7 @@ import { Editor } from '../core/Editor/LazyEditor';
import { FormattedError } from '../core/FormattedError';
import { Icon } from '@yaakapp-internal/ui';
import { Separator } from '../core/Separator';
import { tryFormatGraphql } from '../../lib/formatters';
import { showGraphQLDocExplorerAtom } from './graphqlAtoms';
type Props = Pick<EditorProps, 'heightMode' | 'className' | 'forceUpdateKey'> & {
@@ -156,6 +156,7 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
{ type: 'separator', label: 'Setting' },
{
label: 'Automatic Introspection',
keepOpenOnSelect: true,
onSelect: () => {
setAutoIntrospectDisabled({
...autoIntrospectDisabled,
@@ -210,7 +211,7 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
language="graphql"
heightMode="auto"
graphQLSchema={schema}
format={formatSdl}
format={tryFormatGraphql}
defaultValue={currentBody.query}
onChange={handleChangeQuery}
placeholder="..."

View File

@@ -20,6 +20,18 @@ export async function tryFormatJson(text: string): Promise<string> {
return text;
}
export async function tryFormatGraphql(text: string): Promise<string> {
if (text === '') return text;
try {
return await invokeCmd<string>('cmd_format_graphql', { text });
} catch (err) {
console.warn('Failed to format GraphQL', err);
}
return text;
}
export async function tryFormatXml(text: string): Promise<string> {
if (text === '') return text;

View File

@@ -0,0 +1,30 @@
/**
* Simple heuristic to detect if a string likely contains JSON/JSONC comments.
* Checks for // and /* patterns that are NOT inside double-quoted strings.
* Used for UI hints only — doesn't need to be perfect.
*/
export function textLikelyContainsJsonComments(text: string): boolean {
let inString = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (inString) {
if (ch === '"') {
inString = false;
} else if (ch === '\\') {
i++; // skip escaped char
}
continue;
}
if (ch === '"') {
inString = true;
continue;
}
if (ch === '/' && i + 1 < text.length) {
const next = text[i + 1];
if (next === '/' || next === '*') {
return true;
}
}
}
return false;
}

View File

@@ -17,6 +17,7 @@ type TauriCmd =
| 'cmd_delete_send_history'
| 'cmd_dismiss_notification'
| 'cmd_export_data'
| 'cmd_format_graphql'
| 'cmd_format_json'
| 'cmd_get_http_authentication_config'
| 'cmd_get_http_authentication_summaries'

View File

@@ -1,2 +1 @@
declare module 'format-graphql';
declare module 'vkbeautify';

View File

@@ -27,6 +27,7 @@
"@replit/codemirror-emacs": "^6.1.0",
"@replit/codemirror-vim": "^6.3.0",
"@replit/codemirror-vscode-keymap": "^6.0.2",
"@shopify/lang-jsonc": "^1.0.1",
"@tanstack/react-query": "^5.90.5",
"@tanstack/react-router": "^1.133.13",
"@tanstack/react-virtual": "^3.13.12",
@@ -46,7 +47,6 @@
"deep-equal": "^2.2.3",
"eventemitter3": "^5.0.1",
"focus-trap-react": "^11.0.4",
"format-graphql": "^1.5.0",
"fuzzbunny": "^1.0.1",
"hexy": "^0.3.5",
"history": "^5.3.0",