From 5f76ed028a5dcadd6559e307760d38a87da59508 Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Tue, 28 Jul 2026 11:41:12 +0530 Subject: [PATCH] feat(playground): variable highlight + hover card with inline edit (BRU-4000) Bring the desktop variable experience to the playground request editors: - Green/red `{{var}}` highlighting against the active environment across the request body (Monaco), URL, and params/headers/form-body cells. - Hover card at desktop parity: name, scope badge, resolved value, copy, secret masking; reachable in the Monaco body via a content widget that reuses the shared VariableInfoCard. - Inline value editing (Enter save, Shift-Enter newline, blur save, Esc cancel) for environment/collection/folder/request scopes; read-only scopes (process.env, oauth2, $secrets, dynamic) and typed/object values stay read-only. Edits write to the in-memory playground store so the card, highlight, and request execution update together; reset reverts. Monaco body: new variableDecorations (highlight) and variableHoverWidget (content-widget hover intent). URL bar switched to HighlightedInput. Out of scope: WebSocket/gRPC/GraphQL requests are unsupported in the playground (rendered read-only via UnsupportedRequest), so they keep the docs-style read-only variable preview. Co-Authored-By: Claude Opus 4.8 --- .../HighlightedInput/HighlightedInput.tsx | 13 +- .../Content/Views/Common/BodyTab.tsx | 1 + .../PlaygroundView/QueryBar/QueryBar.tsx | 13 +- .../PlaygroundView/QueryBar/StyledWrapper.ts | 33 ++-- .../VariableInfoCard/StyledWrapper.ts | 36 +++- .../VariableInfoCard.spec.tsx | 44 +++++ .../VariableInfoCard/VariableInfoCard.tsx | 126 +++++++++++-- .../src/hooks/useVariableResolver.tsx | 58 +++++- .../src/store/slices/playground.spec.ts | 56 +++++- .../src/store/slices/playground.ts | 45 ++++- packages/bruno-api-docs/src/styles/index.css | 9 + .../src/ui/CodeEditor/CodeEditor.tsx | 51 ++++- .../src/ui/CodeEditor/StyledWrapper.ts | 12 ++ .../src/ui/CodeEditor/variableDecorations.ts | 58 ++++++ .../src/ui/CodeEditor/variableHoverWidget.ts | 177 ++++++++++++++++++ 15 files changed, 684 insertions(+), 48 deletions(-) create mode 100644 packages/bruno-api-docs/src/ui/CodeEditor/variableDecorations.ts create mode 100644 packages/bruno-api-docs/src/ui/CodeEditor/variableHoverWidget.ts diff --git a/packages/bruno-api-docs/src/components/HighlightedInput/HighlightedInput.tsx b/packages/bruno-api-docs/src/components/HighlightedInput/HighlightedInput.tsx index 08282f13..3be5ef54 100644 --- a/packages/bruno-api-docs/src/components/HighlightedInput/HighlightedInput.tsx +++ b/packages/bruno-api-docs/src/components/HighlightedInput/HighlightedInput.tsx @@ -25,6 +25,9 @@ interface HighlightedInputProps { title?: string; testId?: string; multiline?: boolean; + /** Forwarded key handler, invoked only when the autocomplete dropdown is not open (so it + * can drive e.g. Enter-to-send without stealing Enter from suggestion selection). */ + onKeyDown?: (event: React.KeyboardEvent) => void; } interface HoveredToken { @@ -75,7 +78,8 @@ export const HighlightedInput: React.FC = ({ variablesAutocomplete = true, title, testId, - multiline = false + multiline = false, + onKeyDown }) => { const inputRef = useRef(null); const mirrorRef = useRef(null); @@ -253,7 +257,10 @@ export const HighlightedInput: React.FC = ({ }; const handleKeyDown = (event: React.KeyboardEvent) => { - if (!autocomplete) return; + if (!autocomplete) { + onKeyDown?.(event); + return; + } const { items, active } = autocomplete; switch (event.key) { case 'ArrowDown': @@ -378,7 +385,7 @@ export const HighlightedInput: React.FC = ({ visibility: hoverPos ? 'visible' : 'hidden' }} > - + )} diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/BodyTab.tsx b/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/BodyTab.tsx index 89d11c2f..4d0e300b 100644 --- a/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/BodyTab.tsx +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/Common/BodyTab.tsx @@ -84,6 +84,7 @@ export const BodyTab: React.FC = ({ : 'text' } height={fillHeight ? '100%' : '300px'} + variableAware testId="body-editor" /> diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/QueryBar/QueryBar.tsx b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/QueryBar/QueryBar.tsx index 9182baf1..505f54c2 100644 --- a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/QueryBar/QueryBar.tsx +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/QueryBar/QueryBar.tsx @@ -2,6 +2,8 @@ import React, { useState, useEffect } from 'react'; import type { HttpRequest } from '@opencollection/types/requests/http'; import { StyledWrapper } from './StyledWrapper'; import MenuDropdown from '../../../../../../ui/MenuDropdown'; +import HighlightedInput from '../../../../../HighlightedInput/HighlightedInput'; +import { useResolvedVariables } from '../../../../../../hooks/useVariableResolver'; import { getHttpMethod, getRequestUrl, getHttpParams } from '../../../../../../utils/schemaHelpers'; import { syncPathParams, syncQueryParams } from '../../../../../../utils/pathParams'; import { availableMethods } from '../../../../../../theme/methodColors'; @@ -17,6 +19,7 @@ interface QueryBarProps { } const QueryBar: React.FC = ({ item, onSendRequest, isLoading, onItemChange }) => { + const { isFound, names } = useResolvedVariables(); const [url, setUrl] = useState(getRequestUrl(item)); const [method, setMethod] = useState(getHttpMethod(item)); @@ -74,12 +77,14 @@ const QueryBar: React.FC = ({ item, onSendRequest, isLoading, onI - handleUrlChange(e.target.value)} + onValueChange={handleUrlChange} + isFound={isFound} + names={names} placeholder="Enter request URL" - onKeyPress={(e) => { + testId="query-bar-url" + onKeyDown={(e) => { if (e.key === 'Enter' && url.trim() && !isLoading) { onSendRequest(); } diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/QueryBar/StyledWrapper.ts b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/QueryBar/StyledWrapper.ts index 4dac8584..366b5b11 100644 --- a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/QueryBar/StyledWrapper.ts +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/QueryBar/StyledWrapper.ts @@ -9,23 +9,28 @@ export const StyledWrapper = styled.div` border-radius: var(--oc-radius); background-color: var(--bg-primary); - input { - flex: 1; + /* The URL field is a HighlightedInput (wrapper div + painted mirror), not a bare input. + Stretch the wrapper across the row and give it the compact mono look of the query bar. + !important is required: HighlightedInput's own StyledWrapper is inserted after this one + (child renders after parent) and would otherwise win at equal specificity, leaving the + URL bar too tall with the cell font size. */ + .highlight-input { + flex: 1 !important; min-width: 0; - outline: none; - border: none; - border-radius: 0; - background-color: transparent; + padding: 0 !important; font-family: var(--font-mono); - font-weight: 400; - font-size: 0.75rem; - line-height: 1.125rem; - color: var(--text-primary); + } - &::placeholder { - color: var(--text-secondary); - opacity: 0.6; - } + .highlight-input .text-input, + .highlight-input .highlight-input-mirror { + padding: 0 !important; + font-size: 0.75rem !important; + line-height: 1.125rem !important; + } + + .highlight-input .highlight-input-mirror { + left: 0 !important; + right: 0 !important; } .method-select { diff --git a/packages/bruno-api-docs/src/components/VariableInfoCard/StyledWrapper.ts b/packages/bruno-api-docs/src/components/VariableInfoCard/StyledWrapper.ts index ab5a2de7..7e6fd5f8 100644 --- a/packages/bruno-api-docs/src/components/VariableInfoCard/StyledWrapper.ts +++ b/packages/bruno-api-docs/src/components/VariableInfoCard/StyledWrapper.ts @@ -55,7 +55,8 @@ export const StyledWrapper = styled.div` .var-value-container { position: relative; - height: 2.25rem; + min-height: 2.25rem; + max-height: 11.125rem; /* ~9 lines, matching the desktop app; scrolls past this */ padding: 0.5rem; overflow-y: auto; overflow-x: hidden; @@ -81,6 +82,39 @@ export const StyledWrapper = styled.div` color: var(--text-tertiary); } + .var-value-editable { + cursor: text; + } + + /* While editing, ring the value box in the brand colour (matching the desktop app). */ + .var-value-container:focus-within { + outline: 0.0625rem solid var(--oc-brand); + outline-offset: -0.0625rem; + } + + .var-value-edit { + display: block; + width: 100%; + margin: 0; + padding: 0; + font-family: var(--font-sans); + font-size: 0.8125rem; + font-weight: 400; + line-height: 1.25rem; + color: var(--text-primary); + background: transparent; + border: none; + resize: none; + white-space: pre-wrap; + word-break: break-all; + } + + /* The container shows the brand ring; keep the textarea itself ringless. */ + .var-value-edit:focus, + .var-value-edit:focus-visible { + outline: none; + } + .var-icons { position: absolute; top: 50%; diff --git a/packages/bruno-api-docs/src/components/VariableInfoCard/VariableInfoCard.spec.tsx b/packages/bruno-api-docs/src/components/VariableInfoCard/VariableInfoCard.spec.tsx index f2280a84..30665f33 100644 --- a/packages/bruno-api-docs/src/components/VariableInfoCard/VariableInfoCard.spec.tsx +++ b/packages/bruno-api-docs/src/components/VariableInfoCard/VariableInfoCard.spec.tsx @@ -121,3 +121,47 @@ describe('VariableInfoCard', () => { expect(part(root, 'note').text).toBe('Variable is not defined'); }); }); + +const editableCardTree = (name: string) => { + const store = createOpenCollectionStore(); + store.dispatch(setDocsCollection(collection)); + store.dispatch(setActiveEnv('Dev')); + return ( + + + + + + ); +}; + +describe('VariableInfoCard (editable)', () => { + it('renders an editable value for an environment variable', () => { + const value = part(useRenderToDom(editableCardTree('host')), 'value'); + expect(value.getAttribute('role')).toBe('button'); + expect(value.classList.contains('var-value-editable')).toBe(true); + }); + + it('renders an editable value for a collection variable', () => { + const value = part(useRenderToDom(editableCardTree('apiVersion')), 'value'); + expect(value.classList.contains('var-value-editable')).toBe(true); + }); + + it('stays read-only by default (docs surfaces do not pass editable)', () => { + const value = part(useRenderToDom(cardTree('host')), 'value'); + expect(value.classList.contains('var-value-editable')).toBe(false); + expect(value.getAttribute('role')).toBeFalsy(); + }); + + it('never makes a secret variable editable', () => { + const value = part(useRenderToDom(editableCardTree('bearer_token')), 'value'); + expect(value.text).toBe('(Secret)'); + expect(value.classList.contains('var-value-editable')).toBe(false); + }); + + it('never makes a read-only scope (process.env) editable', () => { + const root = useRenderToDom(editableCardTree('process.env.HOME')); + expect(part(root, 'note').text).toBe('read-only'); + expect(part(root, 'value').classList.contains('var-value-editable')).toBe(false); + }); +}); diff --git a/packages/bruno-api-docs/src/components/VariableInfoCard/VariableInfoCard.tsx b/packages/bruno-api-docs/src/components/VariableInfoCard/VariableInfoCard.tsx index 388b41fe..a36690bf 100644 --- a/packages/bruno-api-docs/src/components/VariableInfoCard/VariableInfoCard.tsx +++ b/packages/bruno-api-docs/src/components/VariableInfoCard/VariableInfoCard.tsx @@ -1,12 +1,18 @@ -import React from 'react'; +import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { useResolvedVariables } from '../../hooks'; import { CopyButton } from '../../ui/CopyButton/CopyButton'; import { SCOPE_LABELS, INVALID_NAME_WARNING } from '../../constants'; import type { VariableScope } from '../../utils/variableResolution'; import { StyledWrapper } from './StyledWrapper'; +// Scopes whose values can be inline-edited (mirrors desktop; process.env/runtime/dynamic/ +// oauth2/$secrets stay read-only). Global has no playground store, so it is read-only too. +const EDITABLE_SCOPES = new Set(['environment', 'collection', 'folder', 'request']); + interface VariableInfoCardProps { name: string; + /** Allow inline-editing the value (playground surfaces only; docs stay read-only). */ + editable?: boolean; testId?: string; } @@ -16,9 +22,62 @@ const getReadOnlyNote = (scope: VariableScope, activeEnvName: string | null): st return null; }; -export const VariableInfoCard: React.FC = ({ name, testId = 'variable-info-card' }) => { - const { lookup, activeEnvName } = useResolvedVariables(); +export const VariableInfoCard: React.FC = ({ + name, + editable = false, + testId = 'variable-info-card' +}) => { + const { lookup, activeEnvName, updateVariable } = useResolvedVariables(); const info = lookup(name); + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(''); + const editRef = useRef(null); + + // Grow the edit field with its content (the container caps it and scrolls past ~9 lines). + useLayoutEffect(() => { + const el = editRef.current; + if (!editing || !el) return; + el.style.height = 'auto'; + el.style.height = `${el.scrollHeight}px`; + }, [editing, draft]); + + // Editable for concrete scopes (env/collection/folder/request); read-only scopes and + // secrets stay read-only. Environment edits need an active environment to target. + const canEdit = + editable && + info.valid && + !info.secret && + EDITABLE_SCOPES.has(info.scope) && + // Only plain-string values are inline-editable; editing a typed value (object/number/etc) + // as text would drop its `{ type, data }` shape, so those stay read-only. + (info.dataType === undefined || info.dataType === 'string') && + (info.scope !== 'environment' || !!activeEnvName); + + // Leave edit mode when the hovered token changes (the card instance is reused across tokens). + useEffect(() => { + setEditing(false); + }, [name]); + + const startEditing = () => { + // Edit the raw stored value (which may contain `{{refs}}`), not the deep-resolved display value. + setDraft(info.rawValue); + setEditing(true); + }; + + const commit = () => { + setEditing(false); + if (draft !== info.rawValue) updateVariable(info.name, draft); + }; + + const handleEditKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + commit(); + } else if (event.key === 'Escape') { + event.preventDefault(); + setEditing(false); + } + }; const header = (
@@ -66,7 +125,19 @@ export const VariableInfoCard: React.FC = ({ name, testId } const readOnlyNote = getReadOnlyNote(info.scope, activeEnvName); - const placeholder = info.secret ? '(Secret)' : info.value === '' ? '(empty)' : null; + const placeholder = info.secret ? '(Secret)' : !canEdit && info.value === '' ? '(empty)' : null; + + const copyIcon = ( +
+ +
+ ); return ( @@ -76,20 +147,49 @@ export const VariableInfoCard: React.FC = ({ name, testId
{placeholder}
+ ) : canEdit ? ( + editing ? ( +