From 22c89d479ef108da8630d44ce0cb28beee324c18 Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Thu, 30 Jul 2026 22:40:14 +0530 Subject: [PATCH 01/25] fix(playground): place caret at end when editing a variable value --- .../tests/playground/playground-variables.spec.ts | 13 +++++++++++++ .../VariableInfoCard/VariableInfoCard.tsx | 7 +++++++ 2 files changed, 20 insertions(+) diff --git a/packages/bruno-api-docs/e2e/tests/playground/playground-variables.spec.ts b/packages/bruno-api-docs/e2e/tests/playground/playground-variables.spec.ts index f8cc71ed..14edebd9 100644 --- a/packages/bruno-api-docs/e2e/tests/playground/playground-variables.spec.ts +++ b/packages/bruno-api-docs/e2e/tests/playground/playground-variables.spec.ts @@ -84,4 +84,17 @@ test.describe('Playground variables: highlight, hover card and inline edit', () await playground.variable.value.click(); await expect(playground.variable.editField).toHaveCount(0); }); + + test('clicking the value puts the caret at the end, not the start', async ({ playground }) => { + await playground.variable.hoverInputToken('host'); + await playground.variable.value.click(); + + const caret = await playground.variable.editField.evaluate( + (el: HTMLTextAreaElement) => ({ start: el.selectionStart, end: el.selectionEnd, length: el.value.length }) + ); + + expect(caret.length).toBeGreaterThan(0); + expect(caret.start).toBe(caret.length); + expect(caret.end).toBe(caret.length); + }); }); diff --git a/packages/bruno-api-docs/src/components/VariableInfoCard/VariableInfoCard.tsx b/packages/bruno-api-docs/src/components/VariableInfoCard/VariableInfoCard.tsx index 2b9066a1..0aa45c79 100644 --- a/packages/bruno-api-docs/src/components/VariableInfoCard/VariableInfoCard.tsx +++ b/packages/bruno-api-docs/src/components/VariableInfoCard/VariableInfoCard.tsx @@ -37,6 +37,13 @@ export const VariableInfoCard: React.FC = ({ el.style.height = `${el.scrollHeight}px`; }, [editing, draft]); + useLayoutEffect(() => { + const el = editRef.current; + if (!editing || !el) return; + const end = el.value.length; + el.setSelectionRange(end, end); + }, [editing]); + const canEdit = editable && canWrite From e27164c886010fac62f244297d80b67a46fd8f44 Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Thu, 30 Jul 2026 23:00:22 +0530 Subject: [PATCH 02/25] fix(playground): show the copy control in every variable card state The copy button lived inside the editable and read-only display branches, so it vanished while the value was being edited and never rendered on the (Secret) or (empty) placeholders. Render it as a sibling of the value node instead, matching the desktop app where the icons row is built once. --- .../playground/playground-variables.spec.ts | 10 ++++ .../variableInfoPopup.spec.ts | 10 ++-- .../VariableInfoCard/StyledWrapper.ts | 2 +- .../VariableInfoCard.spec.tsx | 18 +++++-- .../VariableInfoCard/VariableInfoCard.tsx | 53 +++++++++---------- 5 files changed, 55 insertions(+), 38 deletions(-) diff --git a/packages/bruno-api-docs/e2e/tests/playground/playground-variables.spec.ts b/packages/bruno-api-docs/e2e/tests/playground/playground-variables.spec.ts index 14edebd9..89e38b9f 100644 --- a/packages/bruno-api-docs/e2e/tests/playground/playground-variables.spec.ts +++ b/packages/bruno-api-docs/e2e/tests/playground/playground-variables.spec.ts @@ -97,4 +97,14 @@ test.describe('Playground variables: highlight, hover card and inline edit', () expect(caret.start).toBe(caret.length); expect(caret.end).toBe(caret.length); }); + + test('the copy control stays available while the value is being edited', async ({ playground }) => { + await playground.variable.hoverInputToken('host'); + await expect(playground.variable.copyButton).toHaveCount(1); + + await playground.variable.startEditing('https://edited.example.com'); + + await expect(playground.variable.editField).toBeVisible(); + await expect(playground.variable.copyButton).toHaveCount(1); + }); }); diff --git a/packages/bruno-api-docs/e2e/tests/variableInfoPopup/variableInfoPopup.spec.ts b/packages/bruno-api-docs/e2e/tests/variableInfoPopup/variableInfoPopup.spec.ts index 6f660e5a..a552fa18 100644 --- a/packages/bruno-api-docs/e2e/tests/variableInfoPopup/variableInfoPopup.spec.ts +++ b/packages/bruno-api-docs/e2e/tests/variableInfoPopup/variableInfoPopup.spec.ts @@ -72,17 +72,17 @@ test.describe('Variable hover card', () => { await expect(variableCard.card).toBeVisible(); await expect(variableCard.scopeBadge).toHaveText('Environment'); await expect(variableCard.value).toHaveText('(Secret)'); - await expect(variableCard.copyButton).toHaveCount(0); + await expect(variableCard.copyButton).toHaveCount(1); }); - test('shows an (empty) placeholder with no copy for a defined variable that has no value', async ({ requestPage }) => { + test('shows an (empty) placeholder for a defined variable that has no value', async ({ requestPage }) => { const { variableCard } = requestPage; await variableCard.hoverToken('emptyValue'); await expect(variableCard.card).toBeVisible(); await expect(variableCard.scopeBadge).toHaveText('Environment'); await expect(variableCard.value).toHaveText('(empty)'); - await expect(variableCard.copyButton).toHaveCount(0); + await expect(variableCard.copyButton).toHaveCount(1); }); test('pretty-prints an object-typed value', async ({ requestPage }) => { @@ -110,7 +110,7 @@ test.describe('Variable hover card', () => { await expect(variableCard.note).toContainText('random value'); }); - test('shows a (Secret) placeholder with no reveal or copy, never exposing the value', async ({ requestPage }) => { + test('shows a (Secret) placeholder with no reveal, never exposing the value', async ({ requestPage }) => { const { variableCard } = requestPage; await variableCard.pinToken('bearer_token'); @@ -119,7 +119,7 @@ test.describe('Variable hover card', () => { await expect(variableCard.value).toHaveText('(Secret)'); await expect(variableCard.card).not.toContainText('super-secret-token'); await expect(variableCard.revealToggle).toHaveCount(0); - await expect(variableCard.copyButton).toHaveCount(0); + await expect(variableCard.copyButton).toHaveCount(1); }); test('copies the resolved value from the copy button', async ({ page, requestPage }) => { diff --git a/packages/bruno-api-docs/src/components/VariableInfoCard/StyledWrapper.ts b/packages/bruno-api-docs/src/components/VariableInfoCard/StyledWrapper.ts index dcdb111c..a0d38f71 100644 --- a/packages/bruno-api-docs/src/components/VariableInfoCard/StyledWrapper.ts +++ b/packages/bruno-api-docs/src/components/VariableInfoCard/StyledWrapper.ts @@ -95,7 +95,7 @@ export const StyledWrapper = styled.div` display: block; width: 100%; margin: 0; - padding: 0; + padding: 0 1.5rem 0 0; font-family: var(--font-sans); font-size: 0.8125rem; font-weight: 400; 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 d505603b..cae0ee06 100644 --- a/packages/bruno-api-docs/src/components/VariableInfoCard/VariableInfoCard.spec.tsx +++ b/packages/bruno-api-docs/src/components/VariableInfoCard/VariableInfoCard.spec.tsx @@ -69,19 +69,29 @@ describe('VariableInfoCard', () => { expect(part(useRenderToDom(cardTree('endpoint')), 'value').text).toBe('https://dev.test/v1'); }); - it('shows a (Secret) placeholder with no reveal/copy and never prints the plaintext', () => { + it('shows a (Secret) placeholder with no reveal and never prints the plaintext', () => { const root = useRenderToDom(cardTree('bearer_token')); expect(part(root, 'value').text).toBe('(Secret)'); expect(root.toString()).not.toContain('super-secret'); expect(root.querySelector(selector('reveal'))).toBeNull(); - expect(root.querySelector(selector('copy'))).toBeNull(); }); - it('shows an (empty) placeholder with no copy control when the value is blank', () => { + it('shows an (empty) placeholder when the value is blank', () => { const root = useRenderToDom(cardTree('emptyValue')); expect(part(root, 'scope').text).toBe('Environment'); expect(part(root, 'value').text).toBe('(empty)'); - expect(root.querySelector(selector('copy'))).toBeNull(); + }); + + it('offers the copy control alongside a resolved value', () => { + expect(useRenderToDom(cardTree('host')).querySelector(selector('copy'))).not.toBeNull(); + }); + + it('offers the copy control on the secret placeholder', () => { + expect(useRenderToDom(cardTree('bearer_token')).querySelector(selector('copy'))).not.toBeNull(); + }); + + it('offers the copy control on the empty placeholder', () => { + expect(useRenderToDom(cardTree('emptyValue')).querySelector(selector('copy'))).not.toBeNull(); }); it('pretty-prints an object-typed value', () => { diff --git a/packages/bruno-api-docs/src/components/VariableInfoCard/VariableInfoCard.tsx b/packages/bruno-api-docs/src/components/VariableInfoCard/VariableInfoCard.tsx index 0aa45c79..d76d2989 100644 --- a/packages/bruno-api-docs/src/components/VariableInfoCard/VariableInfoCard.tsx +++ b/packages/bruno-api-docs/src/components/VariableInfoCard/VariableInfoCard.tsx @@ -157,36 +157,30 @@ export const VariableInfoCard: React.FC = ({ ); const editableDisplayNode = ( - <> -
{ - event.preventDefault(); - startEditing(); - }} - onKeyDown={(event) => { - if (event.key !== 'Enter' && event.key !== ' ') return; - event.preventDefault(); - startEditing(); - }} - > - {emptyLabel ?? info.value} -
- {copyButton} - +
{ + event.preventDefault(); + startEditing(); + }} + onKeyDown={(event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + startEditing(); + }} + > + {emptyLabel ?? info.value} +
); const readOnlyDisplayNode = ( - <> -
- {info.value} -
- {copyButton} - +
+ {info.value} +
); const editableNode = editing ? editFieldNode : editableDisplayNode; @@ -195,7 +189,10 @@ export const VariableInfoCard: React.FC = ({ return ( {header} -
{valueNode}
+
+ {valueNode} + {copyButton} +
{readOnlyNote && (
{readOnlyNote} From cdba47f3959b29e28370adbff5542b62a7d347aa Mon Sep 17 00:00:00 2001 From: Sundram Gupta Date: Thu, 30 Jul 2026 23:20:13 +0530 Subject: [PATCH 03/25] fix(playground): drop the copy control when there is no value to copy CopyButton no-ops on an empty string, so rendering it for process.env, undefined variables and blank values left a visible control that does nothing. --- .../variableInfoPopup/variableInfoPopup.spec.ts | 4 ++-- .../VariableInfoCard/VariableInfoCard.spec.tsx | 13 +++---------- .../VariableInfoCard/VariableInfoCard.tsx | 2 +- 3 files changed, 6 insertions(+), 13 deletions(-) diff --git a/packages/bruno-api-docs/e2e/tests/variableInfoPopup/variableInfoPopup.spec.ts b/packages/bruno-api-docs/e2e/tests/variableInfoPopup/variableInfoPopup.spec.ts index a552fa18..00f9ca9c 100644 --- a/packages/bruno-api-docs/e2e/tests/variableInfoPopup/variableInfoPopup.spec.ts +++ b/packages/bruno-api-docs/e2e/tests/variableInfoPopup/variableInfoPopup.spec.ts @@ -75,14 +75,14 @@ test.describe('Variable hover card', () => { await expect(variableCard.copyButton).toHaveCount(1); }); - test('shows an (empty) placeholder for a defined variable that has no value', async ({ requestPage }) => { + test('shows an (empty) placeholder with no copy for a defined variable that has no value', async ({ requestPage }) => { const { variableCard } = requestPage; await variableCard.hoverToken('emptyValue'); await expect(variableCard.card).toBeVisible(); await expect(variableCard.scopeBadge).toHaveText('Environment'); await expect(variableCard.value).toHaveText('(empty)'); - await expect(variableCard.copyButton).toHaveCount(1); + await expect(variableCard.copyButton).toHaveCount(0); }); test('pretty-prints an object-typed value', async ({ requestPage }) => { 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 cae0ee06..8c642ad1 100644 --- a/packages/bruno-api-docs/src/components/VariableInfoCard/VariableInfoCard.spec.tsx +++ b/packages/bruno-api-docs/src/components/VariableInfoCard/VariableInfoCard.spec.tsx @@ -76,24 +76,17 @@ describe('VariableInfoCard', () => { expect(root.querySelector(selector('reveal'))).toBeNull(); }); - it('shows an (empty) placeholder when the value is blank', () => { + it('shows an (empty) placeholder with no copy control when the value is blank', () => { const root = useRenderToDom(cardTree('emptyValue')); expect(part(root, 'scope').text).toBe('Environment'); expect(part(root, 'value').text).toBe('(empty)'); + expect(root.querySelector(selector('copy'))).toBeNull(); }); - it('offers the copy control alongside a resolved value', () => { - expect(useRenderToDom(cardTree('host')).querySelector(selector('copy'))).not.toBeNull(); - }); - - it('offers the copy control on the secret placeholder', () => { + it('offers the copy control on the secret placeholder, which has a value to copy', () => { expect(useRenderToDom(cardTree('bearer_token')).querySelector(selector('copy'))).not.toBeNull(); }); - it('offers the copy control on the empty placeholder', () => { - expect(useRenderToDom(cardTree('emptyValue')).querySelector(selector('copy'))).not.toBeNull(); - }); - it('pretty-prints an object-typed value', () => { const value = part(useRenderToDom(cardTree('profile')), 'value').text; expect(JSON.parse(value)).toEqual({ city: 'NYC', zip: 10001 }); diff --git a/packages/bruno-api-docs/src/components/VariableInfoCard/VariableInfoCard.tsx b/packages/bruno-api-docs/src/components/VariableInfoCard/VariableInfoCard.tsx index d76d2989..9586f726 100644 --- a/packages/bruno-api-docs/src/components/VariableInfoCard/VariableInfoCard.tsx +++ b/packages/bruno-api-docs/src/components/VariableInfoCard/VariableInfoCard.tsx @@ -122,7 +122,7 @@ export const VariableInfoCard: React.FC = ({ const emptyLabel = info.value === '' ? '(empty)' : null; const placeholder = info.secret ? '(Secret)' : canEdit ? null : emptyLabel; - const copyButton = ( + const copyButton = info.value !== '' && (
Date: Thu, 30 Jul 2026 23:35:38 +0530 Subject: [PATCH 04/25] feat(ui): show a green tick on copy and block repeat clicks Matches the desktop copy button: the glyph swaps to a checkmark tinted with the success token, the button is inert for the second the tick is shown, and both glyphs use rounded stroke ends. The tick is stroke="currentColor", so the tint is applied as the button's colour, the same way brunoVarInfo.js does it. --- .../variableInfoPopup.spec.ts | 21 +++++++++++++ .../VariableInfoCard/StyledWrapper.ts | 6 ++++ .../src/ui/CopyButton/CopyButton.spec.tsx | 27 ++++++++++++----- .../src/ui/CopyButton/CopyButton.tsx | 30 ++++++++++++++++--- .../src/ui/CopyButton/StyledWrapper.ts | 4 +++ 5 files changed, 77 insertions(+), 11 deletions(-) diff --git a/packages/bruno-api-docs/e2e/tests/variableInfoPopup/variableInfoPopup.spec.ts b/packages/bruno-api-docs/e2e/tests/variableInfoPopup/variableInfoPopup.spec.ts index 00f9ca9c..585615f5 100644 --- a/packages/bruno-api-docs/e2e/tests/variableInfoPopup/variableInfoPopup.spec.ts +++ b/packages/bruno-api-docs/e2e/tests/variableInfoPopup/variableInfoPopup.spec.ts @@ -133,6 +133,27 @@ test.describe('Variable hover card', () => { await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('2024-01'); }); + test('turns the copy glyph into a green tick while copied, then reverts', async ({ page, requestPage }) => { + const { variableCard } = requestPage; + await page.context().grantPermissions(['clipboard-read', 'clipboard-write']); + + await variableCard.pinToken('apiVersion'); + await expect(variableCard.card).toBeVisible(); + + const tick = variableCard.copyButton.locator('polyline'); + await expect(tick).toHaveCount(0); + + await variableCard.copyButton.click(); + + await expect(variableCard.copyButton).toHaveAttribute('data-copied', 'true'); + await expect(tick).toHaveCount(1); + // The tick is stroke="currentColor", so the button's colour is the tick's colour. + await expect(variableCard.copyButton).toHaveAttribute('style', /--oc-status-success-text/); + + await expect(variableCard.copyButton).not.toHaveAttribute('data-copied', 'true', { timeout: 3000 }); + await expect(tick).toHaveCount(0); + }); + test('is read-only — no editor inside the card', async ({ requestPage }) => { const { variableCard } = requestPage; await variableCard.pinToken('host'); diff --git a/packages/bruno-api-docs/src/components/VariableInfoCard/StyledWrapper.ts b/packages/bruno-api-docs/src/components/VariableInfoCard/StyledWrapper.ts index a0d38f71..9b16587c 100644 --- a/packages/bruno-api-docs/src/components/VariableInfoCard/StyledWrapper.ts +++ b/packages/bruno-api-docs/src/components/VariableInfoCard/StyledWrapper.ts @@ -149,6 +149,12 @@ export const StyledWrapper = styled.div` background: transparent; } + .var-icons .copy-button[data-copied], + .var-icons .copy-button[data-copied]:hover { + opacity: 1; + cursor: default; + } + .var-icons .copy-button:focus-visible { outline: 0.125rem solid var(--primary-color); outline-offset: 0.0625rem; diff --git a/packages/bruno-api-docs/src/ui/CopyButton/CopyButton.spec.tsx b/packages/bruno-api-docs/src/ui/CopyButton/CopyButton.spec.tsx index 2151fd71..406e43fa 100644 --- a/packages/bruno-api-docs/src/ui/CopyButton/CopyButton.spec.tsx +++ b/packages/bruno-api-docs/src/ui/CopyButton/CopyButton.spec.tsx @@ -1,18 +1,31 @@ import React from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; import { describe, it, expect } from 'vitest'; +import { useRenderToDom } from '../../hooks/useRenderToDom'; +import { getByTestId } from '../../test-utils/dom'; import { CopyButton } from './CopyButton'; describe('CopyButton', () => { it('renders an accessible button with the default copy label', () => { - const html = renderToStaticMarkup(); - expect(html).toContain('), 'copy-button'); + expect(button.tagName.toLowerCase()).toBe('button'); + expect(button.getAttribute('type')).toBe('button'); + expect(button.getAttribute('aria-label')).toBe('Copy'); }); it('uses the provided label', () => { - const html = renderToStaticMarkup(); - expect(html).toContain('aria-label="Copy code"'); + const button = getByTestId(useRenderToDom(), 'copy-button'); + expect(button.getAttribute('aria-label')).toBe('Copy code'); + }); + + it('is not marked copied on first render', () => { + const root = useRenderToDom(); + expect(getByTestId(root, 'copy-button').getAttribute('data-copied')).toBeFalsy(); + }); + + it('renders rounded stroke ends so the glyph matches the desktop app', () => { + const root = useRenderToDom(); + const svg = getByTestId(root, 'copy-button').querySelector('svg'); + expect(svg?.getAttribute('stroke-linecap')).toBe('round'); + expect(svg?.getAttribute('stroke-linejoin')).toBe('round'); }); }); diff --git a/packages/bruno-api-docs/src/ui/CopyButton/CopyButton.tsx b/packages/bruno-api-docs/src/ui/CopyButton/CopyButton.tsx index a15b4e63..d6979177 100644 --- a/packages/bruno-api-docs/src/ui/CopyButton/CopyButton.tsx +++ b/packages/bruno-api-docs/src/ui/CopyButton/CopyButton.tsx @@ -13,14 +13,34 @@ interface CopyButtonProps { } const CopyGlyph: React.FC = () => ( -
= ({ {header}
{valueNode} - {copyButton} + {icons}
{readOnlyNote && (
diff --git a/packages/bruno-api-docs/src/ui/CopyButton/CopyButton.tsx b/packages/bruno-api-docs/src/ui/CopyButton/CopyButton.tsx index d6979177..ef7b9688 100644 --- a/packages/bruno-api-docs/src/ui/CopyButton/CopyButton.tsx +++ b/packages/bruno-api-docs/src/ui/CopyButton/CopyButton.tsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { baseIconProps } from '../../assets/icons/baseIconProps'; import { StyledWrapper } from './StyledWrapper'; interface CopyButtonProps { @@ -12,35 +13,17 @@ interface CopyButtonProps { className?: string; } +const glyphProps = { ...baseIconProps, width: '1em', height: '1em' }; + const CopyGlyph: React.FC = () => ( -
- + {info.secret && ( + + )} + {info.value !== '' && ( + + )}
); @@ -141,19 +166,24 @@ export const VariableInfoCard: React.FC = ({ ); const editFieldNode = ( -