Compare commits

..

1 Commits

Author SHA1 Message Date
Bijin A B
aeb6e87860 test: fix test and update default retry in local to 0 2026-06-20 01:19:35 +05:30
503 changed files with 4414 additions and 32406 deletions

2
.github/CODEOWNERS vendored
View File

@@ -1 +1 @@
* @helloanoop @bijin-bruno @lohit-bruno @naman-bruno @sid-bruno @vijayh-bruno @utkarsh-bruno @sanish-bruno
* @helloanoop @maintainer-bruno @bijin-bruno @lohit-bruno @naman-bruno @sid-bruno @vijayh-bruno

View File

@@ -49,21 +49,14 @@ body:
- type: textarea
attributes:
label: Describe the bug
description: A clear and concise description of the bug and how it's affecting your work
description: A clear and concise description of the bug and how it's effecting your work along with steps to reproduce.
validations:
required: true
- type: textarea
attributes:
label: Steps to reproduce
description: The exact steps that can be performed to reproduce the issue
validations:
required: true
- type: textarea
attributes:
label: Collection to reproduce
description: If possible, please attach the collection where the bug is present
label: .bru file to reproduce the bug
description: Attach your .bru file here that can reproduce the problem.
validations:
required: false

View File

@@ -1,4 +1,4 @@
blank_issues_enabled: false
blank_issues_enabled: true
contact_links:
- name: Discussions
url: https://github.com/usebruno/bruno/discussions

View File

@@ -11,8 +11,5 @@ runs:
libglib2.0-0 libnss3 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libgtk-3-0 libasound2t64 \
xvfb
CHROME_SANDBOX="${GITHUB_WORKSPACE}/node_modules/electron/dist/chrome-sandbox"
if [[ -f "$CHROME_SANDBOX" ]]; then
sudo chown root "$CHROME_SANDBOX"
sudo chmod 4755 "$CHROME_SANDBOX"
fi
sudo chown root /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
sudo chmod 4755 /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox

View File

@@ -11,8 +11,5 @@ runs:
libglib2.0-0 libnss3 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libgtk-3-0 libasound2t64 \
xvfb libxml2-utils
CHROME_SANDBOX="${GITHUB_WORKSPACE}/node_modules/electron/dist/chrome-sandbox"
if [[ -f "$CHROME_SANDBOX" ]]; then
sudo chown root "$CHROME_SANDBOX"
sudo chmod 4755 "$CHROME_SANDBOX"
fi
sudo chown root /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
sudo chmod 4755 /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox

View File

@@ -45,11 +45,8 @@ jobs:
- name: Configure Chrome Sandbox
if: matrix.os-name == 'ubuntu'
run: |
CHROME_SANDBOX="${GITHUB_WORKSPACE}/node_modules/electron/dist/chrome-sandbox"
if [[ -f "$CHROME_SANDBOX" ]]; then
sudo chown root "$CHROME_SANDBOX"
sudo chmod 4755 "$CHROME_SANDBOX"
fi
sudo chown root node_modules/electron/dist/chrome-sandbox
sudo chmod 4755 node_modules/electron/dist/chrome-sandbox
- name: Run Benchmark Tests
uses: ./.github/actions/tests/run-benchmark-tests

View File

@@ -40,11 +40,8 @@ jobs:
- name: Install npm dependencies
run: |
npm ci --legacy-peer-deps
CHROME_SANDBOX="${GITHUB_WORKSPACE}/node_modules/electron/dist/chrome-sandbox"
if [[ -f "$CHROME_SANDBOX" ]]; then
sudo chown root "$CHROME_SANDBOX"
sudo chmod 4755 "$CHROME_SANDBOX"
fi
sudo chown root /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
sudo chmod 4755 /home/runner/work/bruno/bruno/node_modules/electron/dist/chrome-sandbox
- name: Install test collection dependencies
run: npm ci --prefix packages/bruno-tests/collection

View File

@@ -6,10 +6,6 @@ on:
pull_request:
branches: [main, 'release/v*']
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
unit-test:
name: Unit Tests (Linux)
@@ -71,11 +67,8 @@ jobs:
- name: Configure Chrome Sandbox
run: |
CHROME_SANDBOX="${GITHUB_WORKSPACE}/node_modules/electron/dist/chrome-sandbox"
if [[ -f "$CHROME_SANDBOX" ]]; then
sudo chown root "$CHROME_SANDBOX"
sudo chmod 4755 "$CHROME_SANDBOX"
fi
sudo chown root node_modules/electron/dist/chrome-sandbox
sudo chmod 4755 node_modules/electron/dist/chrome-sandbox
- name: Run E2E Tests
uses: ./.github/actions/tests/run-e2e-tests

View File

@@ -6,10 +6,6 @@ on:
pull_request:
branches: [main, 'release/v*']
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
unit-test:
name: Unit Tests (macOS)

View File

@@ -6,10 +6,6 @@ on:
pull_request:
branches: [main, 'release/v*']
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
unit-test:
name: Unit Tests (Windows)

81
package-lock.json generated
View File

@@ -37,13 +37,11 @@
"@storybook/react": "^10.1.10",
"@storybook/react-webpack5": "^10.1.10",
"@stylistic/eslint-plugin": "^5.3.1",
"@types/adm-zip": "^0.5.8",
"@types/jest": "^29.5.11",
"@types/js-yaml": "^4.0.9",
"@types/lodash-es": "^4.17.12",
"@types/node": "^22.14.1",
"@typescript-eslint/parser": "^8.39.0",
"adm-zip": "^0.5.17",
"concurrently": "^8.2.2",
"cross-env": "10.1.0",
"eslint": "^9.39.4",
@@ -12680,16 +12678,6 @@
"node": ">=10.13.0"
}
},
"node_modules/@types/adm-zip": {
"version": "0.5.8",
"resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.8.tgz",
"integrity": "sha512-RVVH7QvZYbN+ihqZ4kX/dMiowf6o+Jk1fNwiSdx0NahBJLU787zkULhGhJM8mf/obmLGmgdMM0bXsQTmyfbR7Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
@@ -14188,9 +14176,9 @@
}
},
"node_modules/adm-zip": {
"version": "0.5.17",
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz",
"integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==",
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz",
"integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==",
"license": "MIT",
"engines": {
"node": ">=12.0"
@@ -23879,9 +23867,9 @@
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.0.tgz",
"integrity": "sha512-l1mfj2atMqndAHI3ls7XqPxEjV2J9ZkcNyHpoZA3r2T1LLwDB69jgkMWh71YKwhBbK0G2f4WSn05ahmQXVxupA==",
"license": "MIT"
},
"node_modules/lodash-es": {
@@ -32617,12 +32605,6 @@
"node": ">=0.10.0"
}
},
"node_modules/workerpool": {
"version": "10.0.2",
"resolved": "https://registry.npmjs.org/workerpool/-/workerpool-10.0.2.tgz",
"integrity": "sha512-8PCeZlCwu0+8hXruze1ahYNsY+M0LOCmbmySZ9BWWqWIXP9TAXa6FZCxACTDL/0j47pFcC4xW98Gr8nAC5oymg==",
"license": "Apache-2.0"
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
@@ -32955,7 +32937,6 @@
"codemirror": "5.65.2",
"codemirror-graphql": "2.1.1",
"cookie": "0.7.1",
"diff": "^5.2.0",
"diff2html": "^3.4.47",
"dompurify": "^3.2.4",
"escape-html": "^1.0.3",
@@ -32983,7 +32964,7 @@
"jsonschema": "^1.5.0",
"know-your-http-well": "^0.5.0",
"linkify-it": "^5.0.0",
"lodash": "4.18.1",
"lodash": "^4.17.21",
"markdown-it": "^13.0.2",
"markdown-it-replace-link": "^1.2.0",
"mime-types": "^3.0.2",
@@ -34568,7 +34549,7 @@
"https-proxy-agent": "^7.0.2",
"iconv-lite": "^0.6.3",
"js-yaml": "^4.1.1",
"lodash": "4.18.1",
"lodash": "^4.17.21",
"qs": "^6.14.1",
"socks-proxy-agent": "^8.0.2",
"xmlbuilder": "^15.1.1",
@@ -35105,7 +35086,7 @@
"@usebruno/schema": "^0.7.0",
"js-yaml": "^4.1.1",
"jscodeshift": "^17.3.0",
"lodash": "4.18.1",
"lodash": "^4.17.21",
"nanoid": "3.3.8",
"xml2js": "^0.6.2"
},
@@ -35237,7 +35218,7 @@
"iconv-lite": "^0.6.3",
"is-valid-path": "^0.1.1",
"js-yaml": "^4.1.1",
"lodash": "4.18.1",
"lodash": "^4.17.21",
"mime-types": "^2.1.35",
"nanoid": "3.3.8",
"qs": "^6.14.1",
@@ -35245,7 +35226,6 @@
"socks-proxy-agent": "^8.0.2",
"tough-cookie": "^6.0.0",
"uuid": "^10.0.0",
"workerpool": "10.0.2",
"yup": "^0.32.11",
"zod": "^4.1.8"
},
@@ -35788,7 +35768,7 @@
"@usebruno/common": "0.1.0",
"@usebruno/lang": "0.12.0",
"ajv": "^8.17.1",
"lodash": "4.18.1",
"lodash": "^4.17.21",
"yaml": "^2.3.4"
},
"devDependencies": {
@@ -36002,7 +35982,7 @@
"crypto-js": "^4.2.0",
"json-query": "^2.2.2",
"jsonwebtoken": "^9.0.3",
"lodash": "4.18.1",
"lodash": "^4.17.21",
"moment": "^2.29.4",
"nanoid": "3.3.8",
"node-fetch": "^2.7.0",
@@ -36173,29 +36153,10 @@
"@usebruno/common": "0.1.0",
"arcsecond": "^5.0.0",
"dotenv": "^16.3.1",
"lodash": "4.18.1",
"lodash": "^4.17.21",
"ohm-js": "^16.6.0"
}
},
"packages/bruno-pool": {
"name": "@usebruno/pool",
"version": "0.1.0",
"extraneous": true,
"license": "MIT",
"dependencies": {
"@usebruno/filestore": "0.1.0",
"workerpool": "10.0.2"
},
"devDependencies": {
"@rollup/plugin-commonjs": "23.0.2",
"@rollup/plugin-node-resolve": "15.0.1",
"@rollup/plugin-typescript": "12.1.2",
"@types/node": "^24.1.0",
"rollup": "3.30.0",
"tslib": "2.8.1",
"typescript": "5.4.5"
}
},
"packages/bruno-query": {
"name": "@usebruno/query",
"version": "0.1.0",
@@ -36424,16 +36385,6 @@
"node": ">=14.17"
}
},
"packages/bruno-storage": {
"name": "@usebruno/storage",
"version": "0.1.0",
"extraneous": true,
"license": "MIT",
"devDependencies": {
"@types/node": "^24.1.0",
"typescript": "5.4.5"
}
},
"packages/bruno-tests": {
"name": "@usebruno/tests",
"version": "0.0.1",
@@ -36451,7 +36402,7 @@
"http-proxy": "^1.18.1",
"js-yaml": "^4.1.1",
"jsonwebtoken": "^9.0.3",
"lodash": "4.18.1",
"lodash": "^4.17.21",
"multer": "^1.4.5-lts.1",
"ws": "^8.18.3"
}
@@ -36703,8 +36654,8 @@
"license": "MIT",
"dependencies": {
"@iarna/toml": "^2.2.5",
"lodash": "4.18.1"
"lodash": "^4.17.21"
}
}
}
}
}

View File

@@ -30,13 +30,11 @@
"@storybook/react": "^10.1.10",
"@storybook/react-webpack5": "^10.1.10",
"@stylistic/eslint-plugin": "^5.3.1",
"@types/adm-zip": "^0.5.8",
"@types/jest": "^29.5.11",
"@types/js-yaml": "^4.0.9",
"@types/lodash-es": "^4.17.12",
"@types/node": "^22.14.1",
"@typescript-eslint/parser": "^8.39.0",
"adm-zip": "^0.5.17",
"concurrently": "^8.2.2",
"cross-env": "10.1.0",
"eslint": "^9.39.4",
@@ -85,7 +83,6 @@
"test:e2e": "playwright test --project=default --project=system-pac",
"test:e2e:ssl": "playwright test --project=ssl",
"test:e2e:auth": "playwright test --project=auth",
"test:e2e:sanity": "playwright test --project=default --project=system-pac --grep @sanity",
"test:benchmark": "playwright test --config=playwright.benchmark.config.ts",
"lint": "cross-env NODE_OPTIONS=\"--max_old_space_size=4096\" npx eslint",
"lint:fix": "cross-env NODE_OPTIONS=\"--max_old_space_size=4096\" npx eslint --fix",

View File

@@ -27,7 +27,6 @@
"codemirror": "5.65.2",
"codemirror-graphql": "2.1.1",
"cookie": "0.7.1",
"diff": "^5.2.0",
"diff2html": "^3.4.47",
"dompurify": "^3.2.4",
"escape-html": "^1.0.3",
@@ -55,7 +54,7 @@
"jsonschema": "^1.5.0",
"know-your-http-well": "^0.5.0",
"linkify-it": "^5.0.0",
"lodash": "4.18.1",
"lodash": "^4.17.21",
"markdown-it": "^13.0.2",
"markdown-it-replace-link": "^1.2.0",
"mime-types": "^3.0.2",

View File

@@ -34,15 +34,16 @@ const StyledWrapper = styled.div`
}
}
`;
// Tippy renders the popup into document.body, outside StyledWrapper's subtree.
export const PopupWrapper = styled.div`
width: 360px;
background: ${(props) => props.theme.bg};
border: 1px solid ${(props) => props.theme.input.border};
border-radius: ${(props) => props.theme.border.radius.md};
overflow: hidden;
.ai-assist-popup {
position: absolute;
top: calc(100% + 4px);
right: 0;
width: 360px;
background: ${(props) => props.theme.bg};
border: 1px solid ${(props) => props.theme.input.border};
border-radius: ${(props) => props.theme.border.radius.md};
overflow: hidden;
}
.popup-header {
display: flex;
@@ -218,26 +219,6 @@ export const PopupWrapper = styled.div`
}
}
.btn-stop {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 5px 12px;
font-size: 12px;
font-weight: 500;
border-radius: ${(props) => props.theme.border.radius.sm};
border: 1px solid ${(props) => props.theme.input.border};
background: ${(props) => props.theme.input.bg};
color: ${(props) => props.theme.text};
cursor: pointer;
transition: background-color 0.15s ease, border-color 0.15s ease;
&:hover {
border-color: ${(props) => props.theme.colors.text.danger};
color: ${(props) => props.theme.colors.text.danger};
}
}
.btn-secondary {
padding: 5px 12px;
font-size: 12px;

View File

@@ -1,10 +1,9 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import get from 'lodash/get';
import Tippy from '@tippyjs/react';
import { IconStars, IconX, IconArrowBackUp, IconPlayerStop } from '@tabler/icons';
import { aiGenerateScript, stopAiGeneration } from 'utils/ai';
import StyledWrapper, { PopupWrapper } from './StyledWrapper';
import { IconStars, IconX, IconArrowBackUp } from '@tabler/icons';
import { aiGenerateScript } from 'utils/ai';
import StyledWrapper from './StyledWrapper';
const SUGGESTIONS = {
'tests': [
@@ -22,84 +21,58 @@ const SUGGESTIONS = {
{ label: 'Save token', prompt: 'Extract a token from the response body and save it to an environment variable' },
{ label: 'Save id', prompt: 'Extract the primary id from the response body and save it to a variable' },
{ label: 'Log response', prompt: 'Log the response status and a short summary of the body' }
],
'docs': [
{ label: 'Overview', prompt: 'Write an overview section describing the purpose and key features' },
{ label: 'Request', prompt: 'Document the request method, URL, headers, parameters, and body' },
{ label: 'Examples', prompt: 'Add request and response examples with sample JSON' },
{ label: 'Errors', prompt: 'Document common error responses and status codes' }
],
'app-request': [
{ label: 'Send button', prompt: 'Add a button that calls ctx.sendRequest() and displays the response status, headers, and pretty-printed body' },
{ label: 'Form for body', prompt: 'Build a form whose fields override the request body, then send it with ctx.sendRequest({ variables }) and show the result' },
{ label: 'Response viewer', prompt: 'Render ctx.response with collapsible JSON and a banner showing status and response time; update on ctx.onResponseUpdate' },
{ label: 'Test results', prompt: 'List ctx.testResults and ctx.assertionResults with pass/fail badges; refresh on ctx.onResultsUpdate' }
],
'app-collection': [
{ label: 'Request list', prompt: 'List all requests from ctx.listRequests() with their method and url, and a Run button next to each that calls ctx.runRequest(pathname)' },
{ label: 'Dashboard', prompt: 'Build a small dashboard that runs every request from ctx.listRequests() on load and shows status code, response time, and a pass/fail dot for each' },
{ label: 'Form runner', prompt: 'Render a form, and on submit call ctx.runRequest(pathname, { variables }) for a chosen request and display the response' },
{ label: 'Variables panel', prompt: 'Show ctx.variables in a table and allow editing values via ctx.setRuntimeVariable(key, value); react to ctx.onVariablesUpdate' }
]
};
const TITLES = {
'tests': 'Generate Tests',
'pre-request': 'Generate Pre-Request Script',
'post-response': 'Generate Post-Response Script',
'docs': 'Generate Documentation',
'app-request': 'Generate App',
'app-collection': 'Generate App'
};
const PREVIEW_LABELS = {
'docs': 'Preview · replaces current documentation',
'app-request': 'Preview · replaces current app',
'app-collection': 'Preview · replaces current app'
'post-response': 'Generate Post-Response Script'
};
const isValidType = (t) => SUGGESTIONS[t] !== undefined;
const AIAssist = ({ scriptType, currentScript, requestContext, docsContext, variables, onApply }) => {
const AIAssist = ({ scriptType, currentScript, requestContext, onApply }) => {
const [isOpen, setIsOpen] = useState(false);
const [prompt, setPrompt] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [generated, setGenerated] = useState(null);
const streamIdRef = useRef(null);
const tippyRef = useRef(null);
const buttonRef = useRef(null);
// Focus the prompt textarea when coming back from preview
useEffect(() => {
if (isOpen && generated == null) {
tippyRef.current?.popper?.querySelector('.popup-input')?.focus();
}
}, [isOpen, generated]);
// handle Escape key to close the popup
useEffect(() => {
if (!isOpen) return;
const onKeyDown = (e) => {
if (e.key === 'Escape') {
e.stopPropagation();
tippyRef.current?.hide();
}
};
document.addEventListener('keydown', onKeyDown, true);
return () => document.removeEventListener('keydown', onKeyDown, true);
}, [isOpen]);
const focusOnMount = useCallback((el) => {
el?.focus();
}, []);
const preferences = useSelector((state) => state.app.preferences);
const isAiEnabled = get(preferences, 'ai.enabled', false);
const suggestions = useMemo(() => SUGGESTIONS[scriptType] || [], [scriptType]);
const title = TITLES[scriptType] || 'Generate with AI';
const previewLabel = PREVIEW_LABELS[scriptType] || 'Preview · replaces current script';
const close = useCallback(() => {
tippyRef.current?.hide();
setIsOpen(false);
setError(null);
}, []);
const attachPopup = useCallback((el) => {
if (!el) return undefined;
const onDocMouseDown = (e) => {
if (!el.contains(e.target) && !buttonRef.current?.contains(e.target)) {
close();
}
};
const onKey = (e) => {
if (e.key === 'Escape') close();
};
document.addEventListener('mousedown', onDocMouseDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDocMouseDown);
document.removeEventListener('keydown', onKey);
};
}, [close]);
const handleGenerate = useCallback(
async (overridePrompt) => {
const text = (overridePrompt ?? prompt).trim();
@@ -107,22 +80,13 @@ const AIAssist = ({ scriptType, currentScript, requestContext, docsContext, vari
setIsLoading(true);
setError(null);
const streamId = `sparkle-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
streamIdRef.current = streamId;
try {
const result = await aiGenerateScript({
scriptType,
prompt: text,
currentScript: currentScript || '',
requestContext,
docsContext,
variables,
streamId
requestContext
});
if (result?.stopped) {
return;
}
if (result?.error) {
setError(result.error);
return;
@@ -135,19 +99,12 @@ const AIAssist = ({ scriptType, currentScript, requestContext, docsContext, vari
} catch (err) {
setError(err?.message || 'Failed to generate script');
} finally {
streamIdRef.current = null;
setIsLoading(false);
}
},
[prompt, isLoading, scriptType, currentScript, requestContext, docsContext, variables]
[prompt, isLoading, scriptType, currentScript, requestContext]
);
const handleStop = useCallback(() => {
if (streamIdRef.current) {
stopAiGeneration(streamIdRef.current);
}
}, []);
const handleApply = useCallback(() => {
if (generated == null) return;
onApply(generated);
@@ -165,136 +122,109 @@ const AIAssist = ({ scriptType, currentScript, requestContext, docsContext, vari
return (
<StyledWrapper>
<Tippy
interactive
trigger="click"
placement="bottom-end"
arrow={false}
animation={false}
maxWidth="none"
appendTo={() => document.body}
onCreate={(instance) => (tippyRef.current = instance)}
onShow={(instance) => {
setIsOpen(true);
// rAF so the popup content is in the DOM
requestAnimationFrame(() => instance.popper?.querySelector('.popup-input')?.focus());
}}
onHide={() => {
setIsOpen(false);
setError(null);
}}
render={(attrs) => (
<PopupWrapper className="ai-assist-popup" role="dialog" aria-label={title} tabIndex={-1} {...attrs}>
<div className="popup-header">
<span className="popup-title">
<IconStars size={12} strokeWidth={1.75} />
{title}
</span>
<button className="popup-close" onClick={close} type="button" aria-label="Close">
<IconX size={14} />
</button>
</div>
{generated == null ? (
<>
<div className="popup-body">
<textarea
className="popup-input"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleGenerate();
}
}}
placeholder="Describe what you want to generate..."
rows={3}
disabled={isLoading}
/>
{!isLoading && !prompt && suggestions.length > 0 && (
<div className="popup-suggestions">
{suggestions.map((s) => (
<button
key={s.label}
className="suggestion-chip"
type="button"
onClick={() => handleGenerate(s.prompt)}
disabled={isLoading}
>
{s.label}
</button>
))}
</div>
)}
{error && <div className="popup-error">{error}</div>}
</div>
<div className="popup-footer">
{isLoading ? (
<span className="popup-loading">
<span className="loading-spinner" />
Generating...
</span>
) : (
<span className="popup-hint">Enter to generate · Shift+Enter for newline</span>
)}
{isLoading ? (
<button
className="btn-stop"
type="button"
onClick={handleStop}
title="Stop generating"
>
<IconPlayerStop size={12} /> Stop
</button>
) : (
<button
className="btn-generate"
type="button"
onClick={() => handleGenerate()}
disabled={!prompt.trim()}
>
Generate
</button>
)}
</div>
</>
) : (
<>
<div className="popup-body">
<div className="preview-section">
<span className="preview-label">{previewLabel}</span>
<pre className="preview-code">{generated}</pre>
</div>
</div>
<div className="popup-footer">
<button className="btn-secondary" type="button" onClick={handleBackToPrompt}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<IconArrowBackUp size={12} /> Back
</span>
</button>
<button className="btn-generate" type="button" onClick={handleApply}>
Apply
</button>
</div>
</>
)}
</PopupWrapper>
)}
<button
ref={buttonRef}
className={`ai-assist-trigger ${isOpen ? 'open' : ''}`}
onClick={() => setIsOpen((v) => !v)}
title={title}
type="button"
aria-label={title}
>
<button
className={`ai-assist-trigger ${isOpen ? 'open' : ''}`}
title={title}
type="button"
aria-label={title}
>
<IconStars size={14} strokeWidth={1.75} />
</button>
</Tippy>
<IconStars size={14} strokeWidth={1.75} />
</button>
{isOpen && (
<div ref={attachPopup} className="ai-assist-popup" role="dialog" aria-label={title}>
<div className="popup-header">
<span className="popup-title">
<IconStars size={12} strokeWidth={1.75} />
{title}
</span>
<button className="popup-close" onClick={close} type="button" aria-label="Close">
<IconX size={14} />
</button>
</div>
{generated == null ? (
<>
<div className="popup-body">
<textarea
ref={focusOnMount}
className="popup-input"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleGenerate();
}
}}
placeholder="Describe what you want to generate..."
rows={3}
disabled={isLoading}
/>
{!isLoading && !prompt && suggestions.length > 0 && (
<div className="popup-suggestions">
{suggestions.map((s) => (
<button
key={s.label}
className="suggestion-chip"
type="button"
onClick={() => handleGenerate(s.prompt)}
disabled={isLoading}
>
{s.label}
</button>
))}
</div>
)}
{error && <div className="popup-error">{error}</div>}
</div>
<div className="popup-footer">
{isLoading ? (
<span className="popup-loading">
<span className="loading-spinner" />
Generating...
</span>
) : (
<span className="popup-hint"> + Enter to generate</span>
)}
<button
className="btn-generate"
type="button"
onClick={() => handleGenerate()}
disabled={!prompt.trim() || isLoading}
>
Generate
</button>
</div>
</>
) : (
<>
<div className="popup-body">
<div className="preview-section">
<span className="preview-label">Preview · replaces current script</span>
<pre className="preview-code">{generated}</pre>
</div>
</div>
<div className="popup-footer">
<button className="btn-secondary" type="button" onClick={handleBackToPrompt}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<IconArrowBackUp size={12} /> Back
</span>
</button>
<button className="btn-generate" type="button" onClick={handleApply}>
Apply
</button>
</div>
</>
)}
</div>
)}
</StyledWrapper>
);
};

View File

@@ -1,404 +0,0 @@
import '@testing-library/jest-dom';
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import { ThemeProvider } from 'styled-components';
import { aiGenerateScript, stopAiGeneration } from 'utils/ai';
import AIAssist from './index';
jest.mock('utils/ai', () => ({
aiGenerateScript: jest.fn(),
stopAiGeneration: jest.fn()
}));
const theme = {
bg: '#1e1e1e',
text: '#ffffff',
border: { radius: { sm: '4px', md: '6px' } },
colors: {
accent: '#6366f1',
text: { muted: '#9ca3af', danger: '#ef4444' },
bg: { danger: '#ef4444' }
},
input: {
border: '#374151',
bg: '#111827',
focusBorder: '#6366f1'
},
font: { monospace: 'monospace' }
};
const createStore = (aiEnabled = true) => configureStore({
reducer: {
app: (state = { preferences: { ai: { enabled: aiEnabled } } }) => state
}
});
const defaultProps = {
scriptType: 'tests',
currentScript: 'test("ok", () => {});',
onApply: jest.fn()
};
const renderAIAssist = ({
props = {},
aiEnabled = true
} = {}) => {
const mergedProps = { ...defaultProps, ...props };
return render(
<Provider store={createStore(aiEnabled)}>
<ThemeProvider theme={theme}>
<AIAssist {...mergedProps} />
</ThemeProvider>
</Provider>
);
};
const openPopup = () => {
fireEvent.click(screen.getByRole('button', { name: 'Generate Tests' }));
};
describe('AIAssist', () => {
beforeEach(() => {
jest.clearAllMocks();
aiGenerateScript.mockResolvedValue({ content: 'test("generated", () => {});' });
});
describe('visibility', () => {
it('renders nothing when AI is disabled', () => {
const { container } = renderAIAssist({ aiEnabled: false });
expect(container.firstChild).toBeNull();
});
it('renders nothing for an unsupported script type', () => {
const { container } = renderAIAssist({ props: { scriptType: 'unknown-type' } });
expect(container.firstChild).toBeNull();
});
it('renders the trigger when AI is enabled and the script type is supported', () => {
renderAIAssist();
expect(screen.getByRole('button', { name: 'Generate Tests' })).toBeInTheDocument();
});
});
describe('titles', () => {
it.each([
['tests', 'Generate Tests'],
['pre-request', 'Generate Pre-Request Script'],
['post-response', 'Generate Post-Response Script'],
['docs', 'Generate Documentation']
])('uses the correct title for %s', (scriptType, title) => {
renderAIAssist({ props: { scriptType } });
expect(screen.getByRole('button', { name: title })).toBeInTheDocument();
});
});
describe('popup interactions', () => {
it('opens and closes the popup from the trigger and close button', () => {
renderAIAssist();
openPopup();
expect(screen.getByRole('dialog', { name: 'Generate Tests' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(screen.queryByRole('dialog', { name: 'Generate Tests' })).not.toBeInTheDocument();
});
it('renders the popup into document.body as a portal', () => {
renderAIAssist();
openPopup();
const dialog = screen.getByRole('dialog', { name: 'Generate Tests' });
const tippyRoot = dialog.closest('[data-tippy-root]');
expect(tippyRoot).not.toBeNull();
expect(tippyRoot.parentElement).toBe(document.body);
});
it('closes the popup when Escape is pressed', () => {
renderAIAssist();
openPopup();
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByRole('dialog', { name: 'Generate Tests' })).not.toBeInTheDocument();
});
it('closes the popup when clicking outside', () => {
renderAIAssist();
openPopup();
fireEvent.mouseDown(document.body);
expect(screen.queryByRole('dialog', { name: 'Generate Tests' })).not.toBeInTheDocument();
});
});
describe('prompt view', () => {
it('shows suggestion chips when the prompt is empty', () => {
renderAIAssist();
openPopup();
expect(screen.getByRole('button', { name: 'Status 200' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'JSON body' })).toBeInTheDocument();
});
it('shows docs suggestions for the docs script type', () => {
renderAIAssist({ props: { scriptType: 'docs', currentScript: '# Overview' } });
fireEvent.click(screen.getByRole('button', { name: 'Generate Documentation' }));
expect(screen.getByRole('button', { name: 'Overview' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Request' })).toBeInTheDocument();
});
it('hides suggestions once the user starts typing', () => {
renderAIAssist();
openPopup();
fireEvent.change(screen.getByPlaceholderText('Describe what you want to generate...'), {
target: { value: 'Add a status test' }
});
expect(screen.queryByRole('button', { name: 'Status 200' })).not.toBeInTheDocument();
});
it('keeps Generate disabled until the prompt has text', () => {
renderAIAssist();
openPopup();
expect(screen.getByRole('button', { name: 'Generate' })).toBeDisabled();
fireEvent.change(screen.getByPlaceholderText('Describe what you want to generate...'), {
target: { value: 'Add a status test' }
});
expect(screen.getByRole('button', { name: 'Generate' })).toBeEnabled();
});
});
describe('generation flow', () => {
it('generates from a suggestion chip', async () => {
renderAIAssist();
openPopup();
fireEvent.click(screen.getByRole('button', { name: 'Status 200' }));
await waitFor(() => {
expect(aiGenerateScript).toHaveBeenCalledWith(expect.objectContaining({
scriptType: 'tests',
prompt: 'Add a test asserting the response status code is 200',
currentScript: 'test("ok", () => {});',
requestContext: undefined,
streamId: expect.any(String)
}));
});
expect(screen.getByText('test("generated", () => {});')).toBeInTheDocument();
});
it('passes docs context for folder and collection documentation', async () => {
const docsContext = {
scope: 'folder',
name: 'Users',
collectionName: 'Pet Store API',
folders: [{ name: 'Admin', requestCount: 1, subfolderCount: 0 }],
requests: [{ name: 'List Users', method: 'GET', url: '{{base}}/users' }]
};
renderAIAssist({ props: { scriptType: 'docs', currentScript: '', docsContext } });
fireEvent.click(screen.getByRole('button', { name: 'Generate Documentation' }));
fireEvent.click(screen.getByRole('button', { name: 'Overview' }));
await waitFor(() => {
expect(aiGenerateScript).toHaveBeenCalledWith(expect.objectContaining({
scriptType: 'docs',
prompt: 'Write an overview section describing the purpose and key features',
currentScript: '',
requestContext: undefined,
docsContext
}));
});
});
it('generates from the prompt input and passes request context', async () => {
const requestContext = {
method: 'GET',
url: 'https://api.example.com/users',
headers: [],
params: [],
body: null
};
renderAIAssist({ props: { requestContext } });
openPopup();
fireEvent.change(screen.getByPlaceholderText('Describe what you want to generate...'), {
target: { value: 'Add auth header test' }
});
fireEvent.click(screen.getByRole('button', { name: 'Generate' }));
await waitFor(() => {
expect(aiGenerateScript).toHaveBeenCalledWith(expect.objectContaining({
scriptType: 'tests',
prompt: 'Add auth header test',
currentScript: 'test("ok", () => {});',
requestContext
}));
});
});
it('generates when pressing Enter', async () => {
renderAIAssist();
openPopup();
const textarea = screen.getByPlaceholderText('Describe what you want to generate...');
fireEvent.change(textarea, { target: { value: 'Add response time test' } });
fireEvent.keyDown(textarea, { key: 'Enter' });
await waitFor(() => {
expect(aiGenerateScript).toHaveBeenCalledWith(expect.objectContaining({
scriptType: 'tests',
prompt: 'Add response time test',
currentScript: 'test("ok", () => {});',
requestContext: undefined
}));
});
});
it('does not generate when pressing Shift+Enter (allows newline)', () => {
renderAIAssist();
openPopup();
const textarea = screen.getByPlaceholderText('Describe what you want to generate...');
fireEvent.change(textarea, { target: { value: 'Add response time test' } });
fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: true });
expect(aiGenerateScript).not.toHaveBeenCalled();
});
it('shows a loading state while generation is in progress', async () => {
let resolveGenerate;
aiGenerateScript.mockImplementation(() => new Promise((resolve) => {
resolveGenerate = resolve;
}));
renderAIAssist();
openPopup();
fireEvent.click(screen.getByRole('button', { name: 'Status 200' }));
expect(screen.getByText('Generating...')).toBeInTheDocument();
resolveGenerate({ content: 'test("done", () => {});' });
await waitFor(() => {
expect(screen.getByText('test("done", () => {});')).toBeInTheDocument();
});
});
it('shows a Stop button during generation and cancels via streamId', async () => {
let resolveGenerate;
aiGenerateScript.mockImplementation(() => new Promise((resolve) => {
resolveGenerate = resolve;
}));
renderAIAssist();
openPopup();
fireEvent.click(screen.getByRole('button', { name: 'Status 200' }));
const stopButton = await screen.findByRole('button', { name: /stop/i });
expect(stopButton).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Generate' })).not.toBeInTheDocument();
const passedStreamId = aiGenerateScript.mock.calls[0][0].streamId;
expect(passedStreamId).toEqual(expect.any(String));
fireEvent.click(stopButton);
expect(stopAiGeneration).toHaveBeenCalledWith(passedStreamId);
resolveGenerate({ stopped: true });
await waitFor(() => {
expect(screen.queryByRole('button', { name: /stop/i })).not.toBeInTheDocument();
});
expect(screen.queryByRole('button', { name: 'Apply' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Generate' })).toBeInTheDocument();
});
it('shows an API error without entering preview mode', async () => {
aiGenerateScript.mockResolvedValue({ error: 'Provider unavailable' });
renderAIAssist();
openPopup();
fireEvent.click(screen.getByRole('button', { name: 'Status 200' }));
await waitFor(() => {
expect(screen.getByText('Provider unavailable')).toBeInTheDocument();
});
expect(screen.queryByRole('button', { name: 'Apply' })).not.toBeInTheDocument();
});
it('shows a fallback error when no content is returned', async () => {
aiGenerateScript.mockResolvedValue({});
renderAIAssist();
openPopup();
fireEvent.click(screen.getByRole('button', { name: 'Status 200' }));
await waitFor(() => {
expect(screen.getByText('No content was generated. Try rephrasing your prompt.')).toBeInTheDocument();
});
});
});
describe('preview and apply', () => {
const showPreview = async () => {
renderAIAssist();
openPopup();
fireEvent.click(screen.getByRole('button', { name: 'Status 200' }));
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument();
});
};
it('uses the script preview label for script types', async () => {
await showPreview();
expect(screen.getByText('Preview · replaces current script')).toBeInTheDocument();
});
it('uses the documentation preview label for docs', async () => {
aiGenerateScript.mockResolvedValue({ content: '# API Docs' });
renderAIAssist({ props: { scriptType: 'docs', currentScript: '# Existing' } });
fireEvent.click(screen.getByRole('button', { name: 'Generate Documentation' }));
fireEvent.click(screen.getByRole('button', { name: 'Overview' }));
await waitFor(() => {
expect(screen.getByText('Preview · replaces current documentation')).toBeInTheDocument();
});
expect(screen.getByText('# API Docs')).toBeInTheDocument();
});
it('applies generated content and closes the popup', async () => {
const onApply = jest.fn();
renderAIAssist({ props: { onApply } });
openPopup();
fireEvent.click(screen.getByRole('button', { name: 'Status 200' }));
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
expect(onApply).toHaveBeenCalledWith('test("generated", () => {});');
expect(screen.queryByRole('dialog', { name: 'Generate Tests' })).not.toBeInTheDocument();
});
it('returns to the prompt view when Back is clicked', async () => {
await showPreview();
fireEvent.click(screen.getByRole('button', { name: 'Back' }));
expect(screen.getByPlaceholderText('Describe what you want to generate...')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Status 200' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Apply' })).not.toBeInTheDocument();
});
});
});

View File

@@ -1,46 +0,0 @@
import { useState, useRef, useEffect } from 'react';
import { IconCopy, IconCheck } from '@tabler/icons';
const AssistantCodeBlock = ({ content, language, isOpen, isStreaming, isLast }) => {
const [isCopied, setIsCopied] = useState(false);
const preRef = useRef(null);
useEffect(() => {
if (isStreaming && isOpen && preRef.current) {
preRef.current.scrollTop = preRef.current.scrollHeight;
}
}, [content, isStreaming, isOpen]);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(content);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 1500);
} catch (err) {
console.error('Failed to copy:', err);
}
};
return (
<div className="assistant-code-block">
<div className="assistant-code-block__header">
<div className="assistant-code-block__meta">
<span className="assistant-code-block__lang">{language || 'code'}</span>
{isOpen && <span className="assistant-code-block__spinner" />}
</div>
<button className="assistant-code-block__btn" onClick={handleCopy} title="Copy">
{isCopied ? <IconCheck size={12} /> : <IconCopy size={12} />}
{isCopied ? 'Copied' : 'Copy'}
</button>
</div>
<pre ref={preRef} className="assistant-code-block__body">
<code className={`language-${language || 'text'}`}>
{content}
{isStreaming && isLast && <span className="cursor">|</span>}
</code>
</pre>
</div>
);
};
export default AssistantCodeBlock;

View File

@@ -1,298 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
margin-top: 8px;
border-radius: ${(props) => props.theme.border.radius.base};
overflow: hidden;
border: 1px solid ${(props) => props.theme.border.border1};
background: ${(props) => props.theme.codemirror.bg};
&.accepted {
border-color: ${(props) => props.theme.colors.text.green};
}
&.rejected {
opacity: 0.5;
}
.diff-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
background: ${(props) => props.theme.background.mantle};
border-bottom: 1px solid ${(props) => props.theme.border.border1};
gap: 8px;
flex-wrap: nowrap;
}
.diff-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 500;
color: ${(props) => props.theme.colors.text.muted};
flex-shrink: 0;
.diff-icon {
color: ${(props) => props.theme.brand};
display: flex;
align-items: center;
}
}
.diff-content-type {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 1px 6px;
border-radius: 3px;
background: ${(props) => props.theme.background.surface0};
color: ${(props) => props.theme.colors.text.muted};
}
.diff-stats {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
font-weight: 500;
.stat {
padding: 1px 5px;
border-radius: 4px;
}
.additions {
background: ${(props) => props.theme.status.success.background};
color: ${(props) => props.theme.colors.text.green};
}
.deletions {
background: ${(props) => props.theme.status.danger.background};
color: ${(props) => props.theme.colors.text.danger};
}
}
.diff-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
margin-left: auto;
}
.diff-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 4px 8px;
font-size: 11px;
font-weight: 500;
border: 1px solid transparent;
border-radius: ${(props) => props.theme.border.radius.base};
cursor: pointer;
white-space: nowrap;
&.accept {
background: ${(props) => props.theme.brand};
color: ${(props) => (props.theme.mode === 'dark' ? '#000' : '#fff')};
&:hover:not(:disabled) {
opacity: 0.9;
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
&.reject {
background: transparent;
color: ${(props) => props.theme.colors.text.muted};
border-color: ${(props) => props.theme.border.border1};
&:hover {
background: ${(props) => props.theme.status.danger.background};
color: ${(props) => props.theme.colors.text.danger};
border-color: ${(props) => props.theme.status.danger.background};
}
}
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
font-size: 11px;
border-radius: ${(props) => props.theme.border.radius.base};
font-weight: 500;
&.accepted {
background: ${(props) => props.theme.status.success.background};
color: ${(props) => props.theme.colors.text.green};
}
&.rejected {
background: ${(props) => props.theme.status.danger.background};
color: ${(props) => props.theme.colors.text.danger};
}
}
.diff-warning {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
font-size: 11px;
border-bottom: 1px solid ${(props) => props.theme.border.border1};
&.warn {
background: ${(props) => props.theme.status.warning.background};
color: ${(props) => props.theme.status.warning.text};
}
&.error {
background: ${(props) => props.theme.status.danger.background};
color: ${(props) => props.theme.colors.text.danger};
}
}
.diff-toggle {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 4px 8px;
font-size: 11px;
color: ${(props) => props.theme.colors.text.muted};
background: transparent;
border: none;
border-top: 1px solid ${(props) => props.theme.border.border1};
cursor: pointer;
width: 100%;
&:hover {
background: ${(props) => props.theme.background.surface0};
color: ${(props) => props.theme.text};
}
}
.diff-content {
max-height: 300px;
overflow: auto;
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
font-size: 11px;
line-height: 1.5;
&::-webkit-scrollbar {
width: 4px;
height: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: ${(props) => props.theme.border.border1};
border-radius: 2px;
}
}
.diff-line {
padding: 0 8px 0 4px;
white-space: pre;
display: flex;
min-height: 18px;
line-height: 18px;
.line-number {
width: 24px;
text-align: right;
padding-right: 8px;
color: ${(props) => props.theme.colors.text.muted};
user-select: none;
flex-shrink: 0;
opacity: 0.5;
}
.line-prefix {
width: 12px;
flex-shrink: 0;
}
.line-content {
flex: 1;
overflow-x: auto;
}
&.added {
background: ${(props) => props.theme.status.success.background};
.line-content { color: ${(props) => props.theme.colors.text.green}; }
.line-prefix { color: ${(props) => props.theme.colors.text.green}; font-weight: 600; }
}
&.removed {
background: ${(props) => props.theme.status.danger.background};
.line-content { color: ${(props) => props.theme.colors.text.danger}; }
.line-prefix { color: ${(props) => props.theme.colors.text.danger}; font-weight: 600; }
}
&.unchanged {
.line-content { color: ${(props) => props.theme.colors.text.muted}; }
.line-prefix { opacity: 0; }
}
}
.expand-marker {
display: flex;
align-items: center;
padding: 0 8px 0 4px;
min-height: 22px;
background: ${(props) => props.theme.background.mantle};
.expand-gutter {
width: 24px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: flex-end;
padding-right: 4px;
}
.expand-buttons {
display: flex;
flex-direction: column;
gap: 0;
}
.expand-btn {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 11px;
padding: 0;
background: transparent;
border: none;
color: ${(props) => props.theme.colors.text.muted};
cursor: pointer;
opacity: 0.6;
&:hover {
color: ${(props) => props.theme.text};
opacity: 1;
}
}
.expand-line {
flex: 1;
height: 1px;
background: ${(props) => props.theme.border.border1};
margin-left: 8px;
}
}
`;
export default StyledWrapper;

View File

@@ -1,210 +0,0 @@
import React, { useMemo, useState } from 'react';
import { diffLines } from 'diff';
import { IconCheck, IconX, IconCode, IconChevronDown, IconChevronUp } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
const CONTEXT_LINES = 2;
const EXPAND_CHUNK_SIZE = 20;
const DiffView = ({ originalCode, newCode, onAccept, onReject, status, contentTypeLabel, warning, disableAccept }) => {
const [isExpanded, setIsExpanded] = useState(true);
const [expandedFromTop, setExpandedFromTop] = useState({});
const [expandedFromBottom, setExpandedFromBottom] = useState({});
const diffResult = useMemo(() => {
const changes = diffLines(originalCode || '', newCode || '');
let additions = 0;
let deletions = 0;
let lineNumber = 1;
const lines = changes.flatMap((part) => {
const partLines = part.value.split('\n');
if (partLines[partLines.length - 1] === '') partLines.pop();
return partLines.map((line) => {
const entry = { content: line, lineNumber: null };
if (part.added) {
additions += 1;
entry.type = 'added';
entry.lineNumber = lineNumber++;
} else if (part.removed) {
deletions += 1;
entry.type = 'removed';
} else {
entry.type = 'unchanged';
entry.lineNumber = lineNumber++;
}
return entry;
});
});
return { lines, additions, deletions };
}, [originalCode, newCode]);
const hunks = useMemo(() => {
const { lines } = diffResult;
if (lines.length === 0) return [];
const changedIndices = new Set();
lines.forEach((line, idx) => {
if (line.type === 'added' || line.type === 'removed') changedIndices.add(idx);
});
const visibleIndices = new Set();
changedIndices.forEach((idx) => {
for (let i = Math.max(0, idx - CONTEXT_LINES); i <= Math.min(lines.length - 1, idx + CONTEXT_LINES); i++) {
visibleIndices.add(i);
}
});
const result = [];
let i = 0;
while (i < lines.length) {
if (visibleIndices.has(i)) {
result.push({ type: 'line', data: lines[i], index: i });
i += 1;
} else {
const start = i;
while (i < lines.length && !visibleIndices.has(i)) i += 1;
result.push({
type: 'collapsed',
startIndex: start,
count: i - start,
lines: lines.slice(start, i)
});
}
}
return result;
}, [diffResult]);
const expandUp = (startIndex, totalLines) => {
setExpandedFromTop((prev) => {
const current = prev[startIndex] || 0;
const bottomExpanded = expandedFromBottom[startIndex] || 0;
const remaining = totalLines - current - bottomExpanded;
return { ...prev, [startIndex]: Math.min(current + EXPAND_CHUNK_SIZE, current + remaining) };
});
};
const expandDown = (startIndex, totalLines) => {
setExpandedFromBottom((prev) => {
const current = prev[startIndex] || 0;
const topExpanded = expandedFromTop[startIndex] || 0;
const remaining = totalLines - topExpanded - current;
return { ...prev, [startIndex]: Math.min(current + EXPAND_CHUNK_SIZE, current + remaining) };
});
};
if (diffResult.additions === 0 && diffResult.deletions === 0) return null;
const renderActions = () => {
if (status === 'accepted') {
return (
<span className="status-badge accepted">
<IconCheck size={12} /> Applied
</span>
);
}
if (status === 'rejected') {
return (
<span className="status-badge rejected">
<IconX size={12} /> Dismissed
</span>
);
}
return (
<div className="diff-actions">
<button className="diff-btn reject" onClick={onReject} title="Dismiss changes">
<IconX size={12} />
</button>
<button className="diff-btn accept" onClick={onAccept} title="Apply changes" disabled={disableAccept}>
<IconCheck size={12} /> Apply
</button>
</div>
);
};
const renderLine = (line, key) => (
<div key={key} className={`diff-line ${line.type}`}>
<span className="line-number">{line.type !== 'removed' ? line.lineNumber : ''}</span>
<span className="line-prefix">{line.type === 'added' ? '+' : line.type === 'removed' ? '-' : ' '}</span>
<span className="line-content">{line.content || ' '}</span>
</div>
);
const renderHunks = () =>
hunks.map((hunk, idx) => {
if (hunk.type === 'line') return renderLine(hunk.data, `line-${hunk.index}`);
const topCount = expandedFromTop[hunk.startIndex] || 0;
const bottomCount = expandedFromBottom[hunk.startIndex] || 0;
const remainingCount = hunk.count - topCount - bottomCount;
const topLines = hunk.lines.slice(0, topCount);
const bottomLines = hunk.lines.slice(hunk.count - bottomCount);
const isAtTop = idx === 0;
const isAtBottom = idx === hunks.length - 1;
return (
<React.Fragment key={`collapsed-${hunk.startIndex}`}>
{topLines.map((line, lineIdx) => renderLine(line, `top-${hunk.startIndex}-${lineIdx}`))}
{remainingCount > 0 && (
<div className="expand-marker">
<div className="expand-gutter">
<div className="expand-buttons">
{!isAtTop && (
<button className="expand-btn" onClick={() => expandUp(hunk.startIndex, hunk.count)} title="Expand up">
<IconChevronUp size={10} />
</button>
)}
{!isAtBottom && (
<button className="expand-btn" onClick={() => expandDown(hunk.startIndex, hunk.count)} title="Expand down">
<IconChevronDown size={10} />
</button>
)}
</div>
</div>
<div className="expand-line" />
</div>
)}
{bottomLines.map((line, lineIdx) => renderLine(line, `bottom-${hunk.startIndex}-${lineIdx}`))}
</React.Fragment>
);
});
return (
<StyledWrapper className={status || ''}>
<div className="diff-header">
<div className="diff-title">
<span className="diff-icon"><IconCode size={12} /></span>
{contentTypeLabel && <span className="diff-content-type">{contentTypeLabel}</span>}
<div className="diff-stats">
<span className="stat additions">+{diffResult.additions}</span>
<span className="stat deletions">-{diffResult.deletions}</span>
</div>
</div>
{renderActions()}
</div>
{warning && (
<div className={`diff-warning ${disableAccept ? 'error' : 'warn'}`}>
{warning}
</div>
)}
{isExpanded && <div className="diff-content">{renderHunks()}</div>}
<button className="diff-toggle" onClick={() => setIsExpanded((v) => !v)}>
{isExpanded ? (
<><IconChevronUp size={12} /> Hide</>
) : (
<><IconChevronDown size={12} /> Show ({diffResult.additions + diffResult.deletions})</>
)}
</button>
</StyledWrapper>
);
};
export default DiffView;

View File

@@ -1,831 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
flex-shrink: 0;
height: 100%;
.ai-sidebar {
width: 420px;
height: 100%;
background: ${(props) => props.theme.bg};
border-left: 1px solid ${(props) => props.theme.border.border1};
display: flex;
flex-direction: column;
}
.ai-sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-bottom: 1px solid ${(props) => props.theme.border.border1};
.header-left {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.header-icon {
color: ${(props) => props.theme.brand};
flex-shrink: 0;
display: flex;
align-items: center;
}
.header-method {
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
padding: 2px 6px;
border-radius: 4px;
flex-shrink: 0;
background: ${(props) => props.theme.background.surface0};
display: flex;
align-items: center;
&.method-get { color: ${(props) => props.theme.request.methods.get}; }
&.method-post { color: ${(props) => props.theme.request.methods.post}; }
&.method-put { color: ${(props) => props.theme.request.methods.put}; }
&.method-delete { color: ${(props) => props.theme.request.methods.delete}; }
&.method-patch { color: ${(props) => props.theme.request.methods.patch}; }
&.method-options { color: ${(props) => props.theme.request.methods.options}; }
&.method-head { color: ${(props) => props.theme.request.methods.head}; }
&.method-grpc { color: ${(props) => props.theme.request.grpc}; }
&.method-ws { color: ${(props) => props.theme.request.ws}; }
&.method-gql { color: ${(props) => props.theme.request.gql}; }
&.method-app { color: ${(props) => props.theme.brand}; }
}
.header-title {
font-size: 13px;
color: ${(props) => props.theme.text};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
align-items: center;
}
.chat-switcher-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 2px;
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
color: ${(props) => props.theme.colors.text.muted};
flex-shrink: 0;
&:hover {
background: ${(props) => props.theme.background.surface0};
color: ${(props) => props.theme.text};
}
}
.header-actions {
display: flex;
align-items: center;
gap: 2px;
}
.history-wrap {
position: relative;
}
.icon-btn {
position: relative;
padding: 6px;
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
color: ${(props) => props.theme.colors.text.muted};
display: flex;
align-items: center;
justify-content: center;
&:hover {
background: ${(props) => props.theme.background.surface0};
color: ${(props) => props.theme.text};
}
&.is-active {
background: ${(props) => props.theme.background.surface0};
color: ${(props) => props.theme.text};
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
&.close-btn:hover {
background: ${(props) => props.theme.status.danger.background};
color: ${(props) => props.theme.colors.text.danger};
}
}
}
.history-popover {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 20;
width: 300px;
max-height: 320px;
overflow-y: auto;
background: ${(props) => props.theme.bg};
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: 8px;
box-shadow: ${(props) => props.theme.shadow.md};
padding: 4px;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
background: ${(props) => props.theme.scrollbar.color};
border-radius: 2px;
}
&__empty {
padding: 16px;
text-align: center;
font-size: 11px;
color: ${(props) => props.theme.colors.text.muted};
}
&__item {
display: flex;
align-items: stretch;
gap: 2px;
border-radius: 4px;
&:hover {
background: ${(props) => props.theme.background.surface0};
}
&.is-active {
background: ${(props) => props.theme.background.surface0};
}
}
&__title {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
padding: 6px 8px;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
color: ${(props) => props.theme.text};
}
&__title-text {
display: block;
width: 100%;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&__meta {
font-size: 10px;
color: ${(props) => props.theme.colors.text.muted};
}
&__delete {
display: flex;
align-items: center;
justify-content: center;
padding: 0 8px;
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
color: ${(props) => props.theme.colors.text.muted};
&:hover {
background: ${(props) => props.theme.status.danger.background};
color: ${(props) => props.theme.colors.text.danger};
}
}
}
.ai-sidebar-messages {
flex: 1;
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: ${(props) => props.theme.scrollbar.color};
border-radius: 2px;
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 24px 16px;
animation: fadeIn 0.3s ease;
.empty-icon {
width: 40px;
height: 40px;
border-radius: 10px;
background: ${(props) => props.theme.brand};
display: flex;
align-items: center;
justify-content: center;
color: ${(props) => (props.theme.mode === 'dark' ? '#000' : '#fff')};
margin-bottom: 12px;
}
h3 {
font-size: 14px;
font-weight: 600;
margin: 0 0 4px 0;
color: ${(props) => props.theme.text};
}
> p {
color: ${(props) => props.theme.colors.text.muted};
font-size: 12px;
margin: 0 0 16px 0;
line-height: 1.4;
}
.suggestions-title {
font-size: 11px;
color: ${(props) => props.theme.colors.text.muted};
margin: 0 0 8px 0;
font-weight: 500;
}
.suggestion-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: center;
}
.suggestion-chip {
padding: 5px 10px;
background: ${(props) => props.theme.background.surface0};
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: 12px;
font-size: 11px;
color: ${(props) => props.theme.text};
cursor: pointer;
&:hover {
border-color: ${(props) => props.theme.brand};
color: ${(props) => props.theme.brand};
}
}
}
.message {
animation: slideIn 0.25s ease;
&.user .message-content {
background: ${(props) => props.theme.background.mantle};
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: 8px;
padding: 8px 12px;
font-size: 13px;
line-height: 1.4;
color: ${(props) => props.theme.text};
}
&.assistant .message-content {
color: ${(props) => props.theme.text};
}
}
.message-status {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
margin-bottom: 6px;
color: ${(props) => props.theme.colors.text.muted};
&__spinner {
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid ${(props) => props.theme.brand};
border-top-color: transparent;
animation: spin 0.9s linear infinite;
flex-shrink: 0;
}
}
.tool-activity-log {
display: flex;
flex-direction: column;
gap: 1px;
margin: 6px 0;
padding: 4px 0;
&.completed {
opacity: 0.7;
}
}
.tool-activity-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: ${(props) => props.theme.colors.text.muted};
line-height: 1.6;
padding: 1px 0;
.tool-activity-indicator {
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
flex-shrink: 0;
}
&.done .tool-activity-indicator {
color: ${(props) => props.theme.colors.text.green};
}
&.active {
color: ${(props) => props.theme.text};
.tool-activity-indicator {
color: ${(props) => props.theme.brand};
}
}
.tool-activity-spinner {
width: 10px;
height: 10px;
border-radius: 50%;
border: 1.5px solid ${(props) => props.theme.brand};
border-top-color: transparent;
animation: spin 0.9s linear infinite;
display: block;
}
}
.message-cancelled {
margin-top: 8px;
font-size: 11px;
color: ${(props) => props.theme.colors.text.muted};
}
.assistant-code-block {
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: 8px;
background: ${(props) => props.theme.codemirror.bg};
overflow: hidden;
margin: 8px 0;
&__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 8px;
border-bottom: 1px solid ${(props) => props.theme.border.border1};
}
&__meta {
display: flex;
align-items: center;
gap: 6px;
font-size: 10px;
color: ${(props) => props.theme.colors.text.muted};
}
&__lang {
text-transform: lowercase;
}
&__spinner {
width: 10px;
height: 10px;
border-radius: 50%;
border: 2px solid ${(props) => props.theme.brand};
border-top-color: transparent;
animation: spin 0.9s linear infinite;
flex-shrink: 0;
}
&__btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 6px;
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: 4px;
background: ${(props) => props.theme.background.mantle};
font-size: 10px;
font-weight: 500;
color: ${(props) => props.theme.text};
cursor: pointer;
&:hover {
border-color: ${(props) => props.theme.brand};
color: ${(props) => props.theme.brand};
}
}
&__body {
margin: 0;
padding: 10px 12px;
overflow: auto;
max-height: 240px;
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
font-size: 11px;
line-height: 1.5;
white-space: pre;
}
.cursor {
display: inline-block;
animation: blink 1s infinite;
color: ${(props) => props.theme.brand};
margin-left: 1px;
}
}
.prose.markdown-body {
font-size: 13px;
line-height: 1.5;
.cursor {
display: inline-block;
animation: blink 1s infinite;
color: ${(props) => props.theme.brand};
margin-left: 1px;
}
p {
margin: 0 0 8px 0;
font-size: 13px;
&:last-child { margin-bottom: 0; }
}
h1, h2, h3, h4, h5, h6 {
margin: 10px 0 6px 0;
font-weight: 600;
line-height: 1.3;
&:first-child { margin-top: 0; }
}
h1 { font-size: 1.3em; }
h2 { font-size: 1.2em; }
h3 { font-size: 1.1em; }
ul, ol {
margin: 6px 0;
padding-left: 16px;
}
li {
margin: 4px 0;
font-size: 13px;
}
code {
background: ${(props) => props.theme.codemirror.bg};
padding: 2px 5px;
border-radius: 4px;
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
font-size: 0.9em;
}
pre, .code-block {
background: ${(props) => props.theme.codemirror.bg};
padding: 10px 12px;
border-radius: 6px;
overflow-x: auto;
margin: 8px 0;
border: 1px solid ${(props) => props.theme.border.border1};
code {
background: none;
padding: 0;
font-size: 11px;
line-height: 1.5;
}
}
blockquote {
border-left: 2px solid ${(props) => props.theme.brand};
margin: 8px 0;
padding: 4px 0 4px 10px;
color: ${(props) => props.theme.colors.text.muted};
background: ${(props) => props.theme.background.surface0};
border-radius: 0 4px 4px 0;
}
a {
color: ${(props) => props.theme.textLink};
text-decoration: none;
&:hover { text-decoration: underline; }
}
strong { font-weight: 600; }
em { font-style: italic; }
hr {
border: none;
border-top: 1px solid ${(props) => props.theme.border.border1};
margin: 10px 0;
}
table {
border-collapse: collapse;
width: 100%;
margin: 8px 0;
font-size: 12px;
}
th, td {
border: 1px solid ${(props) => props.theme.border.border1};
padding: 6px 8px;
text-align: left;
}
th {
background: ${(props) => props.theme.codemirror.bg};
font-weight: 600;
}
}
.processing-indicator {
padding: 8px 10px;
background: ${(props) => props.theme.background.surface0};
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: 8px;
animation: slideIn 0.2s ease;
.processing-content {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.processing-icon {
width: 20px;
height: 20px;
border-radius: 4px;
background: ${(props) => props.theme.background.surface1};
display: flex;
align-items: center;
justify-content: center;
color: ${(props) => props.theme.brand};
}
.processing-label {
font-size: 12px;
font-weight: 500;
color: ${(props) => props.theme.text};
}
.processing-dots {
display: flex;
gap: 3px;
margin-left: 2px;
span {
width: 3px;
height: 3px;
background: ${(props) => props.theme.brand};
border-radius: 50%;
animation: dotBounce 1.4s infinite ease-in-out both;
&:nth-child(1) { animation-delay: -0.32s; }
&:nth-child(2) { animation-delay: -0.16s; }
}
}
.processing-bar {
height: 2px;
background: ${(props) => props.theme.border.border1};
border-radius: 1px;
overflow: hidden;
.processing-bar-fill {
height: 100%;
width: 30%;
background: ${(props) => props.theme.brand};
border-radius: 1px;
animation: progressSlide 1.5s infinite ease-in-out;
}
}
}
.error-message {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 8px 10px;
background: ${(props) => props.theme.status.danger.background};
border: 1px solid ${(props) => props.theme.status.danger.border};
border-radius: 6px;
.error-icon {
width: 18px;
height: 18px;
border-radius: 50%;
background: ${(props) => props.theme.colors.text.danger};
color: ${(props) => (props.theme.mode === 'dark' ? '#000' : '#fff')};
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 11px;
flex-shrink: 0;
}
.error-text {
color: ${(props) => props.theme.colors.text.danger};
font-size: 12px;
line-height: 1.4;
}
}
.ai-sidebar-input {
padding: 12px;
border-top: 1px solid ${(props) => props.theme.border.border1};
.no-models-warning {
padding: 10px 12px;
font-size: 12px;
color: ${(props) => props.theme.colors.text.muted};
background: ${(props) => props.theme.input.bg};
border: 1px dashed ${(props) => props.theme.border.border1};
border-radius: 6px;
text-align: center;
line-height: 1.4;
}
.input-container {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
background: ${(props) => props.theme.bg};
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: 8px;
&:focus-within {
border-color: ${(props) => props.theme.brand};
}
}
textarea {
width: 100%;
padding: 0;
margin: 4px 0;
border: none;
background: transparent;
color: ${(props) => props.theme.text};
font-size: 13px;
font-family: inherit;
line-height: 1.4;
resize: none;
outline: none;
max-height: 100px;
&::placeholder {
color: ${(props) => props.theme.colors.text.muted};
}
&:disabled {
opacity: 0.6;
}
}
.input-actions {
display: flex;
align-items: center;
justify-content: space-between;
}
.model-selector {
position: relative;
}
.model-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 6px 4px 8px;
background: transparent;
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: ${(props) => props.theme.border.radius.base};
font-size: 11px;
font-weight: 500;
color: ${(props) => props.theme.text};
cursor: pointer;
svg:first-child {
color: ${(props) => props.theme.brand};
}
&:hover {
border-color: ${(props) => props.theme.border.border2};
}
}
.send-btn, .stop-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border: none;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
cursor: pointer;
}
.send-btn {
background: ${(props) => props.theme.brand};
color: ${(props) => (props.theme.mode === 'dark' ? '#000' : '#fff')};
&:hover {
opacity: 0.9;
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
}
.stop-btn {
background: ${(props) => props.theme.colors.text.danger};
color: ${(props) => (props.theme.mode === 'dark' ? '#000' : '#fff')};
&:hover {
opacity: 0.9;
}
}
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}
@keyframes dotBounce {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
@keyframes progressSlide {
0% { transform: translateX(-100%); }
100% { transform: translateX(400%); }
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`;
export default StyledWrapper;

View File

@@ -1,54 +0,0 @@
export const PROCESSING_STAGES = [
{ id: 'sending', label: 'Sending request', icon: 'send' },
{ id: 'thinking', label: 'AI is thinking', icon: 'sparkles' },
{ id: 'generating', label: 'Generating response', icon: 'wand' },
{ id: 'applying', label: 'Preparing changes', icon: 'code' }
];
export const CONTENT_TYPE_LABELS = {
'app': 'App',
'tests': 'Tests',
'pre-request': 'Script',
'post-response': 'Script',
'docs': 'Docs'
};
export const SUGGESTIONS_BY_TYPE = {
'app': [
{ label: 'Create a form for this request', prompt: 'Create a simple form to send this request' },
{ label: 'Add a loading spinner', prompt: 'Add a loading spinner while the request is pending' },
{ label: 'Show response in a table', prompt: 'Display the response data in a table' },
{ label: 'Add error handling', prompt: 'Add error handling with user-friendly messages' }
],
'tests': [
{ label: 'Generate basic tests', prompt: 'Generate tests for status code, response body, and headers' },
{ label: 'Test response structure', prompt: 'Write tests to validate the response body structure and data types' },
{ label: 'Test error cases', prompt: 'Write tests for common error scenarios' },
{ label: 'Test response time', prompt: 'Add a test to verify response time is acceptable' }
],
'pre-request': [
{ label: 'Add authentication', prompt: 'Add authorization header from environment variable' },
{ label: 'Set dynamic variables', prompt: 'Set dynamic request variables like timestamp or unique ID' },
{ label: 'Conditional logic', prompt: 'Add conditional logic to modify the request based on environment' }
],
'post-response': [
{ label: 'Extract to variables', prompt: 'Extract data from response and save to environment variables' },
{ label: 'Store auth token', prompt: 'Extract auth token from response and save for future requests' },
{ label: 'Log response', prompt: 'Log response status and body for debugging' },
{ label: 'Transform response', prompt: 'Transform and process the response data' }
],
'docs': [
{ label: 'Generate full docs', prompt: 'Generate comprehensive API documentation for this endpoint' },
{ label: 'Document parameters', prompt: 'Document all request parameters, headers, and body' },
{ label: 'Add examples', prompt: 'Add request and response examples' },
{ label: 'Document errors', prompt: 'Document common error responses and status codes' }
]
};
export const PLACEHOLDER_BY_TYPE = {
'tests': { empty: 'Describe the tests you want...', filled: 'Ask to modify or add tests...' },
'pre-request': { empty: 'Describe the script you want...', filled: 'Ask to modify the script...' },
'post-response': { empty: 'Describe the script you want...', filled: 'Ask to modify the script...' },
'docs': { empty: 'Describe the documentation...', filled: 'Ask to update the docs...' },
'app': { empty: 'Describe the app you want to create...', filled: 'Ask to modify your app...' }
};

View File

@@ -1,864 +0,0 @@
import React, { useState, useRef, useEffect, useCallback, useMemo, forwardRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
IconX,
IconPlayerStop,
IconCheck,
IconCode,
IconWand,
IconStars,
IconCornerDownLeft,
IconChevronDown,
IconHistory,
IconPlus,
IconTrash
} from '@tabler/icons';
import get from 'lodash/get';
import find from 'lodash/find';
import MenuDropdown from 'ui/MenuDropdown';
import { focusTab } from 'providers/ReduxStore/slices/tabs';
import {
closeAiSidebar,
sendAiMessage,
stopAiStream,
setChatBinding,
startNewConversation,
refreshChatHistory,
openConversation,
removeConversation,
setMessageCodeStatus
} from 'providers/ReduxStore/slices/chat';
import {
updateAppCode,
updateRequestTests,
updateRequestScript,
updateResponseScript,
updateRequestDocs,
updateFolderRequestScript,
updateFolderResponseScript,
updateFolderTests,
updateFolderDocs,
updateCollectionRequestScript,
updateCollectionResponseScript,
updateCollectionTests,
updateCollectionDocs
} from 'providers/ReduxStore/slices/collections';
import { findItemInCollection, isItemAFolder, isItemARequest } from 'utils/collections';
import { buildAiVariablesPayload, getAiStatus } from 'utils/ai';
import StyledWrapper from './StyledWrapper';
import DiffView from './DiffView';
import AssistantCodeBlock from './AssistantCodeBlock';
import { PROCESSING_STAGES, CONTENT_TYPE_LABELS, SUGGESTIONS_BY_TYPE, PLACEHOLDER_BY_TYPE } from './constants';
import { renderMarkdown, parseMessageSegments } from './utils';
const SELECTED_MODEL_LS_KEY = 'bruno.ai.chat.selectedModel';
const AUTO_MODEL_ID = '';
const ToolActivityGroup = ({ activities }) => {
if (!activities?.length) return null;
const allDone = activities.every((a) => a.done);
return (
<div className={`tool-activity-log ${allDone ? 'completed' : ''}`}>
{activities.map((activity, i) => (
<div key={i} className={`tool-activity-item ${activity.done ? 'done' : 'active'}`}>
<span className="tool-activity-indicator">
{activity.done ? <IconCheck size={10} /> : <span className="tool-activity-spinner" />}
</span>
<span>{activity.label}{!activity.done ? '…' : ''}</span>
</div>
))}
</div>
);
};
const buildMessageTimeline = (cleanedContent, activities) => {
if (!activities?.length) {
return cleanedContent ? [{ type: 'text', content: cleanedContent }] : [];
}
if (!cleanedContent) return [{ type: 'tools', activities }];
const groups = [];
for (const activity of activities) {
const offset = Math.min(activity.textOffset || 0, cleanedContent.length);
const last = groups[groups.length - 1];
if (last && last.offset === offset) last.activities.push(activity);
else groups.push({ offset, activities: [activity] });
}
const parts = [];
let cursor = 0;
for (const group of groups) {
if (group.offset > cursor) {
parts.push({ type: 'text', content: cleanedContent.substring(cursor, group.offset) });
}
parts.push({ type: 'tools', activities: group.activities });
cursor = Math.max(cursor, group.offset);
}
if (cursor < cleanedContent.length) {
parts.push({ type: 'text', content: cleanedContent.substring(cursor) });
}
return parts;
};
const formatRelativeTime = (timestamp) => {
if (!timestamp) return '';
const diff = Date.now() - timestamp;
const minute = 60 * 1000;
const hour = 60 * minute;
const day = 24 * hour;
if (diff < minute) return 'just now';
if (diff < hour) return `${Math.floor(diff / minute)}m ago`;
if (diff < day) return `${Math.floor(diff / hour)}h ago`;
if (diff < 7 * day) return `${Math.floor(diff / day)}d ago`;
return new Date(timestamp).toLocaleDateString();
};
const HistoryPopover = ({ items, activeId, onPick, onDelete, onClose }) => {
const popoverRef = useRef(null);
useEffect(() => {
const handleClick = (e) => {
if (popoverRef.current && !popoverRef.current.contains(e.target)) {
onClose();
}
};
const handleKey = (e) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('mousedown', handleClick);
document.addEventListener('keydown', handleKey);
return () => {
document.removeEventListener('mousedown', handleClick);
document.removeEventListener('keydown', handleKey);
};
}, [onClose]);
return (
<div className="history-popover" ref={popoverRef} role="menu">
{items.length === 0 ? (
<div className="history-popover__empty">No past conversations</div>
) : (
items.map((item) => (
<div
key={item.id}
className={`history-popover__item ${item.id === activeId ? 'is-active' : ''}`}
role="menuitem"
>
<button className="history-popover__title" onClick={() => onPick(item.id)} title={item.title}>
<span className="history-popover__title-text">{item.title || '(untitled)'}</span>
<span className="history-popover__meta">{formatRelativeTime(item.updatedAt)}</span>
</button>
<button
className="history-popover__delete"
onClick={(e) => {
e.stopPropagation(); onDelete(item.id);
}}
title="Delete conversation"
aria-label="Delete conversation"
>
<IconTrash size={12} />
</button>
</div>
))
)}
</div>
);
};
const AiChatSidebar = ({ collection }) => {
const dispatch = useDispatch();
const [input, setInput] = useState('');
const [processingStage, setProcessingStage] = useState(null);
const [availableModels, setAvailableModels] = useState([]);
const [selectedModel, setSelectedModel] = useState(() => {
try { return localStorage.getItem(SELECTED_MODEL_LS_KEY) ?? AUTO_MODEL_ID; } catch { return AUTO_MODEL_ID; }
});
const [historyOpen, setHistoryOpen] = useState(false);
const messagesEndRef = useRef(null);
const messagesContainerRef = useRef(null);
const isNearBottomRef = useRef(true);
const textareaRef = useRef(null);
const isOpen = useSelector((state) => state.chat.isOpen);
const allChats = useSelector((state) => state.chat.chats);
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const preferences = useSelector((state) => state.app.preferences);
const aiEnabled = get(preferences, 'ai.enabled', false);
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
const activeItem = focusedTab && collection ? findItemInCollection(collection, activeTabUid) : null;
const aiContext = useMemo(() => {
if (!focusedTab || !collection) return null;
if (activeItem && (isItemARequest(activeItem) || activeItem.type === 'app')) {
return { kind: 'request', item: activeItem, pathname: activeItem.pathname || '', name: activeItem.name || 'Untitled' };
}
if (activeItem && isItemAFolder(activeItem)) {
return { kind: 'folder', folder: activeItem, pathname: activeItem.pathname || '', name: activeItem.name || 'Untitled' };
}
// Anything else (collection-settings, runner, variables, openapi-sync,
// .js files in File Mode …) falls back to the collection root so the AI
// button always opens a useful chat instead of a no-op.
return { kind: 'collection', pathname: collection.pathname || '', name: collection.name || 'Untitled Collection' };
}, [focusedTab, collection, activeItem]);
const currentChat = allChats[activeTabUid] || { messages: [], isLoading: false, error: null, historyList: [] };
const { messages, isLoading, error, historyList, conversationId } = currentChat;
useEffect(() => {
if (!isOpen || !aiEnabled) return;
let cancelled = false;
getAiStatus()
.then((status) => {
if (cancelled) return;
setAvailableModels(status?.availableModels || []);
})
.catch(() => {
if (!cancelled) setAvailableModels([]);
});
return () => { cancelled = true; };
}, [isOpen, aiEnabled, preferences?.ai]);
// Auto = empty string. We don't auto-correct to the first model — let the
// backend pick, so users get smart defaults that adapt as providers change.
useEffect(() => {
if (selectedModel === AUTO_MODEL_ID) return;
if (availableModels.length === 0) return;
if (availableModels.some((m) => m.id === selectedModel)) return;
setSelectedModel(AUTO_MODEL_ID);
try { localStorage.setItem(SELECTED_MODEL_LS_KEY, AUTO_MODEL_ID); } catch {}
}, [availableModels, selectedModel]);
const requestName = aiContext?.name || activeItem?.name || 'Untitled';
const requestMethod = useMemo(() => {
if (aiContext?.kind === 'folder') return 'FOLDER';
if (aiContext?.kind === 'collection') return 'ROOT';
if (!activeItem) return 'GET';
if (activeItem.type === 'grpc-request') return 'GRPC';
if (activeItem.type === 'ws-request') return 'WS';
if (activeItem.type === 'graphql-request') return 'GQL';
if (activeItem.type === 'app') return 'APP';
const appOn = activeItem.draft
? get(activeItem, 'draft.app.enabled', false)
: get(activeItem, 'app.enabled', false);
if (appOn) return 'APP';
return activeItem.draft
? get(activeItem, 'draft.request.method', 'GET')
: get(activeItem, 'request.method', 'GET');
}, [aiContext?.kind, activeItem]);
// contentType drives the AI prompt, the diff target, and which entry of
// allContent the backend treats as "active". For requests it follows the
// request-pane tab. For folders / collections we read the settings sub-tab
// (and the inner pre/post script split for the Script sub-tab).
const requestPaneTab = focusedTab?.requestPaneTab;
const scriptPaneTab = focusedTab?.scriptPaneTab;
const contentType = useMemo(() => {
if (aiContext?.kind === 'folder') {
const sub = collection?.folderLevelSettingsSelectedTab?.[aiContext.folder.uid];
if (sub === 'test') return 'tests';
if (sub === 'docs') return 'docs';
if (sub === 'script') return scriptPaneTab === 'post-response' ? 'post-response' : 'pre-request';
return 'pre-request';
}
if (aiContext?.kind === 'collection') {
const sub = collection?.settingsSelectedTab;
if (sub === 'tests') return 'tests';
if (sub === 'overview') return 'docs';
if (sub === 'script') return scriptPaneTab === 'post-response' ? 'post-response' : 'pre-request';
return 'pre-request';
}
switch (requestPaneTab) {
case 'tests': return 'tests';
case 'script': return scriptPaneTab === 'post-response' ? 'post-response' : 'pre-request';
case 'docs': return 'docs';
default: return 'app';
}
}, [aiContext, collection?.folderLevelSettingsSelectedTab, collection?.settingsSelectedTab, requestPaneTab, scriptPaneTab]);
// Bind the chat to the active context's pathname so the history list
// reflects this specific request/folder/collection and persistence keys stay
// stable across sessions. Restoring the most recent conversation happens
// once per tab — if the user explicitly starts a new chat, we don't
// auto-replace it.
const restoredOnceRef = useRef({});
useEffect(() => {
if (!isOpen || !aiContext || !collection) return;
dispatch(setChatBinding({
tabUid: activeTabUid,
pathname: aiContext.pathname,
collectionUid: collection.uid,
contentType
}));
dispatch(refreshChatHistory(activeTabUid));
}, [isOpen, aiContext?.pathname, collection?.uid, activeTabUid, contentType, dispatch]);
// First-open restore: if this tab has no conversation yet and there's a
// saved one for the same file, load the most recent.
useEffect(() => {
if (!isOpen || !activeTabUid) return;
if (restoredOnceRef.current[activeTabUid]) return;
if (currentChat.conversationId) return;
if (currentChat.messages?.length > 0) return;
if (!historyList || historyList.length === 0) return;
restoredOnceRef.current[activeTabUid] = true;
dispatch(openConversation(activeTabUid, historyList[0].id));
}, [isOpen, activeTabUid, currentChat.conversationId, currentChat.messages?.length, historyList, dispatch]);
const allContent = useMemo(() => {
if (!aiContext) return {};
if (aiContext.kind === 'request') {
const item = aiContext.item;
const draft = item.draft;
const draftAppCode = get(item, 'draft.app.code');
return {
'app': draftAppCode != null ? draftAppCode : get(item, 'app.code', ''),
'tests': draft ? get(draft, 'request.tests', '') : get(item, 'request.tests', ''),
'pre-request': draft ? get(draft, 'request.script.req', '') : get(item, 'request.script.req', ''),
'post-response': draft ? get(draft, 'request.script.res', '') : get(item, 'request.script.res', ''),
'docs': draft ? get(draft, 'request.docs', '') : get(item, 'request.docs', '')
};
}
if (aiContext.kind === 'folder') {
const folder = aiContext.folder;
const root = folder.draft || folder.root || {};
return {
'tests': get(root, 'request.tests', ''),
'pre-request': get(root, 'request.script.req', ''),
'post-response': get(root, 'request.script.res', ''),
'docs': get(root, 'docs', '')
};
}
// collection
const root = collection?.draft?.root || collection?.root || {};
return {
'tests': get(root, 'request.tests', ''),
'pre-request': get(root, 'request.script.req', ''),
'post-response': get(root, 'request.script.res', ''),
'docs': get(root, 'docs', '')
};
}, [aiContext, collection?.draft?.root, collection?.root]);
const currentContent = allContent[contentType] || '';
// requestContext (URL/method/headers/response shape) only makes sense for
// HTTP-style request items. Folder, collection, and App chats skip it —
// App items live under kind: 'request' but have no URL/method to surface.
const requestContext = useMemo(() => {
if (aiContext?.kind !== 'request' || !isItemARequest(aiContext.item)) return null;
const item = aiContext.item;
const draft = item.draft;
return {
url: draft ? get(item, 'draft.request.url', '') : get(item, 'request.url', ''),
method: draft ? get(item, 'draft.request.method', '') : get(item, 'request.method', ''),
headers: draft ? get(item, 'draft.request.headers', []) : get(item, 'request.headers', []),
params: draft ? get(item, 'draft.request.params', []) : get(item, 'request.params', []),
body: draft ? get(item, 'draft.request.body', null) : get(item, 'request.body', null),
docs: draft ? get(item, 'draft.request.docs', null) : get(item, 'request.docs', null),
responseStatus: get(item, 'response.status', null),
responseData: get(item, 'response.data', null)
};
}, [aiContext]);
// Variables payload is collection-scoped — works for request, folder, and
// collection chats alike. Each entry is { name, value, scope, secret }; the
// model gets a name-only preview in the prompt and can call search_variables
// to fetch values (secrets come back redacted).
const aiVariables = useMemo(() => {
if (aiContext?.kind === 'request') return buildAiVariablesPayload(collection, aiContext.item);
if (aiContext?.kind === 'folder') return buildAiVariablesPayload(collection, aiContext.folder);
return buildAiVariablesPayload(collection, null);
}, [collection, aiContext]);
const chatsWithMessages = useMemo(() => {
if (!collection) return [];
return Object.entries(allChats)
.filter(([, chat]) => chat.messages?.length > 0)
.map(([tabUid, chat]) => {
if (tabUid === collection.uid) {
return { id: tabUid, name: collection.name || 'Untitled Collection', method: 'ROOT', messageCount: chat.messages.length };
}
const item = findItemInCollection(collection, tabUid);
if (!item) return null;
if (isItemAFolder(item)) {
return { id: tabUid, name: item.name || 'Untitled', method: 'FOLDER', messageCount: chat.messages.length };
}
const method = item.draft
? get(item, 'draft.request.method', 'GET')
: get(item, 'request.method', 'GET');
return {
id: tabUid,
name: item.name || 'Untitled',
method,
messageCount: chat.messages.length
};
})
.filter(Boolean);
}, [allChats, collection]);
const scrollToBottom = useCallback((behavior = 'smooth') => {
messagesEndRef.current?.scrollIntoView({ behavior });
}, []);
const handleMessagesScroll = useCallback(() => {
const el = messagesContainerRef.current;
if (!el) return;
isNearBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
}, []);
useEffect(() => {
if (!isNearBottomRef.current) return;
const behavior = messages.some((m) => m.isStreaming) ? 'auto' : 'smooth';
scrollToBottom(behavior);
}, [messages, scrollToBottom]);
useEffect(() => {
if (isOpen) textareaRef.current?.focus();
}, [isOpen]);
useEffect(() => {
if (!isLoading) {
setProcessingStage(null);
return;
}
const last = messages[messages.length - 1];
if (last?.isStreaming && last.content) setProcessingStage('generating');
else if (last?.isStreaming) setProcessingStage('thinking');
else setProcessingStage('sending');
}, [isLoading, messages]);
const handleTextareaChange = (e) => {
setInput(e.target.value);
const el = textareaRef.current;
if (el) {
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 150) + 'px';
}
};
const handleSubmit = async (e) => {
e?.preventDefault();
if (!input.trim() || isLoading || availableModels.length === 0) return;
const text = input.trim();
setInput('');
setProcessingStage('sending');
if (textareaRef.current) textareaRef.current.style.height = 'auto';
try {
await dispatch(sendAiMessage(activeTabUid, text, allContent, requestContext, selectedModel, contentType, aiVariables));
setProcessingStage('applying');
setTimeout(() => setProcessingStage(null), 500);
} catch (err) {
console.error('Failed to send AI message:', err);
setProcessingStage(null);
}
};
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
const handleStop = () => {
dispatch(stopAiStream(activeTabUid));
setProcessingStage(null);
};
const handleApplyCode = (code, originalCode, messageIndex, msgContentType, writeIndex) => {
if (!aiContext || code == null) return;
const targetType = msgContentType || contentType;
// Bail if the live buffer has drifted from what the AI based the diff on.
// The DiffView already disables the button in this case, but guarding here
// too means the keyboard / programmatic path can't blow away local edits.
const liveContent = allContent[targetType] || '';
if (originalCode != null && liveContent !== originalCode) {
return;
}
if (aiContext.kind === 'request') {
const payload = { itemUid: aiContext.item.uid, collectionUid: collection.uid };
switch (targetType) {
case 'tests': dispatch(updateRequestTests({ ...payload, tests: code })); break;
case 'pre-request': dispatch(updateRequestScript({ ...payload, script: code })); break;
case 'post-response': dispatch(updateResponseScript({ ...payload, script: code })); break;
case 'docs': dispatch(updateRequestDocs({ ...payload, docs: code })); break;
default: dispatch(updateAppCode({ ...payload, code })); break;
}
} else if (aiContext.kind === 'folder') {
const payload = { folderUid: aiContext.folder.uid, collectionUid: collection.uid };
switch (targetType) {
case 'tests': dispatch(updateFolderTests({ ...payload, tests: code })); break;
case 'pre-request': dispatch(updateFolderRequestScript({ ...payload, script: code })); break;
case 'post-response': dispatch(updateFolderResponseScript({ ...payload, script: code })); break;
case 'docs': dispatch(updateFolderDocs({ ...payload, docs: code })); break;
// Folders / collections have no 'app' equivalent. Bail rather than
// marking the diff accepted when nothing was dispatched.
default: return;
}
} else {
const payload = { collectionUid: collection.uid };
switch (targetType) {
case 'tests': dispatch(updateCollectionTests({ ...payload, tests: code })); break;
case 'pre-request': dispatch(updateCollectionRequestScript({ ...payload, script: code })); break;
case 'post-response': dispatch(updateCollectionResponseScript({ ...payload, script: code })); break;
case 'docs': dispatch(updateCollectionDocs({ ...payload, docs: code })); break;
default: return;
}
}
dispatch(setMessageCodeStatus({
tabUid: activeTabUid,
messageIndex,
status: 'accepted',
writeIndex
}));
};
const handleRejectCode = (messageIndex, writeIndex) => {
dispatch(setMessageCodeStatus({
tabUid: activeTabUid,
messageIndex,
status: 'rejected',
writeIndex
}));
};
const handleNewChat = () => {
setHistoryOpen(false);
restoredOnceRef.current[activeTabUid] = true; // suppress restore
dispatch(startNewConversation({ tabUid: activeTabUid, contentType }));
textareaRef.current?.focus();
};
const handlePickConversation = (id) => {
setHistoryOpen(false);
restoredOnceRef.current[activeTabUid] = true;
dispatch(openConversation(activeTabUid, id));
};
const handleDeleteConversation = (id) => {
dispatch(removeConversation(activeTabUid, id));
};
const handleClose = () => dispatch(closeAiSidebar());
const handleSwitchChat = (tabUid) => dispatch(focusTab({ uid: tabUid }));
const handleSuggestionClick = (suggestion) => {
setInput(suggestion);
textareaRef.current?.focus();
};
const handleModelSelect = (modelId) => {
setSelectedModel(modelId);
try { localStorage.setItem(SELECTED_MODEL_LS_KEY, modelId); } catch {}
};
const selectedModelLabel = useMemo(() => {
if (selectedModel === AUTO_MODEL_ID) return 'Auto';
return availableModels.find((m) => m.id === selectedModel)?.label || 'Auto';
}, [availableModels, selectedModel]);
const ModelSelectorTrigger = forwardRef((props, ref) => (
<div ref={ref} className="model-btn" {...props}>
<IconStars size={14} strokeWidth={1.75} />
<span>{selectedModelLabel}</span>
<IconChevronDown size={12} />
</div>
));
ModelSelectorTrigger.displayName = 'ModelSelectorTrigger';
const modelMenuItems = useMemo(
() => [
{ id: AUTO_MODEL_ID, label: 'Auto', onClick: () => handleModelSelect(AUTO_MODEL_ID) },
...availableModels.map((model) => ({
id: model.id,
label: model.label,
onClick: () => handleModelSelect(model.id)
}))
],
[availableModels]
);
const hasActiveStream = messages.some((m) => m.isStreaming);
const renderProcessingIndicator = () => {
if (!processingStage || processingStage === 'thinking' || hasActiveStream) return null;
const stage = PROCESSING_STAGES.find((s) => s.id === processingStage) || PROCESSING_STAGES[0];
return (
<div className="processing-indicator">
<div className="processing-content">
<div className="processing-icon">
{stage.icon === 'sparkles' && <IconStars size={12} />}
{stage.icon === 'wand' && <IconWand size={12} />}
{stage.icon === 'code' && <IconCode size={12} />}
{stage.icon === 'send' && <IconCornerDownLeft size={12} />}
</div>
<span className="processing-label">{stage.label}</span>
<div className="processing-dots"><span></span><span></span><span></span></div>
</div>
<div className="processing-bar"><div className="processing-bar-fill"></div></div>
</div>
);
};
const renderMessage = (msg, index) => {
const isUser = msg.role === 'user';
const isStreaming = msg.isStreaming;
const activities = msg.toolActivity || [];
const hasPendingTool = activities.some((a) => !a.done);
const content = msg.content || '';
const showThinking = isStreaming && !content && activities.length === 0;
const showWorking = isStreaming && activities.length > 0 && !hasPendingTool;
const timeline = buildMessageTimeline(content, activities);
return (
<div key={index} className={`message ${msg.role} ${isStreaming ? 'streaming' : ''}`}>
<div className="message-content">
{isUser ? content : (
<>
{showThinking && (
<div className="message-status">
<span className="message-status__spinner" />
<span>Thinking</span>
</div>
)}
{timeline.map((part, partIndex) => {
if (part.type === 'tools') {
return <ToolActivityGroup key={`tools-${partIndex}`} activities={part.activities} />;
}
const segments = parseMessageSegments(part.content);
const isLastTextPart = !timeline.slice(partIndex + 1).some((p) => p.type === 'text');
return (
<React.Fragment key={`text-${partIndex}`}>
{segments.map((segment, segIndex) => {
const isLastSegment = isLastTextPart && segIndex === segments.length - 1;
if (segment.type === 'code') {
return (
<AssistantCodeBlock
key={`p${partIndex}-s${segIndex}`}
content={segment.content}
language={segment.language}
isOpen={segment.isOpen}
isStreaming={isStreaming}
isLast={isLastSegment}
/>
);
}
return (
<div key={`p${partIndex}-s${segIndex}`} className="prose markdown-body">
<div dangerouslySetInnerHTML={{ __html: renderMarkdown(segment.content) }} />
{isStreaming && isLastSegment && <span className="cursor">|</span>}
</div>
);
})}
</React.Fragment>
);
})}
{showWorking && (
<div className="message-status">
<span className="message-status__spinner" />
<span>Working</span>
</div>
)}
{!isStreaming && msg.writes?.length > 0 && msg.writes.map((write, writeIdx) => {
if (write.content === write.originalContent) return null;
const liveContent = allContent[write.type] || '';
const isStale = liveContent !== write.originalContent;
const notRead = !write.wasRead;
return (
<DiffView
key={`write-${writeIdx}`}
originalCode={write.originalContent || ''}
newCode={write.content}
contentTypeLabel={CONTENT_TYPE_LABELS[write.type] || write.type}
warning={
notRead ? 'Content was not read first — changes may overwrite unrelated edits'
: isStale ? 'Content has been modified since AI read it'
: null
}
disableAccept={isStale || notRead}
onAccept={() => handleApplyCode(write.content, write.originalContent, index, write.type, writeIdx)}
onReject={() => handleRejectCode(index, writeIdx)}
status={write.status}
/>
);
})}
{!isStreaming && !msg.writes && msg.code && msg.originalCode && msg.code !== msg.originalCode && (
<DiffView
originalCode={msg.originalCode || ''}
newCode={msg.code}
onAccept={() => handleApplyCode(msg.code, msg.originalCode, index, msg.contentType)}
onReject={() => handleRejectCode(index)}
status={msg.codeStatus}
/>
)}
{!isStreaming && msg.cancelled && (
<div className="message-cancelled"><em>Cancelled</em></div>
)}
</>
)}
</div>
</div>
);
};
const renderEmptyState = () => {
const suggestions = SUGGESTIONS_BY_TYPE[contentType] || SUGGESTIONS_BY_TYPE.app;
return (
<div className="empty-state">
<div className="empty-icon"><IconStars size={20} /></div>
<h3>AI Assistant</h3>
<p>Ask me to generate or modify code, tests, scripts, and docs.</p>
<div className="suggestions">
<p className="suggestions-title">Try asking:</p>
<div className="suggestion-chips">
{suggestions.map((s, i) => (
<button key={i} className="suggestion-chip" onClick={() => handleSuggestionClick(s.prompt)}>
{s.label}
</button>
))}
</div>
</div>
</div>
);
};
if (!isOpen) return null;
if (!aiContext) return null;
const placeholders = PLACEHOLDER_BY_TYPE[contentType] || PLACEHOLDER_BY_TYPE.app;
const placeholder = currentContent ? placeholders.filled : placeholders.empty;
const historyCount = historyList?.length || 0;
return (
<StyledWrapper>
<div className="ai-sidebar">
<div className="ai-sidebar-header">
<div className="header-left">
<IconStars size={18} className="header-icon" />
<span className={`header-method method-${(requestMethod || 'get').toLowerCase()}`}>{requestMethod}</span>
<span className="header-title">{requestName}</span>
{chatsWithMessages.length > 1 && (
<MenuDropdown
items={chatsWithMessages.map((chat) => ({
id: chat.id,
label: `${chat.method} · ${chat.name}`,
onClick: () => handleSwitchChat(chat.id)
}))}
placement="bottom-start"
selectedItemId={activeTabUid}
>
<button className="chat-switcher-btn" title="Switch chat">
<IconChevronDown size={14} />
</button>
</MenuDropdown>
)}
</div>
<div className="header-actions">
<button
className="icon-btn"
onClick={handleNewChat}
title="New chat"
disabled={isLoading || messages.length === 0}
>
<IconPlus size={14} />
</button>
<div className="history-wrap">
<button
className={`icon-btn ${historyOpen ? 'is-active' : ''}`}
onClick={() => setHistoryOpen((v) => !v)}
title="History"
disabled={historyCount === 0}
>
<IconHistory size={14} />
</button>
{historyOpen && (
<HistoryPopover
items={historyList || []}
activeId={conversationId}
onPick={handlePickConversation}
onDelete={handleDeleteConversation}
onClose={() => setHistoryOpen(false)}
/>
)}
</div>
<button className="icon-btn close-btn" onClick={handleClose} title="Close">
<IconX size={14} />
</button>
</div>
</div>
<div className="ai-sidebar-messages" ref={messagesContainerRef} onScroll={handleMessagesScroll}>
{messages.length === 0 ? renderEmptyState() : (
<>
{messages.map(renderMessage)}
{renderProcessingIndicator()}
</>
)}
{error && (
<div className="error-message">
<div className="error-icon">!</div>
<div className="error-text">{error}</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
<div className="ai-sidebar-input">
{availableModels.length === 0 ? (
<div className="no-models-warning">
No AI models available. Configure a provider and enable models in Preferences &gt; AI.
</div>
) : (
<div className="input-container">
<textarea
ref={textareaRef}
value={input}
onChange={handleTextareaChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={isLoading}
rows={1}
/>
<div className="input-actions">
<div className="model-selector">
<MenuDropdown items={modelMenuItems} placement="top-start" selectedItemId={selectedModel}>
<ModelSelectorTrigger />
</MenuDropdown>
</div>
{isLoading ? (
<button className="stop-btn" onClick={handleStop} title="Stop generating">
<IconPlayerStop size={12} /> Stop
</button>
) : (
<button
className="send-btn"
onClick={handleSubmit}
title="Send (Enter)"
disabled={!input.trim()}
>
Send <IconCornerDownLeft size={12} />
</button>
)}
</div>
</div>
)}
</div>
</div>
</StyledWrapper>
);
};
export default AiChatSidebar;

View File

@@ -1,63 +0,0 @@
import MarkdownIt from 'markdown-it';
const SAFE_LANG = /^[a-z0-9_+#.-]+$/i;
const safeLanguage = (lang) => (lang && SAFE_LANG.test(lang) ? lang : 'text');
const md = new MarkdownIt({
html: false,
breaks: true,
linkify: true,
highlight: (str, lang) =>
`<pre class="code-block"><code class="language-${safeLanguage(lang)}">${md.utils.escapeHtml(str)}</code></pre>`
});
export const renderMarkdown = (content) => md.render(content || '');
export const parseMessageSegments = (content = '') => {
if (!content) return [];
const segments = [];
let cursor = 0;
let inCode = false;
let language = '';
while (cursor <= content.length) {
const fenceIndex = content.indexOf('```', cursor);
if (fenceIndex === -1) {
const chunk = content.slice(cursor);
if (inCode || chunk) {
segments.push({
type: inCode ? 'code' : 'text',
content: chunk,
language,
isOpen: inCode
});
}
break;
}
if (!inCode) {
const textChunk = content.slice(cursor, fenceIndex);
if (textChunk) {
segments.push({ type: 'text', content: textChunk });
}
const fenceEnd = fenceIndex + 3;
const lineEnd = content.indexOf('\n', fenceEnd);
language = (lineEnd === -1 ? content.slice(fenceEnd) : content.slice(fenceEnd, lineEnd)).trim();
inCode = true;
cursor = lineEnd === -1 ? content.length : lineEnd + 1;
} else {
const codeChunk = content.slice(cursor, fenceIndex);
if (codeChunk.trim()) {
segments.push({ type: 'code', content: codeChunk, language, isOpen: false });
}
inCode = false;
language = '';
cursor = fenceIndex + 3;
if (content[cursor] === '\n') cursor += 1;
}
}
return segments.filter((seg) => seg.content && seg.content.trim());
};

View File

@@ -683,6 +683,8 @@ const StyledWrapper = styled.div`
.copy-to-clipboard {
button {
background: ${(props) => props.theme.background.mantle};
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: 3px;
}
}

View File

@@ -146,8 +146,6 @@ const AppTitleBar = () => {
};
const handleWorkspaceSwitch = (workspaceUid) => {
if (workspaceUid === activeWorkspaceUid) return;
dispatch(switchWorkspace(workspaceUid));
toast.success(`Switched to ${getWorkspaceDisplayName(workspaces.find((w) => w.uid === workspaceUid)?.name)}`);
};

View File

@@ -1,48 +0,0 @@
import React from 'react';
import styled from 'styled-components';
import { IconAppWindow } from '@tabler/icons';
const Wrapper = styled.div`
flex: 1 1 0;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed ${(props) => props.theme.border.border1};
border-radius: 4px;
background: ${(props) => props.theme.background.surface0};
color: ${(props) => props.theme.colors.text.muted};
.empty-app-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 2rem;
text-align: center;
max-width: 360px;
}
.empty-app-title {
font-size: 13px;
font-weight: 500;
color: ${(props) => props.theme.text};
}
.empty-app-hint {
font-size: 12px;
line-height: 1.4;
}
`;
const EmptyAppState = ({ title = 'No app yet', hint }) => (
<Wrapper data-testid="empty-app-state">
<div className="empty-app-inner">
<IconAppWindow size={32} strokeWidth={1.25} />
<div className="empty-app-title">{title}</div>
{hint ? <div className="empty-app-hint">{hint}</div> : null}
</div>
</Wrapper>
);
export default EmptyAppState;

View File

@@ -1,52 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
flex-grow: 1;
padding: 0.5rem;
.app-view-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 0.25rem 0.4rem;
font-size: 11px;
color: ${(props) => props.theme.colors.text.muted};
}
.app-view-toolbar .app-exit-btn {
cursor: pointer;
padding: 2px 8px;
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: 3px;
background: transparent;
color: ${(props) => props.theme.colors.text.muted};
&:hover {
color: ${(props) => props.theme.text};
border-color: ${(props) => props.theme.text};
}
}
.app-webview-container {
flex: 1 1 0;
min-height: 0;
display: flex;
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: 4px;
overflow: hidden;
background: ${(props) => props.theme.background.surface0};
}
.app-webview {
width: 100%;
height: 100%;
flex: 1 1 0;
border: 0;
}
`;
export default StyledWrapper;

View File

@@ -1,307 +0,0 @@
import React, { useRef, useEffect, useCallback, useMemo } from 'react';
import cloneDeep from 'lodash/cloneDeep';
import { useDispatch } from 'react-redux';
import { sendNetworkRequest } from 'utils/network/index';
import {
findEnvironmentInCollection,
getEnvironmentVariables,
getGlobalEnvironmentVariables
} from 'utils/collections';
import {
responseReceived,
appSetRuntimeVariable,
toggleAppMode,
initRunRequestEvent
} from 'providers/ReduxStore/slices/collections';
import { uuid } from 'utils/common';
import { useTheme } from 'providers/Theme';
import StyledWrapper from './StyledWrapper';
import EmptyAppState from './EmptyAppState';
import {
SENTINEL,
wrapHtml,
toDataUrl,
serializeTimeline,
projectResponse,
useAppWebview
} from './webview-bridge';
// Request-level ctx bootstrap. Injected into the guest so window.ctx exists
// before user scripts run.
const REQUEST_CTX_BOOTSTRAP = `<script>
(function () {
if (window.__brunoBootstrapped) return;
window.__brunoBootstrapped = true;
var SENTINEL = ${JSON.stringify(SENTINEL)};
var pending = new Map();
var nextRequestId = 0;
function sendToHost(payload) {
try { console.log(SENTINEL + JSON.stringify(payload)); } catch (e) {}
}
var ctx = {
theme: 'light',
response: null,
assertionResults: [],
testResults: [],
variables: {},
onThemeChange: null,
onResponseUpdate: null,
onResultsUpdate: null,
onVariablesUpdate: null,
sendRequest: function (overrides) {
return new Promise(function (resolve, reject) {
var requestId = ++nextRequestId;
pending.set(requestId, { resolve: resolve, reject: reject });
sendToHost({ type: 'sendRequest', requestId: requestId, overrides: overrides || {} });
});
},
setRuntimeVariable: function (key, value) {
sendToHost({ type: 'setRuntimeVariable', key: String(key), value: value });
},
log: function () {
var args = Array.prototype.slice.call(arguments);
sendToHost({ type: 'log', args: args });
}
};
window.ctx = ctx;
function applyTheme(theme) {
ctx.theme = theme || 'light';
if (document.body) {
document.body.classList.remove('light', 'dark');
document.body.classList.add(ctx.theme);
}
}
window.__brunoReceive = function (msg) {
if (!msg) return;
switch (msg.type) {
case 'state':
applyTheme(msg.theme);
ctx.response = msg.response || null;
ctx.assertionResults = msg.assertionResults || [];
ctx.testResults = msg.testResults || [];
ctx.variables = msg.variables || {};
break;
case 'theme':
applyTheme(msg.theme);
if (typeof ctx.onThemeChange === 'function') ctx.onThemeChange(ctx.theme);
break;
case 'responseUpdate':
ctx.response = msg.response || null;
if (typeof ctx.onResponseUpdate === 'function') ctx.onResponseUpdate(ctx.response);
break;
case 'results':
ctx.assertionResults = msg.assertionResults || [];
ctx.testResults = msg.testResults || [];
if (typeof ctx.onResultsUpdate === 'function') {
ctx.onResultsUpdate({ assertionResults: ctx.assertionResults, testResults: ctx.testResults });
}
break;
case 'variables':
ctx.variables = msg.variables || {};
if (typeof ctx.onVariablesUpdate === 'function') ctx.onVariablesUpdate(ctx.variables);
break;
case 'response': {
var entry = pending.get(msg.requestId);
if (!entry) return;
pending.delete(msg.requestId);
if (msg.error) entry.reject(new Error(msg.error));
else entry.resolve(msg.response);
break;
}
}
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () { sendToHost({ type: 'ready' }); });
} else {
sendToHost({ type: 'ready' });
}
})();
</script>`;
const buildVariables = (collection) => {
const env = getEnvironmentVariables(collection);
const global = getGlobalEnvironmentVariables({
globalEnvironments: collection?.globalEnvironments || [],
activeGlobalEnvironmentUid: collection?.activeGlobalEnvironmentUid
});
return {
...global,
...env,
...(collection?.collectionVariables || {}),
...(collection?.runtimeVariables || {})
};
};
const AppView = ({ item, collection, code }) => {
const dispatch = useDispatch();
const { displayedTheme } = useTheme();
const src = useMemo(() => toDataUrl(wrapHtml(REQUEST_CTX_BOOTSTRAP, code || '')), [code]);
const environment = useMemo(
() => findEnvironmentInCollection(collection, collection.activeEnvironmentUid),
[collection]
);
const variables = useMemo(() => buildVariables(collection), [collection]);
const response = useMemo(() => (item.response ? projectResponse(item.response) : null), [item.response]);
const assertionResults = useMemo(() => item.assertionResults || [], [item.assertionResults]);
const testResults = useMemo(() => item.testResults || [], [item.testResults]);
// pushToGuest is produced by useAppWebview, which itself needs handleGuestMessage —
// routing through a ref lets the callbacks call the *latest* pushToGuest without
// creating a circular useCallback dependency. Without this, the request-id reply
// (and error reply) close over the first-render no-op pushToGuest and the guest's
// ctx.sendRequest() promise never resolves.
const pushToGuestRef = useRef(() => {});
const handleSendRequest = useCallback(
async (requestId, overrides) => {
const push = pushToGuestRef.current;
try {
// Mint a requestUid and register the run so the main process emits its
// test/assertion/script events against an id the store recognises — this
// is what makes ctx.testResults / ctx.assertionResults populate.
const requestUid = uuid();
const requestItem = cloneDeep(item.draft || item);
requestItem.requestUid = requestUid;
dispatch(initRunRequestEvent({ requestUid, itemUid: item.uid, collectionUid: collection.uid }));
// Variable overrides: accept flat keys or { variables: {...} }.
const flatOverrides = overrides && typeof overrides === 'object' ? { ...overrides } : {};
const explicitVars = flatOverrides.variables;
delete flatOverrides.variables;
const mergedRuntime = {
...(collection.runtimeVariables || {}),
...flatOverrides,
...(explicitVars && typeof explicitVars === 'object' ? explicitVars : {})
};
const result = await sendNetworkRequest(requestItem, collection, environment, mergedRuntime);
// sendNetworkRequest resolves on network/request errors with `error` set —
// surface as a guest-side promise rejection rather than a fake success.
if (result?.error) {
const errorMessage = typeof result.error === 'string'
? result.error
: result.error?.message || 'Request failed';
push({ type: 'response', requestId, error: errorMessage });
return;
}
dispatch(
responseReceived({
itemUid: item.uid,
collectionUid: collection.uid,
response: {
status: result.status,
statusText: result.statusText,
headers: result.headers,
data: result.data,
dataBuffer: result.dataBuffer,
size: result.size,
duration: result.duration,
timeline: serializeTimeline(result.timeline)
}
})
);
push({ type: 'response', requestId, response: projectResponse(result) });
} catch (err) {
push({ type: 'response', requestId, error: err?.message || 'Request failed' });
}
},
[item, collection, environment, dispatch]
);
const handleGuestMessage = useCallback(
(data) => {
switch (data?.type) {
case 'ready':
break;
case 'sendRequest':
handleSendRequest(data.requestId, data.overrides);
break;
case 'setRuntimeVariable':
if (typeof data.key === 'string' && data.key.length) {
dispatch(appSetRuntimeVariable({ collectionUid: collection.uid, key: data.key, value: data.value }));
}
break;
case 'log':
console.log('[app]', ...(data.args || []));
break;
default:
break;
}
},
[handleSendRequest, dispatch, collection.uid]
);
const { domReady, pushToGuest, webviewRef } = useAppWebview(handleGuestMessage);
pushToGuestRef.current = pushToGuest;
// Push a full state snapshot on each readiness transition. Subsequent changes
// are handled by the granular effects below; using a ref avoids re-firing
// this effect (which would be a needless full re-broadcast).
const stateRef = useRef();
stateRef.current = { theme: displayedTheme, response, assertionResults, testResults, variables };
useEffect(() => {
if (!domReady) return;
pushToGuest({ type: 'state', ...stateRef.current });
}, [domReady, pushToGuest]);
useEffect(() => {
pushToGuest({ type: 'theme', theme: displayedTheme });
}, [displayedTheme, pushToGuest]);
useEffect(() => {
pushToGuest({ type: 'responseUpdate', response });
}, [response, pushToGuest]);
useEffect(() => {
pushToGuest({ type: 'results', assertionResults, testResults });
}, [assertionResults, testResults, pushToGuest]);
useEffect(() => {
pushToGuest({ type: 'variables', variables });
}, [variables, pushToGuest]);
const disableApp = useCallback(() => {
dispatch(toggleAppMode({ enabled: false, itemUid: item.uid, collectionUid: collection.uid }));
}, [dispatch, item.uid, collection.uid]);
return (
<StyledWrapper data-testid="app-view">
<div className="app-view-toolbar">
<span>App mode - {item.name}</span>
<button type="button" className="app-exit-btn" data-testid="app-exit-button" onClick={disableApp}>
Exit to editor
</button>
</div>
{code && code.trim().length ? (
<div className="app-webview-container">
<webview
ref={webviewRef}
src={src}
partition="persist:bruno-app-view"
webpreferences="disableDialogs=true, javascript=yes"
className="app-webview"
/>
</div>
) : (
<EmptyAppState
title="No app yet"
hint="Switch to the App tab on this request and write some HTML/JS to get started."
/>
)}
</StyledWrapper>
);
};
export default AppView;

View File

@@ -1,200 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
/*
* Shared transport for Bruno apps that run inside an Electron <webview>:
* host -> guest : webview.executeJavaScript(`window.__brunoReceive(<json>)`)
* guest -> host : console.log(SENTINEL + json), surfaced via 'console-message'
*
* Both the request-level AppView and the standalone CollectionApp use this — they
* differ only in the bootstrap script (which builds window.ctx) and the message
* handler the host registers.
*/
export const SENTINEL = '__BRUNO_APP_MSG__';
// JSON-encode for safe inlining into an executeJavaScript() string literal.
// U+2028/U+2029 are legal in JSON strings but illegal as raw JS source.
export const toJsArg = (value) =>
JSON.stringify(value === undefined ? null : value)
.replace(/</g, '\\u003c')
.replace(/[\u2028]/g, '\\u2028')
.replace(/[\u2029]/g, '\\u2029');
const FRAGMENT_STYLES = `<style>
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
background: #ffffff;
color: #1e1e1e;
transition: background-color 0.15s, color 0.15s;
}
body.dark { background: #1e1e1e; color: #e0e0e0; }
</style>`;
/**
* Wrap user code into a guest document, injecting the host-supplied bootstrap
* script as early as possible (right after <head>) so window.ctx exists before
* any user script runs. Full HTML documents have the bootstrap injected; bare
* fragments are placed inside a minimal shell.
*/
export const wrapHtml = (bootstrap, userCode) => {
const code = userCode || '';
const isFullDocument = /<html[\s>]/i.test(code) || /<!doctype/i.test(code);
if (isFullDocument) {
if (/<head[^>]*>/i.test(code)) {
return code.replace(/<head[^>]*>/i, (m) => `${m}${bootstrap}`);
}
if (/<body[^>]*>/i.test(code)) {
return code.replace(/<body[^>]*>/i, (m) => `${m}${bootstrap}`);
}
return `${bootstrap}${code}`;
}
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
${FRAGMENT_STYLES}
${bootstrap}
</head>
<body>
${code}
</body>
</html>`;
};
export const toDataUrl = (html) =>
`data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
export const serializeTimeline = (timeline) => {
if (!Array.isArray(timeline)) return timeline;
return timeline.map((entry) => ({
...entry,
timestamp: entry.timestamp instanceof Date ? entry.timestamp.getTime() : entry.timestamp
}));
};
export const projectResponse = (r) => ({
status: r?.status ?? null,
statusText: r?.statusText ?? null,
data: r?.data ?? null,
headers: r?.headers ?? null,
duration: r?.duration ?? null,
size: r?.size ?? null
});
/**
* useAppWebview — manages an Electron <webview> guest and provides a typed
* messaging channel back to the host.
*
* const { domReady, pushToGuest, webviewRef } = useAppWebview(handleGuestMessage);
* …
* <webview ref={webviewRef} src={…} … />
*
* `webviewRef` is a **callback ref** (not an object ref). React invokes it with
* the element on mount and with `null` on unmount, which is the only way to
* reliably re-attach listeners when the <webview> is unmounted and remounted —
* e.g. when CollectionApp's user toggles between Code and Preview views. An
* object-ref + useEffect approach would not re-fire on remount because the ref
* object's identity is stable across mounts.
*
* pushToGuest({…}) is a no-op until the guest's dom-ready fires (and after a
* reload, until it fires again). Safe to call eagerly from effects.
*/
export const useAppWebview = (onGuestMessage) => {
const [domReady, setDomReady] = useState(false);
// Latest DOM element (for pushToGuest) and latest message handler (so the
// listener captures fresh state without needing to be re-bound).
const webviewElRef = useRef(null);
const onGuestMessageRef = useRef(onGuestMessage);
onGuestMessageRef.current = onGuestMessage;
// Outgoing messages sent before the guest is ready are queued and flushed by
// the dom-ready effect below. This is critical for guest scripts that call
// promise-returning ctx APIs (e.g. ctx.listRequests) at parse time — the host
// receives the request via console-message before Electron's `dom-ready`
// fires, and without a queue the reply gets dropped and the promise never
// resolves.
const pendingOutbox = useRef([]);
const sendToWebview = (webview, msg) => {
try {
webview.executeJavaScript(
`window.__brunoReceive && window.__brunoReceive(${toJsArg(msg)})`
).catch(() => {});
} catch (_) {
/* webview not yet attached */
}
};
const pushToGuest = useCallback(
(msg) => {
const webview = webviewElRef.current;
if (!webview || !domReady) {
pendingOutbox.current.push(msg);
return;
}
sendToWebview(webview, msg);
},
[domReady]
);
// Flush whatever piled up while the guest was still loading.
useEffect(() => {
if (!domReady) return;
const webview = webviewElRef.current;
if (!webview) return;
const queue = pendingOutbox.current;
if (!queue.length) return;
pendingOutbox.current = [];
for (const msg of queue) sendToWebview(webview, msg);
}, [domReady]);
// Stable callback ref. We stash the per-element listener bag on the element
// itself so we can clean up exactly the right listeners on unmount or replace.
const webviewRef = useCallback((element) => {
const prev = webviewElRef.current;
if (prev && prev !== element) {
const h = prev.__brunoHandlers;
if (h) {
prev.removeEventListener('console-message', h.onConsoleMessage);
prev.removeEventListener('dom-ready', h.onDomReady);
prev.removeEventListener('did-start-loading', h.onStartLoading);
prev.__brunoHandlers = null;
}
}
// Queued messages belong to the prior guest; drop them on element replace.
pendingOutbox.current = [];
webviewElRef.current = element || null;
// dom-ready will fire fresh on the new element; until then pushToGuest no-ops.
setDomReady(false);
if (!element) return;
const onConsoleMessage = (e) => {
const text = e?.message;
if (typeof text !== 'string' || !text.startsWith(SENTINEL)) return;
try {
onGuestMessageRef.current(JSON.parse(text.slice(SENTINEL.length)));
} catch (_) {
/* not our message */
}
};
const onDomReady = () => setDomReady(true);
// A reload (code edit) tears down the guest; reset readiness so the next
// dom-ready can flip us back to true.
const onStartLoading = () => setDomReady(false);
element.__brunoHandlers = { onConsoleMessage, onDomReady, onStartLoading };
element.addEventListener('console-message', onConsoleMessage);
element.addEventListener('dom-ready', onDomReady);
element.addEventListener('did-start-loading', onStartLoading);
}, []);
return { domReady, pushToGuest, webviewRef };
};

View File

@@ -45,15 +45,6 @@ const StyledWrapper = styled.div`
text-decoration: underline;
}
.cm-ghost-text-ai {
opacity: 0.45;
color: ${(props) => props.theme.colors.text.muted};
font-style: italic;
pointer-events: none;
user-select: none;
white-space: pre;
}
/* Removes the glow outline around the folded json */
.CodeMirror-foldmarker {
text-shadow: none;

View File

@@ -6,12 +6,9 @@
*/
import React, { createRef } from 'react';
import { useSelector } from 'react-redux';
import { debounce, isEqual } from 'lodash';
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
import { setupAutoComplete, showRootHints } from 'utils/codemirror/autocomplete';
import { setupAiAutocomplete } from 'utils/codemirror/aiGhostText';
import { buildAutocompleteContext } from 'utils/ai';
import StyledWrapper from './StyledWrapper';
import * as jsonlint from '@prantlf/jsonlint';
import { JSHINT } from 'jshint';
@@ -263,24 +260,6 @@ class CodeEditor extends React.Component {
autoCompleteOptions
);
// AI ghost-text autocomplete (script editors only). Stays inert until
// the user has both enabled AI and configured a provider.
if (this.props.scriptType) {
this.aiAutocompleteCleanup = setupAiAutocomplete(editor, {
scriptType: this.props.scriptType,
isEnabled: () => {
const ai = this.props.aiPreferences;
return Boolean(ai?.enabled) && ai?.autocomplete?.enabled !== false;
},
getTriggerMode: () => this.props.aiPreferences?.autocomplete?.triggerMode || 'debounced',
getContext: () => buildAutocompleteContext({
item: this.props.item,
collection: this.props.collection,
scriptType: this.props.scriptType
})
});
}
setupLinkAware(editor);
// Setup lint error tooltip on line number hover
@@ -413,7 +392,6 @@ class CodeEditor extends React.Component {
});
}
this.aiAutocompleteCleanup?.();
this.editor?._destroyLinkAware?.();
this.editor.off('change', this._onEdit);
@@ -492,15 +470,7 @@ class CodeEditor extends React.Component {
const CodeEditorWithPersistenceScope = React.forwardRef((props, ref) => {
const persistenceScope = usePersistenceScope();
const aiPreferences = useSelector((state) => state.app.preferences?.ai);
return (
<CodeEditor
{...props}
persistenceScope={persistenceScope}
aiPreferences={aiPreferences}
ref={ref}
/>
);
return <CodeEditor {...props} persistenceScope={persistenceScope} ref={ref} />;
});
CodeEditorWithPersistenceScope.displayName = 'CodeEditor';

View File

@@ -1,84 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
padding: 0.5rem;
.app-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 0.25rem 0.5rem;
font-size: 11px;
color: ${(props) => props.theme.colors.text.muted};
}
.app-toolbar .view-toggle {
display: flex;
align-items: center;
height: 24px;
border: 1px solid ${(props) => props.theme.input.border};
border-radius: ${(props) => props.theme.border.radius.base};
overflow: hidden;
}
.app-toolbar .view-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0 10px;
height: 100%;
border: none;
border-right: 1px solid ${(props) => props.theme.input.border};
background: transparent;
color: ${(props) => props.theme.colors.text.muted};
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
font-size: 11px;
&:last-child { border-right: none; }
&:hover:not(.active) {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
color: ${(props) => props.theme.text};
}
&.active {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
color: ${(props) => props.theme.primary.text};
}
}
.app-pane {
flex: 1 1 0;
min-height: 0;
display: flex;
flex-direction: column;
}
.app-pane.code div.CodeMirror {
height: 100%;
}
.app-webview-container {
flex: 1 1 0;
min-height: 0;
display: flex;
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: 4px;
overflow: hidden;
background: ${(props) => props.theme.background.surface0};
}
.app-webview {
width: 100%;
height: 100%;
flex: 1 1 0;
border: 0;
}
`;
export default StyledWrapper;

View File

@@ -1,396 +0,0 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import classnames from 'classnames';
import cloneDeep from 'lodash/cloneDeep';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import { sendNetworkRequest } from 'utils/network/index';
import {
findEnvironmentInCollection,
findItemInCollectionByPathname,
flattenItems,
getEnvironmentVariables,
getGlobalEnvironmentVariables,
isItemARequest
} from 'utils/collections';
import { uuid } from 'utils/common';
import {
appSetRuntimeVariable,
initRunRequestEvent,
responseReceived,
updateAppCode
} from 'providers/ReduxStore/slices/collections';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { useTheme } from 'providers/Theme';
import CodeEditor from 'components/CodeEditor';
import AIAssist from 'components/AIAssist';
import { buildAiVariablesPayload, buildDocsContextFromCollection } from 'utils/ai';
import StyledWrapper from './StyledWrapper';
import EmptyAppState from '../AppView/EmptyAppState';
import {
SENTINEL,
wrapHtml,
toDataUrl,
serializeTimeline,
projectResponse,
useAppWebview
} from '../AppView/webview-bridge';
/*
* Standalone collection-/folder-level app — a file (.bru/.yml) of type 'app'
* that lives in the sidebar and opens as its own tab. The user toggles between
* Code (CodeEditor) and Preview (sandboxed <webview>); preview re-runs whenever
* the code prop changes.
*
* Collection ctx surface differs from the request-level AppView:
* shared: theme, log, variables, setRuntimeVariable, onThemeChange, onVariablesUpdate
* added: collection, listRequests(), runRequest(pathname, overrides?)
* dropped: sendRequest, response, assertionResults, testResults
* (and their on* hooks — they only make sense for one request)
*/
const COLLECTION_CTX_BOOTSTRAP = `<script>
(function () {
if (window.__brunoBootstrapped) return;
window.__brunoBootstrapped = true;
var SENTINEL = ${JSON.stringify(SENTINEL)};
var pending = new Map();
var nextReplyId = 0;
function sendToHost(payload) {
try { console.log(SENTINEL + JSON.stringify(payload)); } catch (e) {}
}
function awaitReply(type, extra) {
return new Promise(function (resolve, reject) {
var replyId = ++nextReplyId;
pending.set(replyId, { resolve: resolve, reject: reject });
sendToHost(Object.assign({ type: type, replyId: replyId }, extra || {}));
});
}
var ctx = {
theme: 'light',
variables: {},
collection: null,
onThemeChange: null,
onVariablesUpdate: null,
listRequests: function () {
return awaitReply('listRequests');
},
runRequest: function (pathname, overrides) {
return awaitReply('runRequest', { pathname: String(pathname || ''), overrides: overrides || {} });
},
setRuntimeVariable: function (key, value) {
sendToHost({ type: 'setRuntimeVariable', key: String(key), value: value });
},
log: function () {
var args = Array.prototype.slice.call(arguments);
sendToHost({ type: 'log', args: args });
}
};
window.ctx = ctx;
function applyTheme(theme) {
ctx.theme = theme || 'light';
if (document.body) {
document.body.classList.remove('light', 'dark');
document.body.classList.add(ctx.theme);
}
}
window.__brunoReceive = function (msg) {
if (!msg) return;
switch (msg.type) {
case 'state':
applyTheme(msg.theme);
ctx.variables = msg.variables || {};
ctx.collection = msg.collection || null;
break;
case 'theme':
applyTheme(msg.theme);
if (typeof ctx.onThemeChange === 'function') ctx.onThemeChange(ctx.theme);
break;
case 'variables':
ctx.variables = msg.variables || {};
if (typeof ctx.onVariablesUpdate === 'function') ctx.onVariablesUpdate(ctx.variables);
break;
case 'collection':
ctx.collection = msg.collection || null;
break;
case 'reply': {
var entry = pending.get(msg.replyId);
if (!entry) return;
pending.delete(msg.replyId);
if (msg.error) entry.reject(new Error(msg.error));
else entry.resolve(msg.result);
break;
}
}
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () { sendToHost({ type: 'ready' }); });
} else {
sendToHost({ type: 'ready' });
}
})();
</script>`;
const buildVariables = (collection) => {
const env = getEnvironmentVariables(collection);
const global = getGlobalEnvironmentVariables({
globalEnvironments: collection?.globalEnvironments || [],
activeGlobalEnvironmentUid: collection?.activeGlobalEnvironmentUid
});
return {
...global,
...env,
...(collection?.collectionVariables || {}),
...(collection?.runtimeVariables || {})
};
};
const listRequestSummaries = (collection) =>
flattenItems(collection?.items || [])
.filter(isItemARequest)
.map((it) => ({
uid: it.uid,
name: it.name,
pathname: it.pathname,
type: it.type,
method: it.request?.method || null,
url: it.request?.url || null
}));
const CollectionApp = ({ item, collection }) => {
const dispatch = useDispatch();
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const [view, setView] = useState('preview');
const code = item.draft ? get(item, 'draft.app.code', '') : get(item, 'app.code', '');
// Preview HTML is keyed on the *saved* code so typing doesn't reload the guest
// on every keystroke. The user toggles to Preview after saving to see updates.
const src = useMemo(
() => toDataUrl(wrapHtml(COLLECTION_CTX_BOOTSTRAP, code || '')),
[code]
);
const environment = useMemo(
() => findEnvironmentInCollection(collection, collection.activeEnvironmentUid),
[collection]
);
const variables = useMemo(() => buildVariables(collection), [collection]);
const collectionInfo = useMemo(
() => ({ name: collection?.name || null, pathname: collection?.pathname || null }),
[collection?.name, collection?.pathname]
);
const docsContext = useMemo(() => buildDocsContextFromCollection(collection), [collection]);
const aiVariables = useMemo(() => buildAiVariablesPayload(collection, null), [collection]);
const onEdit = useCallback(
(value) => dispatch(updateAppCode({ code: value, itemUid: item.uid, collectionUid: collection.uid })),
[dispatch, item.uid, collection.uid]
);
const onSave = useCallback(
() => dispatch(saveRequest(item.uid, collection.uid)),
[dispatch, item.uid, collection.uid]
);
// Execute a single request by its pathname (returned earlier from listRequests).
// Mirrors AppView.handleSendRequest: mints a requestUid, registers the run, merges
// overrides into runtime variables, sends, and dispatches responseReceived so the
// request's normal Response pane updates too.
const runRequestByPath = useCallback(
async (pathname, overrides) => {
const target = findItemInCollectionByPathname(collection, pathname);
if (!target) {
throw new Error(`Request not found: ${pathname}`);
}
if (!isItemARequest(target)) {
throw new Error(`Item is not a request: ${pathname}`);
}
const requestUid = uuid();
const requestItem = cloneDeep(target.draft || target);
requestItem.requestUid = requestUid;
dispatch(
initRunRequestEvent({ requestUid, itemUid: target.uid, collectionUid: collection.uid })
);
const flat = overrides && typeof overrides === 'object' ? { ...overrides } : {};
const explicit = flat.variables;
delete flat.variables;
const mergedRuntime = {
...(collection.runtimeVariables || {}),
...flat,
...(explicit && typeof explicit === 'object' ? explicit : {})
};
const result = await sendNetworkRequest(requestItem, collection, environment, mergedRuntime);
if (result?.error) {
const errorMessage = typeof result.error === 'string'
? result.error
: result.error?.message || 'Request failed';
throw new Error(errorMessage);
}
dispatch(
responseReceived({
itemUid: target.uid,
collectionUid: collection.uid,
response: {
status: result.status,
statusText: result.statusText,
headers: result.headers,
data: result.data,
dataBuffer: result.dataBuffer,
size: result.size,
duration: result.duration,
timeline: serializeTimeline(result.timeline)
}
})
);
return projectResponse(result);
},
[collection, environment, dispatch]
);
// pushToGuest is produced by useAppWebview, which itself needs handleGuestMessage —
// so we can't put it in handleGuestMessage's useCallback deps (circular). Instead
// route guest replies through a ref that always points at the latest pushToGuest.
// Without this, the callback closes over the first-render pushToGuest (which is a
// no-op until dom-ready) and reply messages never reach the guest.
const pushToGuestRef = useRef(() => {});
const handleGuestMessage = useCallback(
async (data) => {
const push = pushToGuestRef.current;
switch (data?.type) {
case 'ready':
break;
case 'log':
console.log('[app]', ...(data.args || []));
break;
case 'setRuntimeVariable':
if (typeof data.key === 'string' && data.key.length) {
dispatch(
appSetRuntimeVariable({ collectionUid: collection.uid, key: data.key, value: data.value })
);
}
break;
case 'listRequests': {
push({ type: 'reply', replyId: data.replyId, result: listRequestSummaries(collection) });
break;
}
case 'runRequest': {
try {
const res = await runRequestByPath(data.pathname, data.overrides);
push({ type: 'reply', replyId: data.replyId, result: res });
} catch (err) {
push({ type: 'reply', replyId: data.replyId, error: err?.message || 'runRequest failed' });
}
break;
}
default:
break;
}
},
[dispatch, collection, runRequestByPath]
);
const { domReady, pushToGuest, webviewRef } = useAppWebview(handleGuestMessage);
pushToGuestRef.current = pushToGuest;
const stateRef = useRef();
stateRef.current = { theme: displayedTheme, variables, collection: collectionInfo };
useEffect(() => {
if (!domReady) return;
pushToGuest({ type: 'state', ...stateRef.current });
}, [domReady, pushToGuest]);
useEffect(() => {
pushToGuest({ type: 'theme', theme: displayedTheme });
}, [displayedTheme, pushToGuest]);
useEffect(() => {
pushToGuest({ type: 'variables', variables });
}, [variables, pushToGuest]);
useEffect(() => {
pushToGuest({ type: 'collection', collection: collectionInfo });
}, [collectionInfo, pushToGuest]);
return (
<StyledWrapper data-testid="collection-app">
<div className="app-toolbar">
<span>App - {item.name}</span>
<div className="view-toggle" data-testid="collection-app-view-toggle">
<button
type="button"
data-testid="collection-app-view-code"
className={classnames('view-btn', { active: view === 'code' })}
onClick={() => setView('code')}
>
Code
</button>
<button
type="button"
data-testid="collection-app-view-preview"
className={classnames('view-btn', { active: view === 'preview' })}
onClick={() => setView('preview')}
>
Preview
</button>
</div>
</div>
{view === 'code' ? (
<div className="app-pane code relative" data-testid="collection-app-code">
<CodeEditor
collection={collection}
value={code || ''}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
onEdit={onEdit}
onSave={onSave}
mode="htmlmixed"
/>
<AIAssist
scriptType="app-collection"
currentScript={code || ''}
docsContext={docsContext}
variables={aiVariables}
onApply={onEdit}
/>
</div>
) : code && code.trim().length ? (
<div className="app-pane app-webview-container" data-testid="collection-app-preview">
<webview
ref={webviewRef}
src={src}
partition="persist:bruno-app-view"
webpreferences="disableDialogs=true, javascript=yes"
className="app-webview"
/>
</div>
) : (
<div className="app-pane" data-testid="collection-app-preview">
<EmptyAppState
title="No app yet"
hint="Switch to Code and write some HTML/JS"
/>
</div>
)}
</StyledWrapper>
);
};
export default CollectionApp;

View File

@@ -2,7 +2,6 @@ import React, { useMemo, useCallback } from 'react';
import get from 'lodash/get';
import { IconCaretDown } from '@tabler/icons';
import MenuDropdown from 'ui/MenuDropdown';
import StatusBadge from 'ui/StatusBadge/index';
import { useDispatch } from 'react-redux';
import { updateCollectionAuthMode } from 'providers/ReduxStore/slices/collections';
import { humanizeRequestAuthMode } from 'utils/collections';
@@ -67,17 +66,6 @@ const AuthMode = ({ collection }) => {
label: 'API Key',
onClick: () => onModeChange('apikey')
},
{
id: 'akamai-edgegrid',
label: (
<span className="flex items-center gap-2">
Akamai EdgeGrid
<StatusBadge status="info" size="xs">Beta</StatusBadge>
</span>
),
ariaLabel: 'Akamai EdgeGrid (Beta)',
onClick: () => onModeChange('akamai-edgegrid')
},
{
id: 'none',
label: 'No Auth',

View File

@@ -1,97 +0,0 @@
import styled from 'styled-components';
import { rgba } from 'polished';
const StyledWrapper = styled.div`
label {
display: flex;
align-items: center;
font-size: ${(props) => props.theme.font.size.sm};
font-weight: 500;
color: ${(props) => props.theme.colors.text.subtext1};
margin-bottom: 0.5rem;
}
.single-line-editor-wrapper {
display: flex;
align-items: center;
max-width: 400px;
margin-bottom: 0.5rem;
padding: 0.15rem 0.4rem;
border-radius: 3px;
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
&:focus-within {
border: solid 1px ${(props) => props.theme.input.focusBorder} !important;
}
}
.advanced-settings-header {
display: flex;
align-items: center;
gap: 10px;
width: fit-content;
margin: 1rem 0 0.75rem;
font-size: ${(props) => props.theme.font.size.sm};
font-weight: 600;
color: ${(props) => props.theme.colors.text.subtext1};
user-select: none;
.advanced-settings-icon {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.375rem 0.625rem;
border-radius: 0.375rem;
background-color: ${(props) => rgba(props.theme.primary.solid, 0.1)};
svg {
color: ${(props) => props.theme.primary.text};
}
}
}
.advanced-settings-hint {
margin: -0.25rem 0 0.75rem;
font-size: ${(props) => props.theme.font.size.sm};
color: rgb(107 114 128);
}
.field-info {
position: relative;
display: inline-flex;
align-items: center;
margin-left: 6px;
cursor: pointer;
svg {
color: rgb(107 114 128);
}
.field-tooltip {
position: absolute;
left: 0;
bottom: 100%;
z-index: 10;
width: max-content;
max-width: 15rem;
margin-bottom: 0.25rem;
padding: 0.5rem;
border-radius: 0.375rem;
background-color: #374151;
color: #fff;
font-size: 0.75rem;
line-height: 1rem;
font-weight: 400;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
}
&:hover .field-tooltip {
opacity: 1;
}
}
`;
export default StyledWrapper;

View File

@@ -1,165 +0,0 @@
import { IconAdjustmentsHorizontal, IconInfoCircle } from '@tabler/icons';
import get from 'lodash/get';
import React from 'react';
import { useDispatch } from 'react-redux';
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
import SingleLineEditor from 'components/SingleLineEditor';
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import { updateCollectionAuth } from 'providers/ReduxStore/slices/collections';
import { saveCollectionRoot } from 'providers/ReduxStore/slices/collections/actions';
import { useTheme } from 'providers/Theme';
import StyledWrapper from './StyledWrapper';
interface AkamaiEdgeGridAuthValues {
accessToken?: string;
clientToken?: string;
clientSecret?: string;
nonce?: string;
timestamp?: string;
baseURL?: string;
headersToSign?: string;
maxBodySize?: number | null;
}
type EdgeGridField = keyof AkamaiEdgeGridAuthValues;
const toMaxBodySize = (value: string): number | null => {
if (value === '' || value == null) return null;
const num = Number(value);
return Number.isNaN(num) ? null : num;
};
interface AkamaiEdgeGridAuthProps {
collection: any;
}
const FIELDS: Array<{ key: EdgeGridField; label: string; tooltip?: string; isSecret?: boolean }> = [
{ key: 'accessToken', label: 'Access Token' },
{ key: 'clientToken', label: 'Client Token' },
{ key: 'clientSecret', label: 'Client Secret', isSecret: true },
{ key: 'baseURL', label: 'Base URL', tooltip: 'Defaults to the request URL if not specified.' },
{
key: 'nonce',
label: 'Nonce',
tooltip: 'A unique nonce is required per request. Defaults to an auto-generated UUID v4 if not provided.'
},
{
key: 'timestamp',
label: 'Timestamp',
tooltip:
'UTC timestamp of when the request is signed (yyyyMMddTHH:mm:ss+0000). Defaults to current time if not provided.'
},
{
key: 'headersToSign',
label: 'Headers to Sign',
tooltip: 'Comma-separated list of headers to include in the signature.'
},
{
key: 'maxBodySize',
label: 'Max Body Size',
tooltip: 'Maximum message body size to include in the signature, in bytes. Defaults to 131072.'
}
];
type EdgeGridFieldConfig = (typeof FIELDS)[number];
// Fields shown up front vs. those grouped under the "Advanced Settings" section
const BASIC_FIELDS = FIELDS.slice(0, 3);
const ADVANCED_FIELDS = FIELDS.slice(3);
const EdgeGridAuth: React.FC<AkamaiEdgeGridAuthProps> = ({ collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const edgeGridAuth: AkamaiEdgeGridAuthValues =
(collection.draft?.root
? get(collection, 'draft.root.request.auth.akamaiEdgegrid')
: get(collection, 'root.request.auth.akamaiEdgegrid')) || {};
const { isSensitive } = useDetectSensitiveField(collection);
const { showWarning: showClientSecretWarning, warningMessage: clientSecretWarningMessage } = isSensitive(
edgeGridAuth?.clientSecret
);
const handleSave = () => dispatch(saveCollectionRoot(collection.uid));
const handleFieldChange = (field: EdgeGridField, value: string) => {
const content: AkamaiEdgeGridAuthValues = {
accessToken: edgeGridAuth.accessToken || '',
clientToken: edgeGridAuth.clientToken || '',
clientSecret: edgeGridAuth.clientSecret || '',
nonce: edgeGridAuth.nonce || '',
timestamp: edgeGridAuth.timestamp || '',
baseURL: edgeGridAuth.baseURL || '',
headersToSign: edgeGridAuth.headersToSign || '',
maxBodySize: edgeGridAuth.maxBodySize ?? null
};
if (field === 'maxBodySize') {
content.maxBodySize = toMaxBodySize(value);
} else {
(content as Record<string, unknown>)[field] = value || '';
}
dispatch(
updateCollectionAuth({
mode: 'akamai-edgegrid',
collectionUid: collection.uid,
content
})
);
};
const renderField = ({ key, label, tooltip, isSecret }: EdgeGridFieldConfig) => {
const showWarning = isSecret && showClientSecretWarning;
const rawValue = edgeGridAuth[key];
const fieldValue = rawValue === null || rawValue === undefined ? '' : String(rawValue);
return (
<div key={key}>
<label>
{label}
{tooltip && (
<span className="field-info">
<IconInfoCircle size={16} />
<span className="field-tooltip">{tooltip}</span>
</span>
)}
</label>
<div className="single-line-editor-wrapper">
<SingleLineEditor
value={fieldValue}
theme={storedTheme}
onSave={handleSave}
onChange={(val: string) => handleFieldChange(key, val)}
collection={collection}
isSecret={isSecret}
isCompact
/>
{showWarning && (
<SensitiveFieldWarning fieldName="edgegrid-client-secret" warningMessage={clientSecretWarningMessage} />
)}
</div>
</div>
);
};
return (
<StyledWrapper className="mt-2 w-full">
{BASIC_FIELDS.map(renderField)}
<div className="advanced-settings-header">
<span className="advanced-settings-icon">
<IconAdjustmentsHorizontal size={16} />
</span>
<span>Advanced Settings</span>
</div>
<>
{ADVANCED_FIELDS.map(renderField)}
</>
</StyledWrapper>
);
};
export default EdgeGridAuth;

View File

@@ -8,7 +8,6 @@ import BasicAuth from './BasicAuth';
import DigestAuth from './DigestAuth';
import WsseAuth from './WsseAuth';
import ApiKeyAuth from './ApiKeyAuth/';
import EdgeGridAuth from './EdgeGridAuth';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import OAuth2 from './OAuth2';
@@ -51,9 +50,6 @@ const Auth = ({ collection }) => {
case 'apikey': {
return <ApiKeyAuth collection={collection} />;
}
case 'akamai-edgegrid': {
return <EdgeGridAuth collection={collection} />;
}
}
};

View File

@@ -4,13 +4,11 @@ import find from 'lodash/find';
import { updateCollectionDocs, deleteCollectionDraft } from 'providers/ReduxStore/slices/collections';
import { updateDocsEditing } from 'providers/ReduxStore/slices/tabs';
import { useTheme } from 'providers/Theme';
import { useMemo, useRef } from 'react';
import { useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import Markdown from 'components/MarkDown';
import CodeEditor from 'components/CodeEditor';
import AIAssist from 'components/AIAssist';
import { buildAiVariablesPayload, buildDocsContextFromCollection } from 'utils/ai';
import StyledWrapper from './StyledWrapper';
import { IconEdit, IconX, IconFileText } from '@tabler/icons';
import Button from 'ui/Button/index';
@@ -27,8 +25,6 @@ const Docs = ({ collection }) => {
const isEditing = focusedTab?.docsEditing || false;
const docs = collection.draft?.root ? get(collection, 'draft.root.docs', '') : get(collection, 'root.docs', '');
const preferences = useSelector((state) => state.app.preferences);
const docsContext = useMemo(() => buildDocsContextFromCollection(collection), [collection]);
const aiVariables = useMemo(() => buildAiVariablesPayload(collection, null), [collection]);
// StyledWrapper has overflow-y: auto — use null selector.
// Preview mode: hook tracks wrapper scroll. Edit mode: CodeEditor's onScroll/initialScroll.
@@ -89,21 +85,18 @@ const Docs = ({ collection }) => {
</div>
</div>
{isEditing ? (
<div className="relative flex-1 min-h-0">
<CodeEditor
collection={collection}
theme={displayedTheme}
value={docs}
onEdit={onEdit}
onSave={onSave}
mode="application/text"
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
initialScroll={scroll}
onScroll={setScroll}
/>
<AIAssist scriptType="docs" currentScript={docs || ''} docsContext={docsContext} variables={aiVariables} onApply={onEdit} />
</div>
<CodeEditor
collection={collection}
theme={displayedTheme}
value={docs}
onEdit={onEdit}
onSave={onSave}
mode="application/text"
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
initialScroll={scroll}
onScroll={setScroll}
/>
) : (
<div className="pl-1">
<div className="h-[1px] min-h-[500px]">

View File

@@ -8,7 +8,6 @@ import ShareCollection from 'components/ShareCollection/index';
import GenerateDocumentation from 'components/Sidebar/Collections/Collection/GenerateDocumentation';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import StyledWrapper from './StyledWrapper';
import Migration from '../Migration';
const Info = ({ collection }) => {
const dispatch = useDispatch();
@@ -127,8 +126,6 @@ const Info = ({ collection }) => {
</div>
</div>
{showGenerateDocumentationModal && <GenerateDocumentation collectionUid={collection.uid} onClose={() => setShowGenerateDocumentationModal(false)} />}
<Migration collection={collection} />
</div>
</div>
</StyledWrapper>

View File

@@ -1,40 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.backup-section {
border: 1px solid ${(props) => props.theme.border.border2};
border-radius: ${(props) => props.theme.border.radius.base};
background-color: ${(props) => props.theme.background.mantle};
padding: 12px 14px;
}
.backup-section-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
color: ${(props) => props.theme.text};
}
.backup-section-title {
font-size: ${(props) => props.theme.font.size.sm};
font-weight: 500;
color: ${(props) => props.theme.colors.text.muted};
text-transform: uppercase;
letter-spacing: 0.04em;
}
.backup-section-help {
font-size: ${(props) => props.theme.font.size.base};
color: ${(props) => props.theme.colors.text.muted};
line-height: 1.45;
margin: 0 0 10px 0;
}
.backup-section-action {
display: flex;
justify-content: flex-start;
}
`;
export default StyledWrapper;

View File

@@ -1,92 +0,0 @@
import React, { useState } from 'react';
import { useDispatch } from 'react-redux';
import toast from 'react-hot-toast';
import { migrateCollectionToYml } from 'providers/ReduxStore/slices/collections/actions';
import Modal from 'components/Modal';
import Portal from 'components/Portal';
import Button from 'ui/Button';
import StyledWrapper from './StyledWrapper';
const MigrateToYmlModal = ({ collection, onClose }) => {
const dispatch = useDispatch();
const [isMigrating, setIsMigrating] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const handleMigrate = () => {
setIsMigrating(true);
dispatch(migrateCollectionToYml(collection.uid))
.catch(() => {})
.finally(() => {
setIsMigrating(false);
onClose();
});
};
const handleExportBackup = async () => {
if (isExporting) return;
setIsExporting(true);
try {
const { ipcRenderer } = window;
const result = await ipcRenderer.invoke('renderer:export-collection-zip', collection.pathname, collection.name);
if (result?.success) {
toast.success('Collection backup exported');
}
} catch (error) {
toast.error('Failed to export backup: ' + error.message);
} finally {
setIsExporting(false);
}
};
return (
<Portal>
<StyledWrapper>
<Modal
size="md"
title="Migrate to YML format"
confirmText="Migrate"
confirmDisabled={isExporting || isMigrating}
handleConfirm={handleMigrate}
handleCancel={onClose}
>
<div>
<p>
This will convert all files in <strong>{collection.name}</strong> from <code>.bru</code> format to <code>.yml</code> format.
</p>
<div className="mt-4 text-sm text-muted">
<p className="font-medium mb-2">What will happen:</p>
<ul className="list-disc ml-5 flex flex-col gap-1">
<li>All <code>.bru</code> request files will be converted to <code>.yml</code></li>
<li>Environment files will be converted to YML format</li>
<li><code>bruno.json</code> will be replaced with <code>opencollection.yml</code></li>
<li>The collection will be reloaded after migration</li>
</ul>
</div>
<div className="backup-section mt-4">
<div className="backup-section-head">
<span className="backup-section-title">Backup</span>
</div>
<p className="backup-section-help">
Export this collection as a ZIP archive before migrating, in case you want to restore it later.
</p>
<div className="backup-section-action">
<Button
data-testid="export-collection-backup-button"
size="sm"
color="secondary"
variant="outline"
onClick={handleExportBackup}
disabled={isExporting}
>
{isExporting ? 'Exporting…' : 'Export Collection'}
</Button>
</div>
</div>
</div>
</Modal>
</StyledWrapper>
</Portal>
);
};
export default MigrateToYmlModal;

View File

@@ -1,20 +0,0 @@
import styled from 'styled-components';
import { rgba } from 'polished';
const StyledWrapper = styled.div`
.migration-section {
padding-top: 1.5rem;
margin-top: 1.5rem;
}
.icon-box.migration {
background-color: ${(props) => rgba(props.theme.colors.text.yellow, 0.08)};
border: 1px solid ${(props) => rgba(props.theme.colors.text.yellow, 0.09)};
svg {
color: ${(props) => props.theme.colors.text.yellow};
}
}
`;
export default StyledWrapper;

View File

@@ -1,64 +0,0 @@
import React, { useState } from 'react';
import { IconFileCode, IconTransform } from '@tabler/icons';
import Button from 'ui/Button';
import MigrateToYmlModal from './MigrateToYmlModal';
import StyledWrapper from './StyledWrapper';
const Migration = ({ collection }) => {
const [showConfirmModal, setShowConfirmModal] = useState(false);
// Only show for bru format collections
if (collection.format !== 'bru') {
return null;
}
return (
<StyledWrapper>
<div className="migration-section">
<div className="text-lg font-medium flex items-center gap-2 mb-4">
<IconTransform size={20} stroke={1.5} />
Migration
</div>
<div className="flex items-start">
<div className="icon-box migration flex-shrink-0 p-3 rounded-lg">
<IconFileCode className="w-5 h-5" stroke={1.5} />
</div>
<div className="ml-4">
<div className="font-medium">Migrate to YML file format</div>
<div className="my-1 text-muted text-sm">
This collection is stored in BRU format.{' '}
Switch to YML.{' '}
<a
href="https://blog.usebruno.com/making-yaml-the-default-in-bruno-v3.1"
target="_blank"
rel="noopener noreferrer"
className="text-link hover:underline"
>
Learn More &#x2197;
</a>
</div>
<Button
data-testid="migrate-collection-to-yml-button"
size="sm"
color="primary"
className="mt-2"
onClick={() => setShowConfirmModal(true)}
>
Convert to YML
</Button>
</div>
</div>
</div>
{showConfirmModal && (
<MigrateToYmlModal
collection={collection}
onClose={() => setShowConfirmModal(false)}
/>
)}
</StyledWrapper>
);
};
export default Migration;

View File

@@ -1,10 +1,9 @@
import React, { useEffect, useMemo, useRef } from 'react';
import React, { useEffect, useRef } from 'react';
import get from 'lodash/get';
import find from 'lodash/find';
import { useDispatch, useSelector } from 'react-redux';
import CodeEditor from 'components/CodeEditor';
import AIAssist from 'components/AIAssist';
import { buildAiVariablesPayload } from 'utils/ai';
import { updateCollectionRequestScript, updateCollectionResponseScript } from 'providers/ReduxStore/slices/collections';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import { updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs';
@@ -102,8 +101,6 @@ const Script = ({ collection }) => {
const hasPreRequestScriptError = items.some((i) => isItemARequest(i) && i.preRequestScriptErrorMessage);
const hasPostResponseScriptError = items.some((i) => isItemARequest(i) && i.postResponseScriptErrorMessage);
const aiVariables = useMemo(() => buildAiVariablesPayload(collection, null), [collection]);
return (
<StyledWrapper className="w-full flex flex-col h-full">
<div className="text-xs mb-4 text-muted">
@@ -140,14 +137,12 @@ const Script = ({ collection }) => {
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'bru']}
scriptType="pre-request"
initialScroll={preReqScroll}
onScroll={setPreReqScroll}
/>
<AIAssist
scriptType="pre-request"
currentScript={requestScript || ''}
variables={aiVariables}
onApply={onRequestScriptEdit}
/>
</div>
@@ -167,14 +162,12 @@ const Script = ({ collection }) => {
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
scriptType="post-response"
initialScroll={postResScroll}
onScroll={setPostResScroll}
/>
<AIAssist
scriptType="post-response"
currentScript={responseScript || ''}
variables={aiVariables}
onApply={onResponseScriptEdit}
/>
</div>

View File

@@ -1,9 +1,8 @@
import React, { useMemo, useRef } from 'react';
import React, { useRef } from 'react';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import CodeEditor from 'components/CodeEditor';
import AIAssist from 'components/AIAssist';
import { buildAiVariablesPayload } from 'utils/ai';
import { updateCollectionTests } from 'providers/ReduxStore/slices/collections';
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
import { useTheme } from 'providers/Theme';
@@ -38,8 +37,6 @@ const Tests = ({ collection }) => {
scriptPhase: 'test'
});
const aiVariables = useMemo(() => buildAiVariablesPayload(collection, null), [collection]);
return (
<StyledWrapper className="w-full flex flex-col h-full">
<div className="text-xs mb-4 text-muted">These tests will run any time a request in this collection is sent.</div>
@@ -59,7 +56,7 @@ const Tests = ({ collection }) => {
initialScroll={testsScroll}
onScroll={setTestsScroll}
/>
<AIAssist scriptType="tests" currentScript={tests || ''} variables={aiVariables} onApply={onEdit} />
<AIAssist scriptType="tests" currentScript={tests || ''} onApply={onEdit} />
</div>
<div className="mt-6">

View File

@@ -37,7 +37,7 @@ const StyledWrapper = styled.div`
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 0;
min-height: 0; /* Important for proper flex behavior */
}
.network-empty {
@@ -68,40 +68,47 @@ const StyledWrapper = styled.div`
flex-direction: column;
height: 100%;
overflow: hidden;
min-height: 0;
min-height: 0; /* Important for proper flex behavior */
position: relative;
}
&.is-resizing {
cursor: col-resize;
user-select: none;
}
.col-separator {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background: ${(props) => props.theme.console.border};
pointer-events: none;
z-index: 2;
}
.requests-header {
display: grid;
flex-shrink: 0;
padding: 0;
background: ${(props) => props.theme.console.headerBg};
border-bottom: 1px solid ${(props) => props.theme.console.border};
font-size: 10px;
color: ${(props) => props.theme.console.titleColor};
text-transform: uppercase;
letter-spacing: 0.5px;
& > * {
min-width: 0;
overflow: hidden;
}
flex-shrink: 0;
.header-cell {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
padding: 4px 8px;
cursor: pointer;
user-select: none;
&:first-child { padding-left: 16px; }
&:last-child { padding-right: 16px; }
&:first-child {
padding-left: 16px;
}
&:last-child {
padding-right: 16px;
}
&:hover {
color: ${(props) => props.theme.console.messageColor};
@@ -113,7 +120,10 @@ const StyledWrapper = styled.div`
white-space: nowrap;
}
svg { flex-shrink: 0; }
svg {
flex-shrink: 0;
}
}
}
@@ -121,70 +131,48 @@ const StyledWrapper = styled.div`
flex: 1;
overflow-y: auto;
overflow-x: hidden;
min-height: 0;
min-height: 0; /* Important for proper scrolling */
}
.request-row {
display: grid;
padding: 0;
cursor: pointer;
transition: background-color 0.1s ease;
font-size: ${(props) => props.theme.font.size.sm};
align-items: center;
& > * {
min-width: 0;
overflow: hidden;
&:hover {
background: ${(props) => props.theme.console.logHoverBg};
}
&:hover { background: ${(props) => props.theme.console.logHoverBg}; }
&.selected {
background: ${(props) => props.theme.console.logHoverBg};
box-shadow: inset 3px 0 0 ${(props) => props.theme.console.checkboxColor};
}
}
.col-separator {
position: absolute;
top: 0;
bottom: 0;
width: 4px;
transform: translateX(-2px);
cursor: col-resize;
z-index: 3;
&::after {
content: '';
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 1px;
height: 100%;
background: ${(props) => props.theme.sidebar.dragbar.border};
}
&:hover::after,
&.resizing::after {
background: ${(props) => props.theme.sidebar.dragbar.activeBorder};
}
.request-method {
padding: 2px 8px 2px 16px;
}
.request-method { padding: 2px 8px 2px 16px; }
.request-status { padding: 2px 8px; }
.request-status {
padding: 2px 8px;
}
.method-badge {
display: inline-flex;
align-items: center;
justify-content: start;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 45px;
}
.status-badge { font-size: ${(props) => props.theme.font.size.sm}; }
.status-badge {
font-size: ${(props) => props.theme.font.size.sm};
}
.request-domain {
padding: 2px 8px;
@@ -208,9 +196,6 @@ const StyledWrapper = styled.div`
color: ${(props) => props.theme.console.timestampColor};
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: ${(props) => props.theme.font.size.xs};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.request-duration {
@@ -219,12 +204,11 @@ const StyledWrapper = styled.div`
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: ${(props) => props.theme.font.size.xs};
text-align: right;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.text-right { text-align: right; }
.text-right {
text-align: right;
}
.request-size {
padding: 2px 8px;
@@ -232,9 +216,6 @@ const StyledWrapper = styled.div`
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: ${(props) => props.theme.font.size.xs};
text-align: right;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
`;

View File

@@ -1,5 +1,4 @@
import React, { useMemo } from 'react';
import { usePersistedState } from 'hooks/usePersistedState';
import React, { useMemo, useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import {
IconNetwork,
@@ -9,17 +8,17 @@ import {
import {
setSelectedRequest
} from 'providers/ReduxStore/slices/logs';
import { useResizableColumns } from 'hooks/useResizableColumns';
import StyledWrapper from './StyledWrapper';
import { sortRequests } from './utils';
import { getGridTemplate, getSeparatorPositions, sortRequests } from './utils';
// TODO: Columns will be resizable in the future, so width can be null (for auto) or a number (for fixed width)
const COLUMNS = [
{ key: 'method', label: 'Method', width: 80, align: 'left' },
{ key: 'status', label: 'Status', width: 70, align: 'left' },
{ key: 'domain', label: 'Domain', width: 180, align: 'left' },
{ key: 'path', label: 'Path', width: 300, align: 'left' },
{ key: 'time', label: 'Time', width: 110, align: 'left' },
{ key: 'duration', label: 'Duration', width: 100, align: 'right' },
{ key: 'method', label: 'Method', width: 90, align: 'left' },
{ key: 'status', label: 'Status', width: 80, align: 'left' },
{ key: 'domain', label: 'Domain', width: 200, align: 'left' },
{ key: 'path', label: 'Path', width: null, align: 'left' },
{ key: 'time', label: 'Time', width: 100, align: 'left' },
{ key: 'duration', label: 'Duration', width: 120, align: 'right' },
{ key: 'size', label: 'Size', width: 80, align: 'right' }
];
@@ -134,27 +133,15 @@ const RequestRow = ({ request, isSelected, onClick, gridTemplateColumns }) => {
const NetworkTab = () => {
const dispatch = useDispatch();
const [sortConfig, setSortConfig] = usePersistedState({ key: 'devtools-network-sort', default: { key: null, direction: null } });
const [savedColWidths, setSavedColWidths] = usePersistedState({ key: 'devtools-network-col-widths', default: null });
const {
containerRef,
gridTemplateColumns,
separatorPositions,
resizingIdx,
handleResizeStart
} = useResizableColumns({
defaultWidths: COLUMNS.map((c) => c.width),
initialWidths: savedColWidths,
minColWidth: 60,
onResizeEnd: setSavedColWidths
});
const [sortConfig, setSortConfig] = useState({ key: null, direction: null });
const gridTemplateColumns = useMemo(() => getGridTemplate(COLUMNS), []);
const separatorPositions = useMemo(() => getSeparatorPositions(COLUMNS), []);
const { networkFilters, selectedRequest } = useSelector((state) => state.logs);
const collections = useSelector((state) => state.collections.collections);
const allRequests = useMemo(() => {
const requests = [];
collections.forEach((collection) => {
if (collection.timeline) {
collection.timeline
@@ -168,6 +155,7 @@ const NetworkTab = () => {
});
}
});
return requests.sort((a, b) => a.timestamp - b.timestamp);
}, [collections]);
@@ -178,11 +166,15 @@ const NetworkTab = () => {
});
}, [allRequests, networkFilters]);
const handleRequestClick = (request) => dispatch(setSelectedRequest(request));
const handleRequestClick = (request) => {
dispatch(setSelectedRequest(request));
};
const handleHeaderClick = (key) => {
setSortConfig((prev) => {
// If clicking a different column, start with ascending sort
if (prev.key !== key) return { key, direction: 'asc' };
if (prev.direction === 'asc') return { key, direction: 'desc' };
return { key: null, direction: null };
});
@@ -203,7 +195,7 @@ const NetworkTab = () => {
<span>Requests will appear here as you make API calls</span>
</div>
) : (
<div className={`requests-container${resizingIdx !== null ? ' is-resizing' : ''}`}>
<div className="requests-container">
<div className="requests-header" style={{ gridTemplateColumns }}>
{COLUMNS.map((col) => (
<div
@@ -222,30 +214,27 @@ const NetworkTab = () => {
))}
</div>
<div ref={containerRef} className="requests-list">
<div className="requests-list">
{sortedRequests.map((request, index) => (
<RequestRow
key={`${request.collectionUid}-${request.itemUid}-${request.timestamp}-${index}`}
request={request}
isSelected={
selectedRequest?.timestamp === request.timestamp
&& selectedRequest?.itemUid === request.itemUid
}
isSelected={selectedRequest?.timestamp === request.timestamp && selectedRequest?.itemUid === request.itemUid}
onClick={() => handleRequestClick(request)}
gridTemplateColumns={gridTemplateColumns}
/>
))}
</div>
{separatorPositions.map((left, i) => (
<div
key={i}
className={`col-separator${resizingIdx === i ? ' resizing' : ''}`}
style={{ left }}
onMouseDown={(e) => handleResizeStart(e, i)}
data-testid={`network-col-separator-${i}`}
/>
))}
{separatorPositions.map((pos, i) =>
pos ? (
<div
key={i}
className="col-separator"
style={'left' in pos ? { left: `${pos.left}px` } : { right: `${pos.right}px` }}
/>
) : null
)}
</div>
)}
</div>

View File

@@ -29,10 +29,6 @@ const makeRequest = (overrides = {}) => ({
const ALL_FILTERS = { GET: true, POST: true, PUT: true, DELETE: true, PATCH: true, HEAD: true, OPTIONS: true };
beforeEach(() => {
localStorage.clear();
});
const renderNetworkTab = (requests = []) => {
const store = configureStore({
reducer: {
@@ -59,10 +55,6 @@ const renderNetworkTab = (requests = []) => {
);
};
beforeEach(() => {
localStorage.clear();
});
describe('sort state cycle', () => {
const requests = [
makeRequest({ itemUid: 'a', method: 'GET' }),
@@ -171,26 +163,6 @@ describe('sort results', () => {
expect(getRowMethods()).toEqual(['DELETE', 'GET', 'POST']);
});
it('restores sort config after close and reopen', () => {
const requests = [
makeRequest({ itemUid: '1', method: 'POST' }),
makeRequest({ itemUid: '2', method: 'GET' }),
makeRequest({ itemUid: '3', method: 'DELETE' })
];
// First mount — set sort to method descending
const { unmount } = renderNetworkTab(requests);
fireEvent.click(screen.getByTestId('network-header-method')); // asc
fireEvent.click(screen.getByTestId('network-header-method')); // desc
expect(getRowMethods()).toEqual(['POST', 'GET', 'DELETE']);
unmount(); // simulate closing devtools
// Second mount — sort should be restored from localStorage
renderNetworkTab(requests);
expect(screen.getByTestId('sort-icon-desc')).toBeInTheDocument();
expect(getRowMethods()).toEqual(['POST', 'GET', 'DELETE']);
});
it('preserves insertion order when sort is cleared', () => {
const requests = [
makeRequest({ itemUid: '1', method: 'POST' }),

View File

@@ -1,3 +1,29 @@
export const getGridTemplate = (columns) =>
columns.map((c) => (c.width ? `${c.width}px` : '1fr')).join(' ');
export const getSeparatorPositions = (columns) => {
const n = columns.length;
const positions = new Array(n - 1).fill(null);
let leftOffset = 0;
for (let i = 0; i < n - 1; i++) {
if (columns[i].width === null) break;
leftOffset += columns[i].width;
positions[i] = { left: leftOffset };
}
let rightOffset = 0;
for (let i = n - 1; i > 0; i--) {
if (columns[i].width === null) break;
rightOffset += columns[i].width;
if (positions[i - 1] === null) {
positions[i - 1] = { right: rightOffset };
}
}
return positions;
};
export const getSortValue = (request, key) => {
const { request: req, response: res, timestamp } = request.data;
switch (key) {

View File

@@ -314,7 +314,6 @@ const StyledWrapper = styled.div`
height: 100% !important;
max-height: 400px !important;
padding: 0.5rem !important;
overflow: auto !important;
.network-logs-pre {
color: ${(props) => props.theme.console.messageColor} !important;

View File

@@ -1,5 +1,4 @@
import React, { useEffect, useRef, useState } from 'react';
import { usePersistedState } from 'hooks/usePersistedState';
import { useSelector, useDispatch } from 'react-redux';
import ReactJson from 'react-json-view';
import { useTheme } from 'providers/Theme';
@@ -24,7 +23,8 @@ import {
setActiveTab,
clearDebugErrors,
updateNetworkFilter,
toggleAllNetworkFilters
toggleAllNetworkFilters,
updateRequestDetailsPanelWidth
} from 'providers/ReduxStore/slices/logs';
import NetworkTab from './NetworkTab';
@@ -386,7 +386,7 @@ const Console = () => {
const dispatch = useDispatch();
const { logs, filters, activeTab, selectedRequest, selectedError, networkFilters, debugErrors } = useSelector((state) => state.logs);
const collections = useSelector((state) => state.collections.collections);
const [savedDetailsPanelWidth, setSavedDetailsPanelWidth] = usePersistedState({ key: 'devtools-details-panel-width', default: 400 });
const savedDetailsPanelWidth = useSelector((state) => state.logs.requestDetailsPanelWidth);
const consoleRef = useRef(null);
const { width: detailsPanelWidth, handleDragStart: handleDetailsPanelDragStart } = useResizablePanel({
@@ -394,7 +394,7 @@ const Console = () => {
minWidth: MIN_DETAILS_PANEL_WIDTH,
maxWidth: MAX_DETAILS_PANEL_WIDTH,
direction: 'right',
onResizeEnd: (newWidth) => setSavedDetailsPanelWidth(newWidth)
onResizeEnd: (newWidth) => dispatch(updateRequestDetailsPanelWidth({ requestDetailsPanelWidth: newWidth }))
});
const logCounts = logs.reduce((counts, log) => {

View File

@@ -4,13 +4,11 @@ import find from 'lodash/find';
import { updateRequestDocs } from 'providers/ReduxStore/slices/collections';
import { updateDocsEditing } from 'providers/ReduxStore/slices/tabs';
import { useTheme } from 'providers/Theme';
import { useMemo, useRef } from 'react';
import { useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import Markdown from 'components/MarkDown';
import CodeEditor from 'components/CodeEditor';
import AIAssist from 'components/AIAssist';
import { buildAiContextPayload } from 'utils/ai';
import StyledWrapper from './StyledWrapper';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
@@ -44,10 +42,6 @@ const Documentation = ({ item, collection }) => {
};
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const { requestContext, variables: aiVariables } = useMemo(
() => buildAiContextPayload(item, collection),
[item, collection]
);
if (!item) {
return null;
@@ -60,27 +54,18 @@ const Documentation = ({ item, collection }) => {
</div>
{isEditing ? (
<div className="relative flex-1 min-h-0">
<CodeEditor
collection={collection}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
value={docs || ''}
onEdit={onEdit}
onSave={onSave}
mode="application/text"
initialScroll={scroll}
onScroll={setScroll}
/>
<AIAssist
scriptType="docs"
currentScript={docs || ''}
requestContext={requestContext}
variables={aiVariables}
onApply={onEdit}
/>
</div>
<CodeEditor
collection={collection}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
value={docs || ''}
onEdit={onEdit}
onSave={onSave}
mode="application/text"
initialScroll={scroll}
onScroll={setScroll}
/>
) : (
<Markdown collectionPath={collection.pathname} onDoubleClick={toggleViewMode} content={docs} />
)}

View File

@@ -1,8 +1,7 @@
import React, { useCallback, useRef, useState, useEffect, useMemo } from 'react';
import { TableVirtuoso } from 'react-virtuoso';
import cloneDeep from 'lodash/cloneDeep';
import isEqual from 'lodash/isEqual';
import { IconTrash, IconAlertCircle, IconInfoCircle } from '@tabler/icons';
import { IconTrash, IconAlertCircle } from '@tabler/icons';
import { useTheme } from 'providers/Theme';
import { useSelector, useDispatch } from 'react-redux';
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
@@ -25,15 +24,6 @@ const MIN_H = 35 * 2;
const MIN_COLUMN_WIDTH = 80;
const MIN_ROW_HEIGHT = 35;
// Non-secret rows first, then secrets. The tabs save independently, so a stable
// order keeps the "modified" comparison accurate regardless of which tab saved last.
const orderVarsBySecret = (vars) => {
const nonSecret = [];
const secret = [];
vars.forEach((v) => (v.secret ? secret : nonSecret).push(v));
return [...nonSecret, ...secret];
};
const TableRow = React.memo(
({ children, item, style, ...rest }) => {
const variable = item?.variable ?? item;
@@ -59,10 +49,8 @@ const EnvironmentVariablesTable = ({
onDraftClear,
setIsModified,
renderExtraValueContent,
searchQuery = '',
variableType = 'variables'
searchQuery = ''
}) => {
const isSecretTab = variableType === 'secrets';
const { storedTheme } = useTheme();
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
const activeWorkspace = useSelector((state) => {
@@ -79,6 +67,7 @@ const EnvironmentVariablesTable = ({
const rowCount = (environment.variables?.length || 0) + 1;
const [tableHeight, setTableHeight] = useState(rowCount * MIN_ROW_HEIGHT);
// We need to add <EditableTable/> component for env table
const [scroll, setScroll] = usePersistedState({
key: `persisted::${activeTabUid}::collection-envs-scroll-${environment.uid}`,
default: 0
@@ -177,19 +166,15 @@ const EnvironmentVariablesTable = ({
const _collection = useMemo(() => {
const c = collection ? cloneDeep(collection) : {};
c.globalEnvironmentVariables = globalEnvironmentVariables;
c.activeEnvironmentUid = environment.uid;
if (!collection && workspaceProcessEnvVariables) {
c.workspaceProcessEnvVariables = workspaceProcessEnvVariables;
}
return c;
}, [collection, globalEnvironmentVariables, workspaceProcessEnvVariables, environment.uid]);
}, [collection, globalEnvironmentVariables, workspaceProcessEnvVariables]);
// Reuse the previous initialValues when only uids changed but the content is
// identical.
const initialValuesRef = useRef(null);
const initialValues = useMemo(() => {
const vars = environment.variables || [];
const next = [
return [
...vars,
{
uid: uuid(),
@@ -200,12 +185,6 @@ const EnvironmentVariablesTable = ({
enabled: true
}
];
const prev = initialValuesRef.current;
if (prev && isEqual(prev.map(stripEnvVarUid), next.map(stripEnvVarUid))) {
return prev;
}
initialValuesRef.current = next;
return next;
}, [environment.uid, environment.variables]);
const formik = useFormik({
@@ -276,7 +255,7 @@ const EnvironmentVariablesTable = ({
name: '',
value: '',
type: 'text',
secret: isSecretTab,
secret: false,
enabled: true
}
]);
@@ -291,18 +270,6 @@ const EnvironmentVariablesTable = ({
setPinnedData({ query: '', uids: new Set() });
}, [savedValuesJson]);
// Keep the trailing empty "add new" row's secret flag in sync with the active
// tab, so typing into it creates a variable of the correct type. The empty row
// is filtered out of save/draft, so this never affects persisted data.
useEffect(() => {
const lastIndex = formik.values.length - 1;
const last = formik.values[lastIndex];
const isEmpty = !last?.name || (typeof last.name === 'string' && last.name.trim() === '');
if (last && isEmpty && !!last.secret !== isSecretTab) {
formik.setFieldValue(`${lastIndex}.secret`, isSecretTab, false);
}
}, [isSecretTab, formik.values]);
// Sync modified state
useEffect(() => {
const currentValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
@@ -387,7 +354,7 @@ const EnvironmentVariablesTable = ({
name: '',
value: '',
type: 'text',
secret: isSecretTab,
secret: false,
enabled: true
}
];
@@ -402,16 +369,12 @@ const EnvironmentVariablesTable = ({
const isLastRow = index === formik.values.length - 1;
if (isLastRow) {
// Pin the newly-named row's secret flag to the active tab synchronously; the
// passive sync effect runs after paint and is racy for fast input.
formik.setFieldValue(`${index}.secret`, isSecretTab, false);
const newVariable = {
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: isSecretTab,
secret: false,
enabled: true
};
setTimeout(() => {
@@ -432,26 +395,25 @@ const EnvironmentVariablesTable = ({
};
const handleSave = useCallback(() => {
const belongsToActiveTab = (variable) => (isSecretTab ? !!variable.secret : !variable.secret);
const namedValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
const variablesToSave = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
const savedValues = environment.variables || [];
// Save is scoped to the active tab. Only the active tab's rows are persisted; the
// other tab keeps its last-saved rows so saving variables never touches secrets and
// vice versa.
const activeCurrent = namedValues.filter(belongsToActiveTab);
const activeSaved = savedValues.filter(belongsToActiveTab);
const otherCurrent = namedValues.filter((variable) => !belongsToActiveTab(variable));
const otherSaved = savedValues.filter((variable) => !belongsToActiveTab(variable));
const hasChanges = JSON.stringify(activeCurrent.map(stripEnvVarUid)) !== JSON.stringify(activeSaved.map(stripEnvVarUid));
// Compare against what's on disk: for an ephemeral overlay, that's
// `persistedValue`, not the scripted value Redux is holding.
const baselineForCompare = (v) => {
const stripped = stripEnvVarUid(v);
if (v?.ephemeral && v?.persistedValue !== undefined) {
stripped.value = v.persistedValue;
}
return stripped;
};
const hasChanges = JSON.stringify(variablesToSave.map(stripEnvVarUid)) !== JSON.stringify(savedValues.map(baselineForCompare));
if (!hasChanges) {
toast.error('No changes to save');
return;
}
const hasValidationErrors = activeCurrent.some((variable) => {
const hasValidationErrors = variablesToSave.some((variable) => {
if (!variable.name || variable.name.trim() === '') {
return true;
}
@@ -466,182 +428,72 @@ const EnvironmentVariablesTable = ({
return;
}
// Persist the active tab's edits alongside the other tab's last-saved rows (unchanged).
const persistedVariables = orderVarsBySecret([...activeCurrent, ...otherSaved]);
onSave(cloneDeep(persistedVariables))
.then(() => {
toast.success('Changes saved successfully');
// Preserve unsaved edits on the other tab across the post-save reinit via the
// draft: keep it if the other tab is still dirty, clear it otherwise.
const otherDirty
= JSON.stringify(otherCurrent.map(stripEnvVarUid)) !== JSON.stringify(otherSaved.map(stripEnvVarUid));
const retainedVariables = orderVarsBySecret([...activeCurrent, ...otherCurrent]);
if (otherDirty) {
onDraftChange(cloneDeep(retainedVariables));
} else {
onDraftClear();
}
formik.resetForm({
values: [
...retainedVariables,
{
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: isSecretTab,
enabled: true
}
]
});
setIsModified(otherDirty);
})
.catch((error) => {
console.error(error);
toast.error('An error occurred while saving the changes');
});
}, [formik.values, environment.variables, onSave, onDraftChange, onDraftClear, setIsModified, isSecretTab]);
const handleReset = useCallback(() => {
const belongsToActiveTab = (variable) => (isSecretTab ? !!variable.secret : !variable.secret);
const savedValues = environment.variables || [];
const activeSaved = savedValues.filter(belongsToActiveTab);
const otherSaved = savedValues.filter((variable) => !belongsToActiveTab(variable));
const otherCurrent = formik.values
.filter((variable) => variable.name && variable.name.trim() !== '')
.filter((variable) => !belongsToActiveTab(variable));
// Reset is scoped to the active tab: revert its rows to the saved baseline while
// leaving the other tab's current (possibly unsaved) edits intact.
const resetVariables = orderVarsBySecret([...activeSaved, ...otherCurrent]);
const otherDirty
= JSON.stringify(otherCurrent.map(stripEnvVarUid)) !== JSON.stringify(otherSaved.map(stripEnvVarUid));
if (otherDirty) {
onDraftChange(cloneDeep(resetVariables));
} else {
onDraftClear();
}
formik.resetForm({
values: [
...resetVariables,
{
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: isSecretTab,
enabled: true
}
]
});
setIsModified(otherDirty);
}, [environment.variables, formik.values, isSecretTab, onDraftChange, onDraftClear, setIsModified]);
const handleSaveAll = useCallback(() => {
const namedValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
const savedValues = environment.variables || [];
const persistedVariables = orderVarsBySecret(namedValues);
const hasChanges
= JSON.stringify(persistedVariables.map(stripEnvVarUid)) !== JSON.stringify(savedValues.map(stripEnvVarUid));
if (!hasChanges) {
toast.error('No changes to save');
return;
}
const hasValidationErrors = namedValues.some((variable) => {
if (!variable.name || variable.name.trim() === '') {
return true;
}
if (!variableNameRegex.test(variable.name)) {
return true;
}
return false;
});
if (hasValidationErrors) {
toast.error('Please fix validation errors before saving');
return;
}
onSave(cloneDeep(persistedVariables))
onSave(cloneDeep(variablesToSave))
.then(() => {
toast.success('Changes saved successfully');
onDraftClear();
formik.resetForm({
values: [
...persistedVariables,
{
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: isSecretTab,
enabled: true
}
]
});
const newValues = [
...variablesToSave,
{
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
}
];
formik.resetForm({ values: newValues });
setIsModified(false);
})
.catch((error) => {
console.error(error);
toast.error('An error occurred while saving the changes');
});
}, [formik.values, environment.variables, onSave, onDraftClear, setIsModified, isSecretTab]);
}, [formik.values, environment.variables, onSave, onDraftClear, setIsModified]);
const handleReset = useCallback(() => {
const originalVars = environment.variables || [];
const resetValues = [
...originalVars,
{
uid: uuid(),
name: '',
value: '',
type: 'text',
secret: false,
enabled: true
}
];
formik.resetForm({ values: resetValues });
setIsModified(false);
}, [environment.variables, setIsModified]);
const handleSaveRef = useRef(handleSave);
handleSaveRef.current = handleSave;
const handleSaveAllRef = useRef(handleSaveAll);
handleSaveAllRef.current = handleSaveAll;
useEffect(() => {
const handleSaveEvent = () => {
handleSaveRef.current();
};
const handleSaveAllEvent = () => {
handleSaveAllRef.current();
};
window.addEventListener('environment-save', handleSaveEvent);
window.addEventListener('environment-save-all', handleSaveAllEvent);
return () => {
window.removeEventListener('environment-save', handleSaveEvent);
window.removeEventListener('environment-save-all', handleSaveAllEvent);
};
}, []);
const filteredVariables = useMemo(() => {
const lastIndex = formik.values.length - 1;
// Show only rows belonging to the active tab, but always keep the trailing
// empty "add new" row so the user can add a variable/secret on either tab.
const tabVariables = formik.values
.map((variable, index) => ({ variable, index }))
.filter(({ variable, index }) => {
const isLastEmptyRow
= index === lastIndex && (!variable.name || (typeof variable.name === 'string' && variable.name.trim() === ''));
if (isLastEmptyRow) return true;
return isSecretTab ? !!variable.secret : !variable.secret;
});
const allVariables = formik.values.map((variable, index) => ({ variable, index }));
if (!searchQuery?.trim()) {
return tabVariables;
return allVariables;
}
const query = searchQuery.toLowerCase().trim();
const effectivePins = pinnedData.query === searchQuery ? pinnedData.uids : new Set();
return tabVariables.filter(({ variable }) => {
return allVariables.filter(({ variable }) => {
if (effectivePins.has(variable.uid)) return true;
const nameMatch = variable.name ? variable.name.toLowerCase().includes(query) : false;
const valueText
@@ -653,7 +505,7 @@ const EnvironmentVariablesTable = ({
const valueMatch = valueText.toLowerCase().includes(query);
return !!(nameMatch || valueMatch);
});
}, [formik.values, searchQuery, pinnedData, isSecretTab]);
}, [formik.values, searchQuery, pinnedData]);
const isSearchActive = !!searchQuery?.trim();
@@ -683,6 +535,7 @@ const EnvironmentVariablesTable = ({
/>
</td>
<td style={{ width: columnWidths.value }}>Value</td>
<td className="text-center">Secret</td>
<td></td>
</tr>
)}
@@ -748,6 +601,11 @@ const EnvironmentVariablesTable = ({
isSecret={variable.secret}
onChange={(newValue) => {
formik.setFieldValue(`${actualIndex}.value`, newValue, true);
// Clear ephemeral metadata when user manually edits the value
if (variable.ephemeral) {
formik.setFieldValue(`${actualIndex}.ephemeral`, undefined, false);
formik.setFieldValue(`${actualIndex}.persistedValue`, undefined, false);
}
// Append a new empty row when editing value on the last row
if (isLastRow) {
setTimeout(() => {
@@ -756,7 +614,7 @@ const EnvironmentVariablesTable = ({
name: '',
value: '',
type: 'text',
secret: isSecretTab,
secret: false,
enabled: true
}, false);
}, 0);
@@ -781,6 +639,17 @@ const EnvironmentVariablesTable = ({
)}
{renderExtraValueContent && renderExtraValueContent(variable)}
</td>
<td className="text-center">
{!isLastEmptyRow && (
<input
type="checkbox"
className="mousetrap"
name={`${actualIndex}.secret`}
checked={variable.secret}
onChange={formik.handleChange}
/>
)}
</td>
<td>
{!isLastEmptyRow && (
<button onClick={() => handleRemoveVar(variable.uid)}>

View File

@@ -33,15 +33,15 @@ const ConfirmCloseEnvironment = ({ onCancel, onCloseWithoutSave, onSaveAndClose,
<div className="flex justify-between mt-6">
<div>
<Button color="danger" onClick={onCloseWithoutSave} data-testid="env-unsaved-close-without-save">
<Button color="danger" onClick={onCloseWithoutSave}>
Don't Save
</Button>
</div>
<div className="flex gap-2">
<Button size="sm" color="secondary" variant="ghost" onClick={onCancel} data-testid="env-unsaved-cancel">
<Button size="sm" color="secondary" variant="ghost" onClick={onCancel}>
Cancel
</Button>
<Button onClick={onSaveAndClose} data-testid="env-unsaved-save-and-close">
<Button onClick={onSaveAndClose}>
Save
</Button>
</div>

View File

@@ -21,14 +21,14 @@ const DeleteEnvironment = ({ onClose, environment, collection }) => {
<Portal>
<StyledWrapper>
<Modal
size="md"
size="sm"
title="Delete Environment"
confirmText="Delete"
handleConfirm={onConfirm}
handleCancel={onClose}
confirmButtonColor="danger"
>
Are you sure you want to delete <span className="font-medium">{environment.name}</span>?
Are you sure you want to delete <span className="font-medium">{environment.name}</span> ?
</Modal>
</StyledWrapper>
</Portal>

View File

@@ -9,7 +9,7 @@ import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
import EnvironmentVariablesTable from 'components/EnvironmentVariablesTable';
import { sensitiveFields } from './constants';
const EnvironmentVariables = ({ environment, setIsModified, collection, searchQuery = '', variableType = 'variables' }) => {
const EnvironmentVariables = ({ environment, setIsModified, collection, searchQuery = '' }) => {
const dispatch = useDispatch();
const environmentsDraft = collection?.environmentsDraft;
@@ -92,7 +92,7 @@ const EnvironmentVariables = ({ environment, setIsModified, collection, searchQu
return (
<SensitiveFieldWarning
fieldName={variable.name}
warningMessage="This variable is used in sensitive fields. Add it as a secret in the Secrets tab for security"
warningMessage="This variable is used in sensitive fields. Mark it as a secret for security"
/>
);
}
@@ -113,7 +113,6 @@ const EnvironmentVariables = ({ environment, setIsModified, collection, searchQu
setIsModified={setIsModified}
renderExtraValueContent={renderExtraValueContent}
searchQuery={searchQuery}
variableType={variableType}
/>
);
};

View File

@@ -96,17 +96,6 @@ const StyledWrapper = styled.div`
display: flex;
align-items: center;
gap: 2px;
}
}
.tabs-container {
padding: 0 20px;
flex-shrink: 0;
.env-search-container {
display: flex;
align-items: center;
gap: 2px;
.search-input-wrapper {
position: relative;
@@ -161,6 +150,30 @@ const StyledWrapper = styled.div`
}
}
}
button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
color: ${(props) => props.theme.colors.text.muted};
background: transparent;
border: none;
border-radius: 5px;
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
color: ${(props) => props.theme.text};
}
&:last-child:hover {
color: ${(props) => props.theme.colors.text.danger};
}
}
}
}
@@ -170,7 +183,6 @@ const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
padding: 0 20px 20px 20px;
margin-top: 16px;
}
`;

View File

@@ -1,6 +1,6 @@
import { IconCopy, IconEdit, IconTrash, IconCheck, IconX, IconSearch, IconDeviceFloppy } from '@tabler/icons';
import { IconCopy, IconEdit, IconTrash, IconCheck, IconX, IconSearch } from '@tabler/icons';
import { useState, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import { renameEnvironment, updateEnvironmentColor } from 'providers/ReduxStore/slices/collections/actions';
import { validateName, validateNameError } from 'utils/common/regex';
import toast from 'react-hot-toast';
@@ -8,16 +8,8 @@ import CopyEnvironment from 'components/Environments/EnvironmentSettings/CopyEnv
import DeleteEnvironment from 'components/Environments/EnvironmentSettings/DeleteEnvironment';
import EnvironmentVariables from './EnvironmentVariables';
import ColorPicker from 'components/ColorPicker';
import ActionIcon from 'ui/ActionIcon';
import ResponsiveTabs from 'ui/ResponsiveTabs';
import { updateTabState } from 'providers/ReduxStore/slices/tabs';
import StyledWrapper from './StyledWrapper';
const TABS = [
{ key: 'variables', label: 'Variables' },
{ key: 'secrets', label: 'Secrets' }
];
const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuery, setSearchQuery, isSearchExpanded, setIsSearchExpanded, debouncedSearchQuery, searchInputRef }) => {
const dispatch = useDispatch();
const environments = collection?.environments || [];
@@ -27,11 +19,7 @@ const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuer
const [isRenaming, setIsRenaming] = useState(false);
const [newName, setNewName] = useState('');
const [nameError, setNameError] = useState('');
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const activeTab = useSelector((state) => state.tabs.tabs.find((t) => t.uid === activeTabUid)?.tabState?.envTab) || 'variables';
const setActiveTab = (tab) => dispatch(updateTabState({ uid: activeTabUid, tabState: { envTab: tab } }));
const inputRef = useRef(null);
const rightContentRef = useRef(null);
const validateEnvironmentName = (name) => {
if (!name || name.trim() === '') {
@@ -145,10 +133,6 @@ const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuer
dispatch(updateEnvironmentColor(environment.uid, color, collection.uid));
};
const handleSaveAll = () => {
window.dispatchEvent(new Event('environment-save-all'));
};
return (
<StyledWrapper>
{openDeleteModal && (
@@ -203,66 +187,48 @@ const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuer
</div>
{nameError && isRenaming && <div className="title-error">{nameError}</div>}
<div className="actions">
<ActionIcon label="Save All" onClick={handleSaveAll} data-testid="save-all-env">
<IconDeviceFloppy size={15} strokeWidth={1.5} />
</ActionIcon>
<ActionIcon label="Rename" onClick={handleRenameClick} data-testid="env-rename-action">
<IconEdit size={15} strokeWidth={1.5} />
</ActionIcon>
<ActionIcon label="Copy" onClick={() => setOpenCopyModal(true)} data-testid="env-copy-action">
<IconCopy size={15} strokeWidth={1.5} />
</ActionIcon>
<ActionIcon label="Delete" onClick={() => setOpenDeleteModal(true)} colorOnHover="danger" data-testid="env-delete-action">
<IconTrash size={15} strokeWidth={1.5} />
</ActionIcon>
</div>
</div>
<div className="tabs-container">
<ResponsiveTabs
tabs={TABS}
activeTab={activeTab}
onTabSelect={setActiveTab}
rightContent={(
<div ref={rightContentRef} className="env-search-container">
{isSearchExpanded ? (
<div className="search-input-wrapper">
<IconSearch size={14} strokeWidth={1.5} className="search-icon" />
<input
ref={searchInputRef}
type="text"
placeholder={activeTab === 'secrets' ? 'Search secrets...' : 'Search variables...'}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onBlur={handleSearchBlur}
className="search-input"
data-testid="env-search-input"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
{searchQuery && (
<button
className="clear-search"
onClick={handleClearSearch}
onMouseDown={(e) => e.preventDefault()}
title="Clear search"
data-testid="env-clear-search"
>
<IconX size={14} strokeWidth={1.5} />
</button>
)}
</div>
) : (
<ActionIcon label="Search" onClick={handleSearchIconClick} data-testid="env-search-action">
<IconSearch size={15} strokeWidth={1.5} />
</ActionIcon>
{isSearchExpanded ? (
<div className="search-input-wrapper">
<IconSearch size={14} strokeWidth={1.5} className="search-icon" />
<input
ref={searchInputRef}
type="text"
placeholder="Search variables..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onBlur={handleSearchBlur}
className="search-input"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
{searchQuery && (
<button
className="clear-search"
onClick={handleClearSearch}
onMouseDown={(e) => e.preventDefault()}
title="Clear search"
>
<IconX size={14} strokeWidth={1.5} />
</button>
)}
</div>
) : (
<button onClick={handleSearchIconClick} title="Search variables">
<IconSearch size={15} strokeWidth={1.5} />
</button>
)}
rightContentRef={rightContentRef}
/>
<button onClick={handleRenameClick} title="Rename">
<IconEdit size={15} strokeWidth={1.5} />
</button>
<button onClick={() => setOpenCopyModal(true)} title="Copy">
<IconCopy size={15} strokeWidth={1.5} />
</button>
<button onClick={() => setOpenDeleteModal(true)} title="Delete">
<IconTrash size={15} strokeWidth={1.5} />
</button>
</div>
</div>
<div className="content">
@@ -271,7 +237,6 @@ const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuer
setIsModified={setIsModified}
collection={collection}
searchQuery={debouncedSearchQuery}
variableType={activeTab}
/>
</div>
</StyledWrapper>

View File

@@ -45,7 +45,7 @@ const StyledWrapper = styled.div`
color: ${(props) => props.theme.colors.text.muted};
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
color: ${(props) => props.theme.text};
@@ -79,7 +79,7 @@ const StyledWrapper = styled.div`
&::placeholder {
color: ${(props) => props.theme.colors.text.muted};
}
&:focus {
outline: none;
border-color: ${(props) => props.theme.colors.accent};
@@ -111,7 +111,6 @@ const StyledWrapper = styled.div`
flex-direction: column;
overflow: hidden;
padding: 8px;
border-right: 1px solid ${(props) => props.theme.border.border0};
}
.section-header {
@@ -164,7 +163,7 @@ const StyledWrapper = styled.div`
cursor: pointer;
border-radius: 6px;
transition: background 0.15s ease;
.environment-name {
flex: 1;
white-space: nowrap;
@@ -217,18 +216,18 @@ const StyledWrapper = styled.div`
&:hover {
background: ${(props) => props.theme.workspace.button.bg};
}
&.active {
background: ${(props) => props.theme.background.surface0};
color: ${(props) => props.theme.text};
}
&.renaming,
&.creating {
cursor: default;
padding: 4px 4px 4px 8px;
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
&:hover {
background: ${(props) => props.theme.workspace.button.bg};
}
@@ -240,7 +239,7 @@ const StyledWrapper = styled.div`
flex: 1;
min-width: 0;
overflow: hidden;
.environment-name-input {
flex: 1;
min-width: 0;
@@ -250,12 +249,12 @@ const StyledWrapper = styled.div`
color: ${(props) => props.theme.text};
font-size: 13px;
padding: 2px 4px;
&::placeholder {
color: ${(props) => props.theme.colors.text.muted};
}
}
.inline-actions {
display: flex;
gap: 2px;
@@ -274,12 +273,12 @@ const StyledWrapper = styled.div`
color: ${(props) => props.theme.text};
font-size: 13px;
padding: 2px 4px;
&::placeholder {
color: ${(props) => props.theme.colors.text.muted};
}
}
.inline-actions {
display: flex;
gap: 2px;
@@ -300,25 +299,25 @@ const StyledWrapper = styled.div`
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
&.save {
color: ${(props) => props.theme.colors.text.green};
&:hover {
background: ${(props) => rgba(props.theme.colors.text.green, 0.1)};
}
}
&.cancel {
color: ${(props) => props.theme.colors.text.danger};
&:hover {
background: ${(props) => rgba(props.theme.colors.text.danger, 0.1)};
}
}
}
}
.env-error {
padding: 4px 12px;
margin-top: 4px;

View File

@@ -1,33 +1,16 @@
import React, { useState, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { updateTabState } from 'providers/ReduxStore/slices/tabs';
import React, { useState } from 'react';
import EnvironmentList from './EnvironmentList';
import StyledWrapper from './StyledWrapper';
import ExportEnvironmentModal from 'components/Environments/Common/ExportEnvironmentModal';
const EnvironmentSettings = ({ collection }) => {
const dispatch = useDispatch();
const [isModified, setIsModified] = useState(false);
const environments = collection?.environments || [];
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const persistedEnvUid = useSelector((state) => state.tabs.tabs.find((t) => t.uid === activeTabUid)?.tabState?.envUid);
// Remember which environment the user last viewed in this tab (via tabState) so navigating away and back preserves it.
const selectedEnvironment = useMemo(() => {
const [selectedEnvironment, setSelectedEnvironment] = useState(() => {
if (!environments.length) return null;
return (
environments.find((env) => env.uid === persistedEnvUid)
|| environments.find((env) => env.uid === collection?.activeEnvironmentUid)
|| environments[0]
);
}, [environments, persistedEnvUid, collection?.activeEnvironmentUid]);
const setSelectedEnvironment = (env) => {
if (!activeTabUid || !env?.uid) return;
dispatch(updateTabState({ uid: activeTabUid, tabState: { envUid: env.uid } }));
};
return environments.find((env) => env.uid === collection?.activeEnvironmentUid) || environments[0];
});
const [showExportModal, setShowExportModal] = useState(false);
return (

View File

@@ -4,8 +4,6 @@ const StyledWrapper = styled.div`
color: ${(props) => props.theme.colors.danger};
pre {
color: ${(props) => props.theme.colors.danger};
max-height: 60vh;
overflow: auto;
}
`;

View File

@@ -1,27 +1,26 @@
import ApiKeyAuth from 'components/RequestPane/Auth/ApiKeyAuth';
import AwsV4Auth from 'components/RequestPane/Auth/AwsV4Auth';
import React, { useMemo } from 'react';
import get from 'lodash/get';
import StyledWrapper from './StyledWrapper';
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import OAuth2AuthorizationCode from 'components/RequestPane/Auth/OAuth2/AuthorizationCode/index';
import { updateFolderAuth as _updateFolderAuth } from 'providers/ReduxStore/slices/collections';
import { useDispatch } from 'react-redux';
import OAuth2PasswordCredentials from 'components/RequestPane/Auth/OAuth2/PasswordCredentials/index';
import OAuth2ClientCredentials from 'components/RequestPane/Auth/OAuth2/ClientCredentials/index';
import OAuth2Implicit from 'components/RequestPane/Auth/OAuth2/Implicit/index';
import GrantTypeSelector from 'components/RequestPane/Auth/OAuth2/GrantTypeSelector/index';
import AuthMode from '../AuthMode';
import BasicAuth from 'components/RequestPane/Auth/BasicAuth';
import BearerAuth from 'components/RequestPane/Auth/BearerAuth';
import DigestAuth from 'components/RequestPane/Auth/DigestAuth';
import EdgeGridAuth from 'components/RequestPane/Auth/EdgeGridAuth';
import NTLMAuth from 'components/RequestPane/Auth/NTLMAuth';
import OAuth1 from 'components/RequestPane/Auth/OAuth1';
import OAuth2AuthorizationCode from 'components/RequestPane/Auth/OAuth2/AuthorizationCode/index';
import OAuth2ClientCredentials from 'components/RequestPane/Auth/OAuth2/ClientCredentials/index';
import GrantTypeSelector from 'components/RequestPane/Auth/OAuth2/GrantTypeSelector/index';
import OAuth2Implicit from 'components/RequestPane/Auth/OAuth2/Implicit/index';
import OAuth2PasswordCredentials from 'components/RequestPane/Auth/OAuth2/PasswordCredentials/index';
import WsseAuth from 'components/RequestPane/Auth/WsseAuth';
import get from 'lodash/get';
import { updateFolderAuth as _updateFolderAuth } from 'providers/ReduxStore/slices/collections';
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import React, { useMemo } from 'react';
import { useDispatch } from 'react-redux';
import ApiKeyAuth from 'components/RequestPane/Auth/ApiKeyAuth';
import AwsV4Auth from 'components/RequestPane/Auth/AwsV4Auth';
import { humanizeRequestAuthMode } from 'utils/collections/index';
import Button from 'ui/Button';
import { getEffectiveAuthSource } from 'utils/auth';
import { humanizeRequestAuthMode } from 'utils/collections/index';
import AuthMode from '../AuthMode';
import StyledWrapper from './StyledWrapper';
const GrantTypeComponentMap = ({ collection, folder, updateFolderAuth }) => {
const dispatch = useDispatch();
@@ -183,19 +182,6 @@ const Auth = ({ collection, folder }) => {
</>
);
}
case 'akamai-edgegrid': {
return (
<>
<EdgeGridAuth
collection={collection}
item={folder}
updateAuth={updateFolderAuth}
request={request}
save={() => handleSave()}
/>
</>
);
}
case 'none': {
return null;
}

View File

@@ -2,7 +2,6 @@ import React, { useMemo, useCallback } from 'react';
import get from 'lodash/get';
import { IconCaretDown } from '@tabler/icons';
import MenuDropdown from 'ui/MenuDropdown';
import StatusBadge from 'ui/StatusBadge/index';
import { useDispatch } from 'react-redux';
import { updateFolderAuthMode } from 'providers/ReduxStore/slices/collections';
import { humanizeRequestAuthMode } from 'utils/collections';
@@ -68,17 +67,6 @@ const AuthMode = ({ collection, folder }) => {
label: 'API Key',
onClick: () => onModeChange('apikey')
},
{
id: 'akamai-edgegrid',
label: (
<span className="flex items-center gap-2">
Akamai EdgeGrid
<StatusBadge status="info" size="xs">Beta</StatusBadge>
</span>
),
ariaLabel: 'Akamai EdgeGrid (Beta)',
onClick: () => onModeChange('akamai-edgegrid')
},
{
id: 'inherit',
label: 'Inherit',

View File

@@ -4,13 +4,11 @@ import find from 'lodash/find';
import { updateFolderDocs } from 'providers/ReduxStore/slices/collections';
import { updateDocsEditing } from 'providers/ReduxStore/slices/tabs';
import { useTheme } from 'providers/Theme';
import { useMemo, useRef } from 'react';
import { useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import Markdown from 'components/MarkDown';
import CodeEditor from 'components/CodeEditor';
import AIAssist from 'components/AIAssist';
import { buildAiVariablesPayload, buildDocsContextFromFolder } from 'utils/ai';
import Button from 'ui/Button';
import StyledWrapper from './StyledWrapper';
import { usePersistedState } from 'hooks/usePersistedState';
@@ -45,8 +43,6 @@ const Documentation = ({ collection, folder }) => {
};
const onSave = () => dispatch(saveFolderRoot(collection.uid, folder.uid));
const docsContext = useMemo(() => buildDocsContextFromFolder(collection, folder), [collection, folder]);
const aiVariables = useMemo(() => buildAiVariablesPayload(collection, folder), [collection, folder]);
if (!folder) {
return null;
@@ -60,7 +56,7 @@ const Documentation = ({ collection, folder }) => {
{isEditing ? (
<div className="flex flex-col flex-1 min-h-0">
<div className="mt-2 flex-1 overflow-auto min-h-0 relative">
<div className="mt-2 flex-1 overflow-auto min-h-0">
<CodeEditor
collection={collection}
theme={displayedTheme}
@@ -73,7 +69,6 @@ const Documentation = ({ collection, folder }) => {
initialScroll={scroll}
onScroll={setScroll}
/>
<AIAssist scriptType="docs" currentScript={docs || ''} docsContext={docsContext} variables={aiVariables} onApply={onEdit} />
</div>
<div className="mt-6 flex-shrink-0">
<Button type="submit" size="sm" onClick={onSave}>

View File

@@ -1,10 +1,9 @@
import React, { useEffect, useMemo, useRef } from 'react';
import React, { useEffect, useRef } from 'react';
import get from 'lodash/get';
import find from 'lodash/find';
import { useDispatch, useSelector } from 'react-redux';
import CodeEditor from 'components/CodeEditor';
import AIAssist from 'components/AIAssist';
import { buildAiVariablesPayload } from 'utils/ai';
import { updateFolderRequestScript, updateFolderResponseScript } from 'providers/ReduxStore/slices/collections';
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import { updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs';
@@ -103,8 +102,6 @@ const Script = ({ collection, folder }) => {
dispatch(saveFolderRoot(collection.uid, folder.uid));
};
const aiVariables = useMemo(() => buildAiVariablesPayload(collection, folder), [collection, folder]);
const items = flattenItems(folder.items || []);
const hasPreRequestScriptError = items.some((i) => isItemARequest(i) && i.preRequestScriptErrorMessage);
const hasPostResponseScriptError = items.some((i) => isItemARequest(i) && i.postResponseScriptErrorMessage);
@@ -145,14 +142,12 @@ const Script = ({ collection, folder }) => {
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'bru']}
scriptType="pre-request"
initialScroll={preReqScroll}
onScroll={setPreReqScroll}
/>
<AIAssist
scriptType="pre-request"
currentScript={requestScript || ''}
variables={aiVariables}
onApply={onRequestScriptEdit}
/>
</div>
@@ -172,14 +167,12 @@ const Script = ({ collection, folder }) => {
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
scriptType="post-response"
initialScroll={postResScroll}
onScroll={setPostResScroll}
/>
<AIAssist
scriptType="post-response"
currentScript={responseScript || ''}
variables={aiVariables}
onApply={onResponseScriptEdit}
/>
</div>

View File

@@ -1,9 +1,8 @@
import React, { useMemo, useRef } from 'react';
import React, { useRef } from 'react';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import CodeEditor from 'components/CodeEditor';
import AIAssist from 'components/AIAssist';
import { buildAiVariablesPayload } from 'utils/ai';
import { updateFolderTests } from 'providers/ReduxStore/slices/collections';
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
import { useTheme } from 'providers/Theme';
@@ -39,8 +38,6 @@ const Tests = ({ collection, folder }) => {
scriptPhase: 'test'
});
const aiVariables = useMemo(() => buildAiVariablesPayload(collection, folder), [collection, folder]);
return (
<StyledWrapper className="w-full flex flex-col h-full">
<div className="text-xs mb-4 text-muted">These tests will run any time a request in this collection is sent.</div>
@@ -60,7 +57,7 @@ const Tests = ({ collection, folder }) => {
initialScroll={testsScroll}
onScroll={setTestsScroll}
/>
<AIAssist scriptType="tests" currentScript={tests || ''} variables={aiVariables} onApply={onEdit} />
<AIAssist scriptType="tests" currentScript={tests || ''} onApply={onEdit} />
</div>
<div className="mt-6">

View File

@@ -6,9 +6,9 @@ import { isValidUrl } from 'utils/url/index';
import DOMPurify from 'dompurify';
import { useMemo } from 'react';
const Markdown = ({ collectionPath, onDoubleClick, content, allowHtml = true }) => {
const Markdown = ({ collectionPath, onDoubleClick, content }) => {
const markdownItOptions = {
html: allowHtml,
html: true,
breaks: true,
linkify: true,
replaceLink: function (link, env) {
@@ -35,7 +35,7 @@ const Markdown = ({ collectionPath, onDoubleClick, content, allowHtml = true })
};
const md = new MarkdownIt(markdownItOptions).use(MarkdownItReplaceLink);
const htmlFromMarkdown = useMemo(() => md.render(content || ''), [content, collectionPath, allowHtml]);
const htmlFromMarkdown = useMemo(() => md.render(content || ''), [content, collectionPath]);
const cleanHTML = useMemo(() => DOMPurify.sanitize(htmlFromMarkdown), [htmlFromMarkdown]);
return (

View File

@@ -219,7 +219,7 @@ class MultiLineEditor extends Component {
*/
secretEye = (isSecret) => {
return isSecret === true ? (
<button className="mx-2" data-testid="secret-reveal-toggle" onClick={() => this.toggleVisibleSecret()}>
<button className="mx-2" onClick={() => this.toggleVisibleSecret()}>
{this.state.maskInput === true ? (
<IconEyeOff size={18} strokeWidth={2} />
) : (

View File

@@ -1,4 +1,4 @@
import Markdown from 'components/MarkDown';
import DOMPurify from 'dompurify';
import { parseToRgb, rgba } from 'polished';
import { useTheme } from 'providers/Theme';
import { humanizeDate } from 'utils/common';
@@ -19,9 +19,47 @@ export const getBadgeStyle = (color, theme) => {
};
};
const getSanitizedDescription = (description) => {
return DOMPurify.sanitize(description || '', {
ALLOWED_TAGS: ['a', 'ul', 'img', 'li', 'div', 'span', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'br', 'strong', 'em'],
ALLOWED_ATTR: ['href', 'style', 'target', 'src', 'alt']
});
};
const NotificationDetail = ({ notification }) => {
const { theme } = useTheme();
// Rendered in a sandboxed iframe (no allow-scripts); theme CSS is inlined
// since the iframe doesn't inherit app styles.
const buildDescriptionDocument = (description) => {
const body = getSanitizedDescription(description);
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<base target="_blank" />
<style>
html, body { margin: 0; padding: 0; background: ${theme.notifications.bg}; }
body {
padding: 8px 12px;
font-family: Inter, sans-serif;
font-size: 12px;
line-height: 20px;
font-weight: 500;
color: ${theme.colors.text.muted};
word-break: break-word;
}
p { margin: 0 0 0.75rem 0; }
a { color: ${theme.textLink}; text-decoration: underline; }
h1, h2, h3, h4, h5, h6 { font-size: 13px; font-weight: 600; margin: 0 0 0.5rem 0; color: ${theme.text}; }
ul { padding-left: 1.25rem; margin: 0 0 0.75rem 0; }
img { max-width: 100%; }
</style>
</head>
<body>${body}</body>
</html>`;
};
if (!notification) {
return (
<div className="notif-detail">
@@ -43,9 +81,13 @@ const NotificationDetail = ({ notification }) => {
</div>
<div className="notif-detail-title">{notification.title}</div>
</div>
<div key={notification.id} className="notif-detail-body">
<Markdown content={notification.description} allowHtml={false} onDoubleClick={() => {}} />
</div>
<iframe
key={notification.id}
className="notif-detail-body"
title="Notification details"
sandbox="allow-popups"
srcDoc={buildDescriptionDocument(notification.description)}
/>
</div>
);
};

View File

@@ -244,50 +244,8 @@ const StyledWrapper = styled.div`
flex: 1;
min-height: 0;
width: 100%;
overflow-y: auto;
padding: 8px 12px;
.markdown-body {
background: transparent;
font-size: 12px;
line-height: 20px;
font-weight: 500;
color: ${(props) => props.theme.colors.text.muted};
word-break: break-word;
p {
margin: 0 0 0.75rem 0;
white-space: normal;
}
a {
color: ${(props) => props.theme.textLink};
text-decoration: underline;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-size: 13px;
font-weight: 600;
margin: 0 0 0.5rem 0;
padding: 0;
border: none;
color: ${(props) => props.theme.text};
}
ul {
padding-left: 1.25rem;
margin: 0 0 0.75rem 0;
}
img {
max-width: 100%;
}
}
border: none;
background: transparent;
}
.notif-empty {

View File

@@ -1,160 +0,0 @@
import { IconChevronDown } from '@tabler/icons';
import ToggleSwitch from 'components/ToggleSwitch';
/**
* Autocomplete tab content. Sibling of the Configuration tab inside
* Preferences > AI.
*
* - master AI off → notice only; the whole card is hidden
* - no provider configured → notice in the card body, controls disabled
* - no enabled model → notice in the card body, controls disabled
* - everything on → fully interactive
*/
const TRIGGER_MODES = [
{
value: 'aggressive',
label: 'Aggressive',
description: 'Suggest after every keystroke'
},
{
value: 'debounced',
label: 'Debounced',
description: 'Suggest after you pause typing (default)'
},
{
value: 'manual',
label: 'Manual',
description: 'Only on ⌘+\\ / Ctrl+\\'
}
];
const AutocompletePane = ({
aiEnabled,
enabled,
model,
triggerMode,
availableModels,
hasConfiguredProvider,
onToggleEnabled,
onChangeModel,
onChangeTriggerMode
}) => {
if (!aiEnabled) {
return (
<div className="autocomplete-tab flex flex-col gap-3">
<div className="ai-empty-notice px-3.5 py-3 text-xs">
Turn on AI in the Configuration tab to use autocomplete.
</div>
</div>
);
}
const hasUsableModel = availableModels.length > 0;
const isInteractive = enabled && hasUsableModel;
const activeTrigger = TRIGGER_MODES.find((m) => m.value === (triggerMode || 'debounced'));
// Surface the most actionable blocker first when the user can't actually
// get suggestions yet.
let blockerMessage = null;
if (!hasConfiguredProvider) {
blockerMessage = 'Add a provider API key in the Configuration tab to enable autocomplete.';
} else if (!hasUsableModel) {
blockerMessage = 'No models are available. Enable a model on its provider card in Configuration.';
}
return (
<div className="autocomplete-tab flex flex-col gap-3">
<div className="autocomplete-card">
<div className="autocomplete-header flex items-center justify-between gap-3 px-3.5 py-3">
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-[12.5px] font-semibold">Inline Autocomplete</span>
<span className="autocomplete-sub text-[11px]">
Ghost-text suggestions in Pre-Request, Post-Response, and Tests scripts
</span>
</div>
<ToggleSwitch
size="m"
isOn={enabled}
handleToggle={() => onToggleEnabled(!enabled)}
data-testid="ai-autocomplete-enabled-toggle"
/>
</div>
</div>
<div className={`autocomplete-card ${enabled ? '' : 'dimmed'}`}>
{blockerMessage && (
<div className="autocomplete-blocker px-3.5 py-3 text-[11px]">
{blockerMessage}
</div>
)}
<div className="autocomplete-row flex items-center justify-between gap-3 px-3.5 py-3">
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-[11.5px] font-medium">Model</span>
<span className="autocomplete-sub text-[10.5px]">
{hasUsableModel
? 'Lightweight models are recommended for speed'
: 'No model available yet'}
</span>
</div>
<div className="model-select-wrap relative inline-flex items-center">
<select
className="model-select"
value={model || ''}
disabled={!isInteractive}
onChange={(e) => onChangeModel(e.target.value)}
aria-label="Autocomplete model"
data-testid="ai-autocomplete-model-select"
>
<option value="">Auto (fastest available)</option>
{availableModels.map((m) => (
<option key={m.id} value={m.id}>{m.label}</option>
))}
</select>
<IconChevronDown size={12} strokeWidth={1.75} className="model-select-chevron" />
</div>
</div>
<div className="autocomplete-row flex items-center justify-between gap-3 px-3.5 py-3">
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-[11.5px] font-medium">Trigger</span>
<span className="autocomplete-sub text-[10.5px]">
{activeTrigger?.description}
</span>
</div>
<div className="trigger-pills inline-flex" role="radiogroup" aria-label="Trigger mode">
{TRIGGER_MODES.map((m) => {
const isSelected = (triggerMode || 'debounced') === m.value;
return (
<button
key={m.value}
type="button"
role="radio"
aria-checked={isSelected}
className={`trigger-pill ${isSelected ? 'selected' : ''}`}
disabled={!isInteractive}
onClick={() => onChangeTriggerMode(m.value)}
data-testid={`ai-autocomplete-trigger-${m.value}`}
>
{m.label}
</button>
);
})}
</div>
</div>
<div className="autocomplete-row px-3.5 py-3">
<div className="flex flex-col gap-1">
<span className="text-[11.5px] font-medium">Keymap</span>
<div className="autocomplete-keymap text-[10.5px]">
<kbd>Tab</kbd> accept · <kbd>Esc</kbd> dismiss · <kbd></kbd>+<kbd>\</kbd> trigger
</div>
</div>
</div>
</div>
</div>
);
};
export default AutocompletePane;

View File

@@ -1,467 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { v4 as uuid } from 'uuid';
import {
IconAlertCircle,
IconBolt,
IconCheck,
IconChevronDown,
IconEye,
IconEyeOff,
IconLoader2,
IconPencil,
IconPlus,
IconServer,
IconTrash,
IconX
} from '@tabler/icons';
import toast from 'react-hot-toast';
import { clearAiApiKey, getAiApiKey, setAiApiKey, testAiProvider } from 'utils/ai';
const stopBubble = (e) => e.stopPropagation();
const CompatEndpointCard = ({
endpoint,
provider,
providerEnabled,
providerToggle,
pending,
isModelEnabled,
onToggleModel,
onChangeName,
onChangeBaseURL,
onAddModel,
onRemoveModel,
onUpdateModel,
onRemoveEndpoint,
onStatusChange
}) => {
const [expanded, setExpanded] = useState(!endpoint.baseURL);
const [keyDraft, setKeyDraft] = useState('');
const [editing, setEditing] = useState(false);
const [showKey, setShowKey] = useState(false);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const [feedback, setFeedback] = useState(null);
const [newModelId, setNewModelId] = useState('');
const [newModelLabel, setNewModelLabel] = useState('');
const prev = useRef({ enabled: providerEnabled });
useEffect(() => {
const was = prev.current;
if (!was.enabled && providerEnabled) setExpanded(true);
else if (was.enabled && !providerEnabled) setExpanded(false);
prev.current = { enabled: providerEnabled };
}, [providerEnabled]);
const isEditingKey = editing || !provider.configured;
const handleSaveKey = async () => {
const trimmed = keyDraft.trim();
if (!trimmed) return;
setSaving(true);
setFeedback(null);
try {
const status = await setAiApiKey({ providerId: provider.id, apiKey: trimmed });
onStatusChange?.(status);
setKeyDraft('');
setShowKey(false);
setEditing(false);
setFeedback({ type: 'success', message: 'API key saved' });
} catch (err) {
setFeedback({ type: 'error', message: err.message || 'Failed to save API key' });
} finally {
setSaving(false);
}
};
const handleClearKey = async () => {
setFeedback(null);
try {
const status = await clearAiApiKey({ providerId: provider.id });
onStatusChange?.(status);
setEditing(false);
setKeyDraft('');
toast.success(`${endpoint.name || 'Endpoint'} API key removed`);
} catch (err) {
toast.error(err.message || 'Failed to clear API key');
}
};
const handleTest = async () => {
setTesting(true);
setFeedback(null);
try {
const result = await testAiProvider({ providerId: provider.id });
if (result.ok) {
setFeedback({ type: 'success', message: 'Connection successful' });
} else {
setFeedback({ type: 'error', message: result.error || 'Connection failed' });
}
} catch (err) {
setFeedback({ type: 'error', message: err.message || 'Connection failed' });
} finally {
setTesting(false);
}
};
const handleStartEditKey = async () => {
setEditing(true);
setFeedback(null);
try {
const current = await getAiApiKey({ providerId: provider.id });
setKeyDraft(current || '');
} catch (err) {
setKeyDraft('');
}
};
const handleCancelEditKey = () => {
setEditing(false);
setKeyDraft('');
setShowKey(false);
setFeedback(null);
};
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (keyDraft.trim() && !saving) handleSaveKey();
} else if (e.key === 'Escape' && provider.configured) {
e.preventDefault();
handleCancelEditKey();
}
};
const handleAddModel = () => {
const id = newModelId.trim();
if (!id) return;
onAddModel({
id: uuid(),
modelId: id,
label: newModelLabel.trim() || id
});
setNewModelId('');
setNewModelLabel('');
};
const handleAddModelKeyDown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddModel();
}
};
const models = endpoint.models || [];
const enabledModelsCount = models.filter((m) => isModelEnabled(m.id)).length;
return (
<div className={`provider-row ${expanded ? 'expanded' : ''}`} data-testid={`ai-endpoint-${endpoint.id}`}>
<div
className="provider-header flex items-center justify-between gap-3 px-3 py-2.5 cursor-pointer select-none"
onClick={() => setExpanded(!expanded)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setExpanded(!expanded);
}
}}
>
<div className="flex items-center gap-2.5 min-w-0 flex-1">
<IconServer size={16} strokeWidth={1.5} className="provider-logo flex-shrink-0" />
<div className="flex flex-col min-w-0">
<span className="font-semibold text-[12.5px] truncate">{endpoint.name || 'Unnamed endpoint'}</span>
{endpoint.baseURL && (
<span className="provider-status text-[10.5px] truncate">{endpoint.baseURL}</span>
)}
</div>
</div>
<div className="flex items-center gap-2.5 flex-shrink-0">
<span className={`provider-status inline-flex items-center gap-1.5 text-[11px] ${provider.configured ? 'configured' : ''}`}>
<span className={`status-dot w-[7px] h-[7px] rounded-full ${provider.configured ? 'configured' : ''}`} />
{provider.configured
? `${enabledModelsCount}/${models.length} model${models.length === 1 ? '' : 's'}`
: 'Not configured'}
</span>
<span className="flex items-center" onClick={stopBubble}>
{providerToggle}
</span>
<span className={`chevron flex items-center ${expanded ? 'expanded' : ''}`}>
<IconChevronDown size={16} strokeWidth={1.5} />
</span>
</div>
</div>
<div className={`provider-body-wrapper ${expanded ? 'open' : ''}`}>
<div className="provider-body-inner">
<div className="provider-body flex flex-col gap-3.5 px-3 pt-3 pb-3">
{/* Endpoint details */}
<div className="grid grid-cols-2 gap-2" onClick={stopBubble}>
<div className="flex flex-col gap-1">
<label className="key-section-label text-[11px]" htmlFor={`endpoint-name-${endpoint.id}`}>
Name
</label>
<input
id={`endpoint-name-${endpoint.id}`}
type="text"
className="key-input w-full h-8 box-border text-xs leading-none pl-2.5 pr-2"
placeholder="e.g. Ollama local"
value={endpoint.name || ''}
onChange={(e) => onChangeName(e.target.value)}
onClick={stopBubble}
/>
</div>
<div className="flex flex-col gap-1">
<label className="key-section-label text-[11px]" htmlFor={`endpoint-baseurl-${endpoint.id}`}>
Base URL
</label>
<input
id={`endpoint-baseurl-${endpoint.id}`}
type="text"
className="key-input w-full h-8 box-border text-xs leading-none pl-2.5 pr-2"
placeholder="https://api.example.com/v1"
value={endpoint.baseURL || ''}
onChange={(e) => onChangeBaseURL(e.target.value)}
autoComplete="off"
autoCorrect="off"
spellCheck="false"
onClick={stopBubble}
/>
</div>
</div>
{/* API key */}
<div>
<div className="key-section-label flex items-center justify-between gap-2 text-[11px] mb-1">
<span>API Key</span>
</div>
{!isEditingKey ? (
<div
className="key-display-row flex items-center justify-between gap-2 h-8 box-border pl-2.5 pr-0.5"
onClick={stopBubble}
>
<span className="key-display-mask text-xs"></span>
<div className="flex items-center gap-0.5">
<button
type="button"
className="btn-icon w-7 h-7 box-border inline-flex items-center justify-center cursor-pointer"
onClick={handleTest}
disabled={testing || pending || !providerEnabled || !endpoint.baseURL}
title={endpoint.baseURL ? 'Test connection' : 'Set Base URL first'}
aria-label="Test connection"
data-testid={`ai-endpoint-${endpoint.id}-test`}
>
{testing ? <IconLoader2 size={15} className="spin" /> : <IconBolt size={15} />}
</button>
<button
type="button"
className="btn-icon w-7 h-7 box-border inline-flex items-center justify-center cursor-pointer"
onClick={handleStartEditKey}
disabled={pending}
title="Replace key"
aria-label="Replace key"
data-testid={`ai-endpoint-${endpoint.id}-edit-key`}
>
<IconPencil size={15} />
</button>
<button
type="button"
className="btn-icon danger w-7 h-7 box-border inline-flex items-center justify-center cursor-pointer"
onClick={handleClearKey}
disabled={pending}
title="Remove key"
aria-label="Remove key"
data-testid={`ai-endpoint-${endpoint.id}-clear-key`}
>
<IconTrash size={15} />
</button>
</div>
</div>
) : (
<div className="flex items-center gap-1.5" onClick={stopBubble}>
<div className="relative flex-1 flex items-center">
<input
id={`api-key-${provider.id}`}
type={showKey ? 'text' : 'password'}
className="key-input w-full h-8 box-border text-xs leading-none pl-2.5 pr-8"
placeholder="sk-..."
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={keyDraft}
onChange={(e) => setKeyDraft(e.target.value)}
onKeyDown={handleKeyDown}
onClick={stopBubble}
autoFocus
data-testid={`ai-endpoint-${endpoint.id}-key-input`}
/>
<button
type="button"
className="key-eye-btn absolute right-1 p-1 inline-flex items-center cursor-pointer"
onClick={() => setShowKey(!showKey)}
tabIndex={-1}
aria-label={showKey ? 'Hide API key' : 'Show API key'}
>
{showKey ? <IconEyeOff size={14} /> : <IconEye size={14} />}
</button>
</div>
<button
type="button"
className="btn-primary h-8 box-border px-3 text-xs font-medium inline-flex items-center justify-center gap-1 cursor-pointer"
disabled={saving || pending || !keyDraft.trim()}
onClick={handleSaveKey}
data-testid={`ai-endpoint-${endpoint.id}-save-key`}
>
{saving ? <IconLoader2 size={13} className="spin" /> : <IconCheck size={13} />}
Save
</button>
{provider.configured && (
<button
type="button"
className="btn-icon w-7 h-7 box-border inline-flex items-center justify-center cursor-pointer"
onClick={handleCancelEditKey}
title="Cancel"
>
<IconX size={15} />
</button>
)}
</div>
)}
{pending && (
<div className="feedback flex items-center gap-1.5 text-[11px] px-2 py-1 mt-1.5" role="status">
<IconLoader2 size={12} className="spin" />
Saving endpoint
</div>
)}
{feedback && (
<div
className={`feedback flex items-center gap-1.5 text-[11px] px-2 py-1 mt-1.5 ${feedback.type}`}
role="status"
>
{feedback.type === 'success' ? <IconCheck size={12} /> : <IconAlertCircle size={12} />}
{feedback.message}
</div>
)}
</div>
{/* Models */}
<div className="flex flex-col gap-1.5" onClick={stopBubble}>
<div className="models-label-row flex items-center justify-between text-[11px]">
<span>Models</span>
{!provider.configured && (
<span className="keyless-hint flex items-center gap-1.5 text-[11px] py-1">
<IconAlertCircle size={12} />
Add an API key to enable
</span>
)}
</div>
{models.length === 0 && (
<div className="compat-models-empty text-[11px] px-2.5 py-2">
No models yet. Add the model id your provider expects (e.g. <code>gpt-4o</code> or <code>llama3.1:8b</code>).
</div>
)}
{models.length > 0 && (
<div className="flex flex-col gap-1">
{models.map((model) => {
const enabled = isModelEnabled(model.id);
const disabled = !provider.configured || !providerEnabled;
return (
<div
key={model.id}
className={`compat-model-row flex items-center gap-2 px-2.5 py-1.5 ${enabled && !disabled ? 'selected' : ''} ${disabled ? 'disabled' : ''}`}
>
<input
type="checkbox"
className="cursor-pointer m-0"
checked={enabled}
disabled={disabled}
onChange={() => onToggleModel(model.id, !enabled)}
/>
<input
type="text"
className="compat-inline-input flex-1 text-xs"
value={model.label || ''}
placeholder="Display name"
onChange={(e) => onUpdateModel(model.id, { label: e.target.value })}
/>
<input
type="text"
className="compat-inline-input compat-inline-id flex-1 text-xs"
value={model.modelId || ''}
placeholder="Model id"
onChange={(e) => onUpdateModel(model.id, { modelId: e.target.value })}
/>
<button
type="button"
className="btn-icon danger w-6 h-6 box-border inline-flex items-center justify-center cursor-pointer"
onClick={() => onRemoveModel(model.id)}
title="Remove model"
aria-label="Remove model"
>
<IconTrash size={13} />
</button>
</div>
);
})}
</div>
)}
<div className="compat-add-model flex items-center gap-1.5 mt-1">
<input
type="text"
className="key-input flex-1 h-8 box-border text-xs leading-none pl-2.5 pr-2"
placeholder="Model id (required)"
value={newModelId}
onChange={(e) => setNewModelId(e.target.value)}
onKeyDown={handleAddModelKeyDown}
data-testid={`ai-endpoint-${endpoint.id}-new-model-id`}
/>
<input
type="text"
className="key-input flex-1 h-8 box-border text-xs leading-none pl-2.5 pr-2"
placeholder="Label (optional)"
value={newModelLabel}
onChange={(e) => setNewModelLabel(e.target.value)}
onKeyDown={handleAddModelKeyDown}
data-testid={`ai-endpoint-${endpoint.id}-new-model-label`}
/>
<button
type="button"
className="btn-primary h-8 box-border px-3 text-xs font-medium inline-flex items-center justify-center gap-1 cursor-pointer"
disabled={!newModelId.trim()}
onClick={handleAddModel}
data-testid={`ai-endpoint-${endpoint.id}-add-model`}
>
<IconPlus size={13} />
Add
</button>
</div>
</div>
<div className="flex justify-end pt-1" onClick={stopBubble}>
<button
type="button"
className="compat-remove-endpoint inline-flex items-center gap-1 text-[11px] cursor-pointer"
onClick={() => onRemoveEndpoint(endpoint.id)}
data-testid={`ai-endpoint-${endpoint.id}-remove`}
>
<IconTrash size={12} />
Remove endpoint
</button>
</div>
</div>
</div>
</div>
</div>
);
};
export default CompatEndpointCard;

View File

@@ -1,255 +0,0 @@
import { useState } from 'react';
import { IconPlus, IconTrash } from '@tabler/icons';
import ToggleSwitch from 'components/ToggleSwitch';
const BUILT_IN_HEADER_EXAMPLES = [
'Authorization',
'Proxy-Authorization',
'Cookie',
'Set-Cookie',
'X-API-Key',
'X-Auth-Token',
'X-Access-Token',
'X-CSRF-Token'
];
const normalize = (raw) => String(raw || '').trim();
/**
* Compact editor for a case-insensitive name list. Used for both custom
* header names and custom variable names — the shape is identical.
*/
const CHIP_MAX_LENGTH = 200;
const CHIP_MAX_COUNT = 200;
const ChipListEditor = ({ list, placeholder, onChange, addTestId, inputTestId, removeTestIdPrefix }) => {
const [draft, setDraft] = useState('');
const values = Array.isArray(list) ? list : [];
const atCapacity = values.length >= CHIP_MAX_COUNT;
const handleAdd = () => {
const value = normalize(draft);
if (!value || value.length > CHIP_MAX_LENGTH || atCapacity) return;
if (values.some((v) => v.toLowerCase() === value.toLowerCase())) {
setDraft('');
return;
}
onChange([...values, value]);
setDraft('');
};
const handleRemove = (name) => {
onChange(values.filter((v) => v !== name));
};
const handleKeyDown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAdd();
}
};
const trimmedDraft = normalize(draft);
const draftTooLong = trimmedDraft.length > CHIP_MAX_LENGTH;
const addDisabled = !trimmedDraft || draftTooLong || atCapacity;
return (
<>
<div className="security-add-row flex items-center gap-2">
<input
type="text"
className="security-input flex-1"
placeholder={placeholder}
value={draft}
maxLength={CHIP_MAX_LENGTH}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
disabled={atCapacity}
data-testid={inputTestId}
/>
<button
type="button"
className="security-add-btn inline-flex items-center gap-1 text-[11px] font-medium"
onClick={handleAdd}
disabled={addDisabled}
data-testid={addTestId}
>
<IconPlus size={13} strokeWidth={1.75} />
Add
</button>
</div>
{atCapacity && (
<span className="security-sub text-[10.5px]">
Reached the {CHIP_MAX_COUNT}-entry limit. Remove one to add another.
</span>
)}
{values.length > 0 && (
<ul className="security-chip-list flex flex-wrap gap-1.5">
{values.map((name) => (
<li key={name} className="security-chip inline-flex items-center gap-1">
<span className="security-chip-text">{name}</span>
<button
type="button"
className="security-chip-remove"
onClick={() => handleRemove(name)}
aria-label={`Remove ${name}`}
data-testid={removeTestIdPrefix ? `${removeTestIdPrefix}-${name}` : undefined}
>
<IconTrash size={11} strokeWidth={1.75} />
</button>
</li>
))}
</ul>
)}
</>
);
};
const SecurityPane = ({
aiEnabled,
redactHeaders,
redactBody,
redactVariables,
redactResponse,
customRedactedHeaders,
customRedactedVariables,
onToggleRedactHeaders,
onToggleRedactBody,
onToggleRedactVariables,
onToggleRedactResponse,
onChangeCustomRedactedHeaders,
onChangeCustomRedactedVariables
}) => {
if (!aiEnabled) {
return (
<div className="security-tab flex flex-col gap-3">
<div className="ai-empty-notice px-3.5 py-3 text-xs">
Turn on AI in the Configuration tab to configure redaction.
</div>
</div>
);
}
return (
<div className="security-tab flex flex-col gap-3">
<div className="ai-empty-notice px-3.5 py-3 text-xs">
Bruno strips sensitive values from the context it sends to AI providers. Toggle any check off if it gets in the way, or extend the lists below.
</div>
<div className="security-card">
<div className="security-row flex items-center justify-between gap-3 px-3.5 py-3">
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-[12.5px] font-semibold">Redact sensitive header values</span>
<span className="security-sub text-[11px]">
Masks Authorization, cookies, API keys, and other credential-bearing headers in the request context.
</span>
</div>
<ToggleSwitch
size="m"
isOn={redactHeaders}
handleToggle={() => onToggleRedactHeaders(!redactHeaders)}
data-testid="ai-security-headers-toggle"
/>
</div>
<div className="security-row flex items-center justify-between gap-3 px-3.5 py-3">
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-[12.5px] font-semibold">Redact sensitive body keys</span>
<span className="security-sub text-[11px]">
Masks values under keys like <code>password</code>, <code>*_token</code>, <code>secret</code> in JSON and GraphQL variables. Structure and non-sensitive fields still pass through.
</span>
</div>
<ToggleSwitch
size="m"
isOn={redactBody}
handleToggle={() => onToggleRedactBody(!redactBody)}
data-testid="ai-security-body-toggle"
/>
</div>
<div className="security-row flex items-center justify-between gap-3 px-3.5 py-3">
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-[12.5px] font-semibold">Redact response values</span>
<span className="security-sub text-[11px]">
Sends the response as a shape only real values replaced with type placeholders (<code>&lt;string&gt;</code>, <code>&lt;number&gt;</code>). Turn off to send the actual response body.
</span>
</div>
<ToggleSwitch
size="m"
isOn={redactResponse}
handleToggle={() => onToggleRedactResponse(!redactResponse)}
data-testid="ai-security-response-toggle"
/>
</div>
<div className="security-row flex items-center justify-between gap-3 px-3.5 py-3">
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-[12.5px] font-semibold">Redact secret variable values</span>
<span className="security-sub text-[11px]">
Masks values whose names look like secrets. Variables explicitly marked <em>secret</em> are always redacted regardless of this switch.
</span>
</div>
<ToggleSwitch
size="m"
isOn={redactVariables}
handleToggle={() => onToggleRedactVariables(!redactVariables)}
data-testid="ai-security-variables-toggle"
/>
</div>
</div>
<div className="security-card">
<div className="security-row flex flex-col gap-2 px-3.5 py-3">
<div className="flex flex-col gap-0.5">
<span className="text-[12.5px] font-semibold">Custom redacted headers</span>
<span className="security-sub text-[11px]">
Exact, case-insensitive header names to always mask on top of the built-in list.
</span>
</div>
<ChipListEditor
list={customRedactedHeaders}
placeholder="X-Custom-Token"
onChange={onChangeCustomRedactedHeaders}
inputTestId="ai-security-custom-header-input"
addTestId="ai-security-custom-header-add"
removeTestIdPrefix="ai-security-custom-header-remove"
/>
</div>
<div className="security-row flex flex-col gap-2 px-3.5 py-3">
<div className="flex flex-col gap-0.5">
<span className="text-[12.5px] font-semibold">Custom redacted variables</span>
<span className="security-sub text-[11px]">
Variable names whose values should always be masked when Bruno lists them for the model for anything you want redacted besides values already flagged as <em>secret</em>.
</span>
</div>
<ChipListEditor
list={customRedactedVariables}
placeholder="MY_SESSION_TOKEN"
onChange={onChangeCustomRedactedVariables}
inputTestId="ai-security-custom-var-input"
addTestId="ai-security-custom-var-add"
removeTestIdPrefix="ai-security-custom-var-remove"
/>
</div>
<div className="security-row flex flex-col gap-1 px-3.5 py-3">
<span className="text-[11px] font-medium security-sub">Already covered by default</span>
<div className="security-builtin flex flex-wrap gap-1.5">
{BUILT_IN_HEADER_EXAMPLES.map((name) => (
<span key={name} className="security-builtin-chip">{name}</span>
))}
<span className="security-builtin-more text-[10.5px]">
plus any name matching <code>token</code>, <code>secret</code>, <code>password</code>, or <code>api_key</code>.
</span>
</div>
</div>
</div>
</div>
);
};
export default SecurityPane;

View File

@@ -3,46 +3,6 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
color: ${(props) => props.theme.text};
.ai-tabs {
border-bottom: 1px solid ${(props) => props.theme.input.border};
margin-bottom: 14px;
}
.ai-tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 12px;
margin-bottom: -1px;
font-size: 12px;
font-weight: 500;
color: ${(props) => props.theme.colors.text.muted};
background: transparent;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
transition: color 0.15s ease, border-color 0.15s ease;
&:hover:not(.active) {
color: ${(props) => props.theme.text};
}
&.active {
color: ${(props) => props.theme.text};
border-bottom-color: ${(props) => props.theme.colors.accent};
}
svg {
color: inherit;
}
}
.ai-tab-panel {
display: flex;
flex-direction: column;
min-height: 0;
}
.ai-master {
border: 1px solid ${(props) => props.theme.input.border};
border-radius: ${(props) => props.theme.border.radius.md};
@@ -270,314 +230,6 @@ const StyledWrapper = styled.div`
color: ${(props) => props.theme.colors.text.muted};
}
.autocomplete-card {
border: 1px solid ${(props) => props.theme.input.border};
border-radius: ${(props) => props.theme.border.radius.md};
background: ${(props) => props.theme.input.bg};
}
.autocomplete-sub {
color: ${(props) => props.theme.colors.text.muted};
}
.autocomplete-card.dimmed {
opacity: 0.55;
}
.autocomplete-row + .autocomplete-row {
border-top: 1px dashed ${(props) => props.theme.input.border};
}
.autocomplete-blocker {
color: ${(props) => props.theme.colors.text.muted};
background: ${(props) => props.theme.input.bg};
border-bottom: 1px solid ${(props) => props.theme.input.border};
}
.model-select {
appearance: none;
-webkit-appearance: none;
padding: 4px 24px 4px 8px;
font-size: 11.5px;
font-family: inherit;
border-radius: ${(props) => props.theme.border.radius.sm};
border: 1px solid ${(props) => props.theme.input.border};
background: ${(props) => props.theme.bg};
color: ${(props) => props.theme.text};
cursor: pointer;
min-width: 160px;
transition: border-color 0.15s ease;
&:hover:not(:disabled) {
border-color: ${(props) => props.theme.colors.accent}80;
}
&:focus {
outline: none;
border-color: ${(props) => props.theme.input.focusBorder};
}
&:disabled {
cursor: not-allowed;
}
}
.model-select-chevron {
position: absolute;
right: 6px;
pointer-events: none;
color: ${(props) => props.theme.colors.text.muted};
}
.trigger-pills {
border: 1px solid ${(props) => props.theme.input.border};
border-radius: ${(props) => props.theme.border.radius.sm};
padding: 2px;
background: ${(props) => props.theme.bg};
}
.trigger-pill {
padding: 3px 9px;
font-size: 11px;
font-weight: 500;
border: 1px solid transparent;
border-radius: ${(props) => props.theme.border.radius.sm};
background: transparent;
color: ${(props) => props.theme.colors.text.muted};
cursor: pointer;
transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease;
&:hover:not(:disabled):not(.selected) {
color: ${(props) => props.theme.text};
}
&.selected {
color: ${(props) => props.theme.text};
background: ${(props) => props.theme.colors.accent}10;
border-color: ${(props) => props.theme.colors.accent}55;
}
&:disabled {
cursor: not-allowed;
}
}
.autocomplete-keymap {
color: ${(props) => props.theme.colors.text.muted};
kbd {
display: inline-block;
padding: 0 4px;
margin: 0 1px;
font-family: ${(props) => props.theme.font.monospace || 'monospace'};
font-size: 10px;
line-height: 1.5;
color: ${(props) => props.theme.text};
background: ${(props) => props.theme.bg};
border: 1px solid ${(props) => props.theme.input.border};
border-radius: 3px;
}
}
.compat-add-btn {
color: ${(props) => props.theme.colors.text.muted};
background: transparent;
border: 1px solid ${(props) => props.theme.input.border};
border-radius: ${(props) => props.theme.border.radius.sm};
padding: 3px 8px;
transition: color 0.15s ease, border-color 0.15s ease;
&:hover {
color: ${(props) => props.theme.text};
border-color: ${(props) => props.theme.colors.accent}80;
}
}
.compat-models-empty {
color: ${(props) => props.theme.colors.text.muted};
border: 1px dashed ${(props) => props.theme.input.border};
border-radius: ${(props) => props.theme.border.radius.sm};
code {
font-family: ${(props) => props.theme.font.monospace || 'monospace'};
color: ${(props) => props.theme.text};
}
}
.compat-model-row {
border-radius: ${(props) => props.theme.border.radius.sm};
border: 1px solid ${(props) => props.theme.input.border};
background: ${(props) => props.theme.input.bg};
transition: background-color 0.15s ease, border-color 0.15s ease;
&.selected {
background: ${(props) => props.theme.colors.accent}06;
}
&.disabled {
opacity: 0.45;
input {
cursor: not-allowed;
}
}
}
.compat-inline-input {
background: transparent;
border: none;
outline: none;
color: ${(props) => props.theme.text};
padding: 2px 4px;
border-radius: ${(props) => props.theme.border.radius.sm};
min-width: 0;
font-family: inherit;
&::placeholder {
color: ${(props) => props.theme.colors.text.muted};
opacity: 0.6;
}
&:focus {
background: ${(props) => props.theme.bg};
box-shadow: inset 0 0 0 1px ${(props) => props.theme.input.focusBorder};
}
}
.compat-inline-id {
font-family: ${(props) => props.theme.font.monospace || 'monospace'};
}
.compat-add-model {
padding-top: 4px;
}
.compat-remove-endpoint {
color: ${(props) => props.theme.colors.text.muted};
background: transparent;
border: none;
padding: 4px 6px;
border-radius: ${(props) => props.theme.border.radius.sm};
transition: color 0.15s ease, background-color 0.15s ease;
&:hover {
color: ${(props) => props.theme.colors.text.danger};
background: ${(props) => props.theme.colors.bg.danger}15;
}
}
.security-card {
border: 1px solid ${(props) => props.theme.input.border};
border-radius: ${(props) => props.theme.border.radius.md};
background: ${(props) => props.theme.input.bg};
}
.security-sub {
color: ${(props) => props.theme.colors.text.muted};
code {
font-family: ${(props) => props.theme.font.monospace || 'monospace'};
color: ${(props) => props.theme.text};
}
}
.security-row + .security-row {
border-top: 1px dashed ${(props) => props.theme.input.border};
}
.security-input {
padding: 5px 8px;
font-size: 12px;
font-family: ${(props) => props.theme.font.monospace || 'monospace'};
border-radius: ${(props) => props.theme.border.radius.sm};
border: 1px solid ${(props) => props.theme.input.border};
background: ${(props) => props.theme.bg};
color: ${(props) => props.theme.text};
&::placeholder {
color: ${(props) => props.theme.colors.text.muted};
opacity: 0.7;
}
&:focus {
outline: none;
border-color: ${(props) => props.theme.input.focusBorder};
}
}
.security-add-btn {
color: ${(props) => props.theme.colors.text.muted};
background: transparent;
border: 1px solid ${(props) => props.theme.input.border};
border-radius: ${(props) => props.theme.border.radius.sm};
padding: 4px 10px;
cursor: pointer;
transition: color 0.15s ease, border-color 0.15s ease;
&:hover:not(:disabled) {
color: ${(props) => props.theme.text};
border-color: ${(props) => props.theme.colors.accent}80;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.security-chip-list {
list-style: none;
padding: 0;
margin: 0;
}
.security-chip {
padding: 3px 4px 3px 8px;
font-size: 11px;
font-family: ${(props) => props.theme.font.monospace || 'monospace'};
border: 1px solid ${(props) => props.theme.input.border};
border-radius: ${(props) => props.theme.border.radius.sm};
background: ${(props) => props.theme.bg};
color: ${(props) => props.theme.text};
}
.security-chip-remove {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px;
border: none;
background: transparent;
color: ${(props) => props.theme.colors.text.muted};
cursor: pointer;
border-radius: ${(props) => props.theme.border.radius.sm};
transition: color 0.15s ease, background-color 0.15s ease;
&:hover {
color: ${(props) => props.theme.colors.text.danger};
background: ${(props) => props.theme.colors.bg.danger}15;
}
}
.security-builtin-chip {
padding: 2px 7px;
font-size: 10.5px;
font-family: ${(props) => props.theme.font.monospace || 'monospace'};
color: ${(props) => props.theme.colors.text.muted};
border: 1px dashed ${(props) => props.theme.input.border};
border-radius: ${(props) => props.theme.border.radius.sm};
}
.security-builtin-more {
color: ${(props) => props.theme.colors.text.muted};
align-self: center;
code {
font-family: ${(props) => props.theme.font.monospace || 'monospace'};
color: ${(props) => props.theme.text};
}
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }

View File

@@ -1,56 +1,22 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import get from 'lodash/get';
import debounce from 'lodash/debounce';
import { v4 as uuid } from 'uuid';
import { useFormik } from 'formik';
import { useDispatch, useSelector } from 'react-redux';
import * as Yup from 'yup';
import toast from 'react-hot-toast';
import { IconPlus, IconSettings, IconShieldLock, IconTerminal2 } from '@tabler/icons';
import { IconStars } from '@tabler/icons';
import { savePreferences } from 'providers/ReduxStore/slices/app';
import ToggleSwitch from 'components/ToggleSwitch';
import { clearAiApiKey, getAiStatus } from 'utils/ai';
import { getAiStatus } from 'utils/ai';
import ProviderCard from './ProviderCard';
import CompatEndpointCard from './CompatEndpointCard';
import AutocompletePane from './AutocompletePane';
import SecurityPane from './SecurityPane';
import StyledWrapper from './StyledWrapper';
const OPENAI_COMPATIBLE_PREFIX = 'openai-compatible:';
const isCompatProviderId = (id) => typeof id === 'string' && id.startsWith(OPENAI_COMPATIBLE_PREFIX);
const aiPreferencesSchema = Yup.object().shape({
enabled: Yup.boolean(),
providers: Yup.object(),
models: Yup.object(),
defaultModel: Yup.string().max(200).nullable(),
openaiCompatibleEndpoints: Yup.array().of(
Yup.object().shape({
id: Yup.string().required(),
name: Yup.string().max(120).nullable(),
baseURL: Yup.string().max(2048).nullable(),
models: Yup.array().of(
Yup.object().shape({
id: Yup.string().required(),
label: Yup.string().max(120).nullable(),
modelId: Yup.string().max(200).nullable()
})
)
})
),
autocomplete: Yup.object().shape({
enabled: Yup.boolean(),
model: Yup.string().max(200).nullable(),
triggerMode: Yup.string().oneOf(['aggressive', 'debounced', 'manual']).nullable()
}),
security: Yup.object().shape({
redactHeaders: Yup.boolean(),
redactBody: Yup.boolean(),
redactVariables: Yup.boolean(),
redactResponse: Yup.boolean(),
customRedactedHeaders: Yup.array().of(Yup.string().max(200)).max(200),
customRedactedVariables: Yup.array().of(Yup.string().max(200)).max(200)
})
defaultModel: Yup.string().max(200).nullable()
});
const AI = () => {
@@ -58,7 +24,6 @@ const AI = () => {
const preferences = useSelector((state) => state.app.preferences);
const [status, setStatus] = useState(null);
const [statusError, setStatusError] = useState(null);
const [activeTab, setActiveTab] = useState('config');
const refreshStatus = useCallback(async () => {
try {
@@ -78,12 +43,6 @@ const AI = () => {
const formik = useFormik({
enableReinitialize: true,
// Skip per-change validation — every toggle would otherwise re-run the
// full nested schema (arrays of endpoints × models × …), which adds tens
// of ms of blocking work per click. debouncedSave already validates via
// `aiPreferencesSchema.validate` right before persisting.
validateOnChange: false,
validateOnBlur: false,
initialValues: {
enabled: get(preferences, 'ai.enabled', false),
providers: providerIds.reduce((acc, id) => {
@@ -91,28 +50,14 @@ const AI = () => {
return acc;
}, {}),
models: get(preferences, 'ai.models', {}),
defaultModel: get(preferences, 'ai.defaultModel', ''),
openaiCompatibleEndpoints: get(preferences, 'ai.openaiCompatibleEndpoints', []),
autocomplete: {
enabled: get(preferences, 'ai.autocomplete.enabled', true),
model: get(preferences, 'ai.autocomplete.model', ''),
triggerMode: get(preferences, 'ai.autocomplete.triggerMode', 'debounced')
},
security: {
redactHeaders: get(preferences, 'ai.security.redactHeaders', true),
redactBody: get(preferences, 'ai.security.redactBody', true),
redactVariables: get(preferences, 'ai.security.redactVariables', true),
redactResponse: get(preferences, 'ai.security.redactResponse', true),
customRedactedHeaders: get(preferences, 'ai.security.customRedactedHeaders', []),
customRedactedVariables: get(preferences, 'ai.security.customRedactedVariables', [])
}
defaultModel: get(preferences, 'ai.defaultModel', '')
},
validationSchema: aiPreferencesSchema,
onSubmit: () => {}
});
const handleSave = useCallback(
(values) =>
(values) => {
dispatch(
savePreferences({
...preferences,
@@ -120,35 +65,15 @@ const AI = () => {
enabled: values.enabled,
providers: values.providers,
models: values.models,
defaultModel: values.defaultModel || '',
openaiCompatibleEndpoints: values.openaiCompatibleEndpoints || [],
autocomplete: {
enabled: values.autocomplete?.enabled !== false,
model: values.autocomplete?.model || '',
triggerMode: values.autocomplete?.triggerMode || 'debounced'
},
security: {
redactHeaders: values.security?.redactHeaders !== false,
redactBody: values.security?.redactBody !== false,
redactVariables: values.security?.redactVariables !== false,
redactResponse: values.security?.redactResponse !== false,
customRedactedHeaders: Array.isArray(values.security?.customRedactedHeaders)
? values.security.customRedactedHeaders
: [],
customRedactedVariables: Array.isArray(values.security?.customRedactedVariables)
? values.security.customRedactedVariables
: []
}
defaultModel: values.defaultModel || ''
}
})
)
.then(() => refreshStatus())
.catch((err) => {
console.error('Failed to save AI preferences:', err);
toast.error('Failed to save AI preferences');
throw err;
}),
[dispatch, preferences, refreshStatus]
).catch((err) => {
console.error('Failed to save AI preferences:', err);
toast.error('Failed to save AI preferences');
});
},
[dispatch, preferences]
);
const handleSaveRef = useRef(handleSave);
@@ -187,135 +112,40 @@ const AI = () => {
formik.setFieldValue(`models.${modelId}.enabled`, next);
};
const endpoints = formik.values.openaiCompatibleEndpoints || [];
const handleAddEndpoint = async () => {
const newEndpoint = {
id: uuid(),
name: `Endpoint ${endpoints.length + 1}`,
baseURL: '',
models: []
};
const next = [...endpoints, newEndpoint];
formik.setFieldValue('openaiCompatibleEndpoints', next);
formik.setFieldValue(`providers.${OPENAI_COMPATIBLE_PREFIX}${newEndpoint.id}.enabled`, true);
// Persist immediately so the backend recognises the new virtual provider id
// by the time the user enters an API key. The card derives a `pending` flag
// from `status.providers` so its key/test actions stay disabled until this
// resolves, which also closes the race with debouncedSave.
try {
await handleSaveRef.current({
...formik.values,
openaiCompatibleEndpoints: next,
providers: {
...formik.values.providers,
[`${OPENAI_COMPATIBLE_PREFIX}${newEndpoint.id}`]: { enabled: true }
}
});
} catch (_) {
// toast already raised by handleSave
}
};
const updateEndpoint = (endpointId, patch) => {
const next = endpoints.map((e) => (e.id === endpointId ? { ...e, ...patch } : e));
formik.setFieldValue('openaiCompatibleEndpoints', next);
};
const updateEndpointModels = (endpointId, mapFn) => {
const next = endpoints.map((e) => (e.id === endpointId ? { ...e, models: mapFn(e.models || []) } : e));
formik.setFieldValue('openaiCompatibleEndpoints', next);
};
const handleRemoveEndpoint = async (endpointId) => {
const providerId = `${OPENAI_COMPATIBLE_PREFIX}${endpointId}`;
const removed = endpoints.find((e) => e.id === endpointId);
const removedModelIds = new Set((removed?.models || []).map((m) => m.id));
const next = endpoints.filter((e) => e.id !== endpointId);
formik.setFieldValue('openaiCompatibleEndpoints', next);
const providersCopy = { ...formik.values.providers };
delete providersCopy[providerId];
formik.setFieldValue('providers', providersCopy);
// Drop per-model toggles and clear any selector still pointing at a removed
// model so the picker doesn't resolve to an unknown id later.
if (removedModelIds.size > 0) {
const modelsCopy = { ...(formik.values.models || {}) };
for (const id of removedModelIds) delete modelsCopy[id];
formik.setFieldValue('models', modelsCopy);
if (removedModelIds.has(formik.values.defaultModel)) {
formik.setFieldValue('defaultModel', '');
}
if (removedModelIds.has(formik.values.autocomplete?.model)) {
formik.setFieldValue('autocomplete.model', '');
}
}
// Best-effort key cleanup so we don't leave orphan encrypted blobs on disk.
try {
await clearAiApiKey({ providerId });
} catch (_) {
// ignore, key may not have been set
}
};
const usableModels = useMemo(() => {
if (!status) return [];
const endpointsById = new Map((formik.values.openaiCompatibleEndpoints || []).map((e) => [e.id, e]));
return (status.models || []).filter((m) => {
const summary = useMemo(() => {
if (!status || !formik.values.enabled) return 'Turn on to configure providers and models';
const usableProviders = Object.values(status.providers).filter(
(p) => p.configured && formik.values.providers?.[p.id]?.enabled
);
if (usableProviders.length === 0) return 'Add a provider to get started';
// Count models live from formik + current key status, not the electron-side
// snapshot which lags behind toggle changes during the save debounce window.
const totalEnabledModels = (status.models || []).filter((m) => {
if (!formik.values.providers?.[m.provider]?.enabled) return false;
if (!status.providers?.[m.provider]?.configured) return false;
if (!isModelEnabled(m.id)) return false;
if (isCompatProviderId(m.provider)) {
const endpointId = m.provider.slice(OPENAI_COMPATIBLE_PREFIX.length);
const endpoint = endpointsById.get(endpointId);
if (!endpoint?.baseURL) return false;
}
return true;
});
}, [status, formik.values.providers, formik.values.models, formik.values.openaiCompatibleEndpoints]);
return isModelEnabled(m.id);
}).length;
const plural = (n, s) => `${n} ${s}${n === 1 ? '' : 's'}`;
return `${plural(usableProviders.length, 'provider')} · ${plural(totalEnabledModels, 'model')} ready`;
}, [status, formik.values.enabled, formik.values.providers, formik.values.models]);
return (
<StyledWrapper className="w-full flex flex-col text-xs min-h-0 max-h-[calc(100%-30px)]">
<div className="section-header">AI</div>
<div className="ai-tabs flex items-center gap-1" role="tablist" aria-label="AI preferences">
<button
type="button"
role="tab"
aria-selected={activeTab === 'config'}
className={`ai-tab ${activeTab === 'config' ? 'active' : ''}`}
onClick={() => setActiveTab('config')}
data-testid="ai-tab-config"
>
<IconSettings size={14} strokeWidth={1.5} />
Configuration
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === 'autocomplete'}
className={`ai-tab ${activeTab === 'autocomplete' ? 'active' : ''}`}
onClick={() => setActiveTab('autocomplete')}
data-testid="ai-tab-autocomplete"
>
<IconTerminal2 size={14} strokeWidth={1.5} />
Autocomplete
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === 'security'}
className={`ai-tab ${activeTab === 'security' ? 'active' : ''}`}
onClick={() => setActiveTab('security')}
data-testid="ai-tab-security"
>
<IconShieldLock size={14} strokeWidth={1.5} />
Security
</button>
<div className="ai-master flex items-center justify-between gap-4 px-3.5 py-3 mb-4">
<div className="flex flex-col gap-0.5 min-w-0">
<div className="flex items-center gap-2 text-[13px] font-semibold">
<IconStars size={15} strokeWidth={1.75} className="ai-master-icon" />
<span>AI Features</span>
</div>
<span className="ai-master-summary text-[11px]">{summary}</span>
</div>
<ToggleSwitch
size="m"
isOn={formik.values.enabled}
handleToggle={() => formik.setFieldValue('enabled', !formik.values.enabled)}
/>
</div>
{statusError && (
@@ -324,177 +154,46 @@ const AI = () => {
</div>
)}
{activeTab === 'config' && (
<div className="ai-tab-panel" role="tabpanel">
<div className="ai-master flex items-center justify-between gap-4 px-3.5 py-3 mb-4">
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-[13px] font-semibold">AI Features</span>
<span className="ai-master-summary text-[11px]">
Turn on to configure providers and models. Your keys stay local.
</span>
</div>
<ToggleSwitch
size="m"
isOn={formik.values.enabled}
handleToggle={() => formik.setFieldValue('enabled', !formik.values.enabled)}
/>
{!formik.values.enabled && !statusError && (
<div className="ai-empty-notice px-3.5 py-3 text-xs">
Bring your own API key. Bruno talks to providers directly, your keys never leave your machine.
</div>
)}
{formik.values.enabled && status && (
<>
<div className="ai-section-header text-[11px] font-medium uppercase tracking-wider mt-[18px] mb-2">
Providers
</div>
<div className="flex flex-col gap-1.5">
{providerIds.map((id) => {
const provider = status.providers[id];
const providerEnabled = get(formik.values, `providers.${id}.enabled`, false);
{!formik.values.enabled && !statusError && (
<div className="ai-empty-notice px-3.5 py-3 text-xs">
Bring your own API key. Bruno talks to providers directly, your keys never leave your machine.
</div>
)}
const providerToggle = (
<ToggleSwitch
size="s"
isOn={providerEnabled}
handleToggle={() =>
formik.setFieldValue(`providers.${id}.enabled`, !providerEnabled)}
/>
);
{formik.values.enabled && status && (
<>
<div className="ai-section-header text-[11px] font-medium uppercase tracking-wider mb-2">
Providers
</div>
<div className="flex flex-col gap-1.5">
{providerIds
.filter((id) => !isCompatProviderId(id))
.map((id) => {
const provider = status.providers[id];
const providerEnabled = get(formik.values, `providers.${id}.enabled`, false);
const providerToggle = (
<ToggleSwitch
size="s"
isOn={providerEnabled}
handleToggle={() =>
formik.setFieldValue(`providers.${id}.enabled`, !providerEnabled)}
/>
);
return (
<ProviderCard
key={id}
provider={provider}
providerEnabled={providerEnabled}
providerToggle={providerToggle}
models={modelsByProvider[id] || []}
isModelEnabled={isModelEnabled}
onToggleModel={handleToggleModel}
onStatusChange={(next) => setStatus(next)}
/>
);
})}
</div>
<div className="ai-section-header flex items-center justify-between text-[11px] font-medium uppercase tracking-wider mt-5 mb-2">
<span>OpenAI-Compatible Endpoints</span>
<button
type="button"
className="compat-add-btn inline-flex items-center gap-1 text-[11px] font-medium cursor-pointer normal-case tracking-normal"
onClick={handleAddEndpoint}
data-testid="ai-compat-add-endpoint"
>
<IconPlus size={13} strokeWidth={1.75} />
Add endpoint
</button>
</div>
{endpoints.length === 0 && (
<div className="ai-empty-notice px-3.5 py-3 text-xs">
Point Bruno at any OpenAI-compatible API Ollama, LM Studio, Together, Groq, OpenRouter, vLLM, and more.
</div>
)}
{endpoints.length > 0 && (
<div className="flex flex-col gap-1.5">
{endpoints.map((endpoint) => {
const providerId = `${OPENAI_COMPATIBLE_PREFIX}${endpoint.id}`;
const pending = !status.providers[providerId];
const provider = status.providers[providerId] || {
id: providerId,
label: endpoint.name,
configured: false,
isCustom: true
};
const providerEnabled = get(formik.values, `providers.${providerId}.enabled`, false);
const providerToggle = (
<ToggleSwitch
size="s"
isOn={providerEnabled}
handleToggle={() =>
formik.setFieldValue(`providers.${providerId}.enabled`, !providerEnabled)}
/>
);
return (
<CompatEndpointCard
key={endpoint.id}
endpoint={endpoint}
provider={provider}
providerEnabled={providerEnabled}
providerToggle={providerToggle}
pending={pending}
isModelEnabled={isModelEnabled}
onToggleModel={handleToggleModel}
onChangeName={(name) => updateEndpoint(endpoint.id, { name })}
onChangeBaseURL={(baseURL) => updateEndpoint(endpoint.id, { baseURL })}
onAddModel={(model) =>
updateEndpointModels(endpoint.id, (models) => [...models, model])}
onRemoveModel={(modelId) =>
updateEndpointModels(endpoint.id, (models) =>
models.filter((m) => m.id !== modelId)
)}
onUpdateModel={(modelId, patch) =>
updateEndpointModels(endpoint.id, (models) =>
models.map((m) => (m.id === modelId ? { ...m, ...patch } : m))
)}
onRemoveEndpoint={handleRemoveEndpoint}
onStatusChange={(next) => setStatus(next)}
/>
);
})}
</div>
)}
</>
)}
</div>
)}
{activeTab === 'autocomplete' && (
<div className="ai-tab-panel" role="tabpanel">
<AutocompletePane
aiEnabled={formik.values.enabled}
enabled={formik.values.autocomplete?.enabled !== false}
model={formik.values.autocomplete?.model || ''}
triggerMode={formik.values.autocomplete?.triggerMode || 'debounced'}
availableModels={usableModels}
hasConfiguredProvider={Boolean(
status && Object.entries(status.providers || {}).some(
([providerId, p]) => p?.configured && formik.values.providers?.[providerId]?.enabled
)
)}
onToggleEnabled={(next) => formik.setFieldValue('autocomplete.enabled', next)}
onChangeModel={(next) => formik.setFieldValue('autocomplete.model', next)}
onChangeTriggerMode={(next) => formik.setFieldValue('autocomplete.triggerMode', next)}
/>
</div>
)}
{activeTab === 'security' && (
<div className="ai-tab-panel" role="tabpanel">
<SecurityPane
aiEnabled={formik.values.enabled}
redactHeaders={formik.values.security?.redactHeaders !== false}
redactBody={formik.values.security?.redactBody !== false}
redactVariables={formik.values.security?.redactVariables !== false}
redactResponse={formik.values.security?.redactResponse !== false}
customRedactedHeaders={formik.values.security?.customRedactedHeaders || []}
customRedactedVariables={formik.values.security?.customRedactedVariables || []}
onToggleRedactHeaders={(next) => formik.setFieldValue('security.redactHeaders', next)}
onToggleRedactBody={(next) => formik.setFieldValue('security.redactBody', next)}
onToggleRedactVariables={(next) => formik.setFieldValue('security.redactVariables', next)}
onToggleRedactResponse={(next) => formik.setFieldValue('security.redactResponse', next)}
onChangeCustomRedactedHeaders={(next) => formik.setFieldValue('security.customRedactedHeaders', next)}
onChangeCustomRedactedVariables={(next) => formik.setFieldValue('security.customRedactedVariables', next)}
/>
</div>
return (
<ProviderCard
key={id}
provider={provider}
providerEnabled={providerEnabled}
providerToggle={providerToggle}
models={modelsByProvider[id] || []}
isModelEnabled={isModelEnabled}
onToggleModel={handleToggleModel}
onStatusChange={(next) => setStatus(next)}
/>
);
})}
</div>
</>
)}
</StyledWrapper>
);

View File

@@ -2,85 +2,11 @@ import styled from 'styled-components';
const StyledWrapper = styled.div`
color: ${(props) => props.theme.text};
.cache-section-title {
text-transform: uppercase;
font-size: ${(props) => props.theme.font.size.sm};
letter-spacing: 0.05em;
color: ${(props) => props.theme.colors.text.muted};
margin-bottom: 0.75rem;
}
.cache-item {
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: ${(props) => props.theme.border.radius.md};
margin-bottom: 1rem;
overflow: hidden;
}
.cache-item-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
gap: 1rem;
background: ${(props) => props.theme.background.surface0};
border-bottom: 1px solid ${(props) => props.theme.border.border1};
}
.cache-item-title-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.cache-item-title {
font-size: ${(props) => props.theme.font.size.md};
font-weight: 600;
}
.beta-badge {
display: inline-flex;
align-items: center;
padding: 1px 6px;
border-radius: ${(props) => props.theme.border.radius.sm};
font-size: ${(props) => props.theme.font.size.xs};
font-weight: 500;
line-height: 1.5;
background: ${(props) => props.theme.status.info.background};
color: ${(props) => props.theme.status.info.text};
}
.cache-item-body {
display: flex;
align-items: flex-end;
justify-content: space-between;
padding: 0.875rem 1rem;
gap: 1.25rem;
}
.cache-item-body-text {
flex: 1;
min-width: 0;
}
.cache-item-description {
font-size: ${(props) => props.theme.font.size.base};
color: ${(props) => props.theme.colors.text.muted};
line-height: 1.5;
margin: 0;
}
.cache-item-size {
font-size: ${(props) => props.theme.font.size.base};
color: ${(props) => props.theme.colors.text.subtext2};
margin: 0.5rem 0 0 0;
}
.cache-item-size strong {
font-weight: 600;
color: ${(props) => props.theme.text};
margin-left: 0.25rem;
form.bruno-form {
label {
font-size: 0.8125rem;
}
}
`;

View File

@@ -1,147 +1,120 @@
import React, { useEffect, useState, useCallback } from 'react';
import React, { useEffect, useCallback, useRef } from 'react';
import { useFormik } from 'formik';
import { useSelector, useDispatch } from 'react-redux';
import { savePreferences, clearHttpHttpsAgentCache } from 'providers/ReduxStore/slices/app';
import {
savePreferences,
clearHttpHttpsAgentCache
} from 'providers/ReduxStore/slices/app';
import toast from 'react-hot-toast';
import get from 'lodash/get';
import { IconEraser } from '@tabler/icons';
import { useTheme } from 'providers/Theme';
import ToggleSwitch from 'components/ToggleSwitch';
import ActionIcon from 'ui/ActionIcon';
import StyledWrapper from './StyledWrapper';
import { formatSize } from 'utils/common';
import * as Yup from 'yup';
import debounce from 'lodash/debounce';
import get from 'lodash/get';
const cacheSchema = Yup.object().shape({
sslSession: Yup.object({
enabled: Yup.boolean()
})
});
const Cache = () => {
const preferences = useSelector((state) => state.app.preferences);
const dispatch = useDispatch();
const { theme } = useTheme();
const { ipcRenderer } = window;
const fileCacheEnabled = get(preferences, 'cache.file.enabled', false);
const sslSessionEnabled = get(preferences, 'cache.sslSession.enabled', false);
const handleSave = useCallback(
(newCachePreferences) => {
dispatch(
savePreferences({
...preferences,
cache: newCachePreferences
})
).catch(() => toast.error('Failed to update cache preferences'));
},
[dispatch, preferences]
);
const [fileCacheSize, setFileCacheSize] = useState(null);
const handleSaveRef = useRef(handleSave);
handleSaveRef.current = handleSave;
const refreshFileCacheSize = useCallback(() => {
if (!ipcRenderer) return;
ipcRenderer
.invoke('renderer:get-file-cache-size')
.then((size) => setFileCacheSize(size))
.catch(() => setFileCacheSize(null));
}, [ipcRenderer]);
const formik = useFormik({
initialValues: {
sslSession: {
enabled: get(preferences, 'cache.sslSession.enabled', false)
}
},
validationSchema: cacheSchema,
onSubmit: async (values) => {
try {
const newPreferences = await cacheSchema.validate(values, { abortEarly: true });
handleSave(newPreferences);
} catch (error) {
console.error('Cache preferences validation error:', error.message);
}
}
});
const debouncedSave = useCallback(
debounce((values) => {
cacheSchema
.validate(values, { abortEarly: true })
.then((validatedValues) => handleSaveRef.current(validatedValues))
.catch(() => {});
}, 500),
[]
);
useEffect(() => {
refreshFileCacheSize();
}, [refreshFileCacheSize, fileCacheEnabled]);
if (formik.dirty && formik.isValid) {
debouncedSave(formik.values);
}
return () => {
debouncedSave.flush();
};
}, [formik.values, formik.dirty, formik.isValid, debouncedSave]);
const persist = (next) => {
dispatch(savePreferences({ ...preferences, cache: next })).catch(() => {
toast.error('Failed to update cache preferences');
});
};
const handleToggleFileCache = () => {
persist({
...preferences.cache,
file: { enabled: !fileCacheEnabled }
});
};
const handleToggleSslSession = () => {
const next = !sslSessionEnabled;
persist({
...preferences.cache,
sslSession: { enabled: next }
});
if (!next) {
const handleAgentCachingChange = (e) => {
formik.handleChange(e);
// Immediately evict all cached agents when caching is disabled
if (!e.target.checked) {
dispatch(clearHttpHttpsAgentCache()).catch(() => {});
}
};
const handleClearFileCache = () => {
if (!ipcRenderer) return;
ipcRenderer
.invoke('renderer:clear-file-cache')
.then((size) => {
setFileCacheSize(size);
toast.success('File cache cleared');
})
.catch(() => toast.error('Failed to clear file cache'));
};
const handleClearSslSession = () => {
const handleResetCache = () => {
dispatch(clearHttpHttpsAgentCache())
.then(() => toast.success('SSL session cache cleared'))
.catch(() => toast.error('Failed to clear SSL session cache'));
.then(() => toast.success('ssl session cache cleared'))
.catch(() => toast.error('Failed to clear ssl session cache'));
};
return (
<StyledWrapper className="w-full">
<div className="cache-section-title">Cache</div>
<form className="bruno-form" onSubmit={formik.handleSubmit}>
<div className="section-title mt-6 mb-3">Cache SSL Session</div>
<div className="cache-item">
<div className="cache-item-header">
<div className="cache-item-title-group">
<span className="cache-item-title">File cache</span>
<span className="beta-badge">Beta</span>
</div>
<ToggleSwitch
data-testid="cache.file.enabled"
isOn={fileCacheEnabled}
handleToggle={handleToggleFileCache}
size="2xs"
activeColor={theme.primary.solid}
<div className="flex items-center my-2">
<input
id="sslSession.enabled"
type="checkbox"
name="sslSession.enabled"
checked={formik.values.sslSession.enabled}
onChange={handleAgentCachingChange}
className="mousetrap mr-0"
/>
<label className="block ml-2 select-none" htmlFor="sslSession.enabled">
Enable SSL session caching
</label>
</div>
<div className="cache-item-body">
<div className="cache-item-body-text">
<p className="cache-item-description">
Loads your workspace faster by caching opened collections. Bruno refreshes the cache when your collection
changes. Clearing it won't affect your original files.
</p>
<p className="cache-item-size">
Cache size <strong>{fileCacheSize == null ? '' : formatSize(fileCacheSize)}</strong>
</p>
</div>
<ActionIcon
label="Clear cache"
onClick={handleClearFileCache}
disabled={!fileCacheSize}
colorOnHover={theme.colors.text.danger}
>
<IconEraser size={16} strokeWidth={1.5} />
</ActionIcon>
<div className="text-xs mt-1 ml-6 opacity-70">
Reuses TLS sessions and connections across requests for faster handshakes. Disable to create a fresh connection for every
request.
</div>
</div>
<div className="cache-item">
<div className="cache-item-header">
<div className="cache-item-title-group">
<span className="cache-item-title">SSL session cache</span>
</div>
<ToggleSwitch
data-testid="sslSession.enabled"
isOn={sslSessionEnabled}
handleToggle={handleToggleSslSession}
size="2xs"
activeColor={theme.primary.solid}
/>
<div className="mt-6">
<button type="button" className="text-link cursor-pointer hover:underline" onClick={handleResetCache}>
Clear
</button>
</div>
<div className="cache-item-body">
<div className="cache-item-body-text">
<p className="cache-item-description">
Reuses TLS sessions and connections across requests for faster handshakes. Disable to create a fresh
connection for every request.
</p>
</div>
<ActionIcon
label="Clear cache"
onClick={handleClearSslSession}
colorOnHover={theme.colors.text.danger}
>
<IconEraser size={16} strokeWidth={1.5} />
</ActionIcon>
</div>
</div>
</form>
</StyledWrapper>
);
};

View File

@@ -1,15 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.app-toggle-row {
border-bottom: 1px solid ${(props) => props.theme.border.border1};
}
.app-editor {
div.CodeMirror {
height: inherit;
}
}
`;
export default StyledWrapper;

View File

@@ -1,69 +0,0 @@
import React, { useMemo } from 'react';
import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import CodeEditor from 'components/CodeEditor';
import ToggleSwitch from 'components/ToggleSwitch';
import AIAssist from 'components/AIAssist';
import { buildAiContextPayload } from 'utils/ai';
import { updateAppCode, toggleAppMode } from 'providers/ReduxStore/slices/collections';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { useTheme } from 'providers/Theme';
import StyledWrapper from './StyledWrapper';
const AppCodeEditor = ({ item, collection }) => {
const dispatch = useDispatch();
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
const code = item.draft ? get(item, 'draft.app.code', '') : get(item, 'app.code', '');
const enabled = item.draft ? get(item, 'draft.app.enabled', false) : get(item, 'app.enabled', false);
const onEdit = (value) =>
dispatch(updateAppCode({ code: value, itemUid: item.uid, collectionUid: collection.uid }));
const onToggle = () =>
dispatch(toggleAppMode({ enabled: !enabled, itemUid: item.uid, collectionUid: collection.uid }));
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const { requestContext, variables: aiVariables } = useMemo(
() => buildAiContextPayload(item, collection),
[item, collection]
);
return (
<StyledWrapper className="w-full h-full flex flex-col">
<div className="app-toggle-row mb-3 px-1 pb-3 flex items-center justify-between">
<div className="flex flex-col">
<label className="text-xs font-medium">Enable App</label>
<p className="text-xs opacity-70">
When enabled, replaces the request/response panes with the app view for this request.
</p>
</div>
<ToggleSwitch isOn={enabled} handleToggle={onToggle} size="m" data-testid="app-enable-toggle" />
</div>
<div className="flex-1 app-editor relative" data-testid="app-code-editor">
<CodeEditor
collection={collection}
value={code || ''}
theme={displayedTheme}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
onEdit={onEdit}
onSave={onSave}
mode="javascript"
/>
<AIAssist
scriptType="app-request"
currentScript={code || ''}
requestContext={requestContext}
variables={aiVariables}
onApply={onEdit}
/>
</div>
</StyledWrapper>
);
};
export default AppCodeEditor;

View File

@@ -2,7 +2,6 @@ import React, { useMemo, useCallback } from 'react';
import get from 'lodash/get';
import { IconCaretDown } from '@tabler/icons';
import MenuDropdown from 'ui/MenuDropdown';
import StatusBadge from 'ui/StatusBadge/index';
import { useDispatch } from 'react-redux';
import { updateRequestAuthMode } from 'providers/ReduxStore/slices/collections';
import { humanizeRequestAuthMode } from 'utils/collections';
@@ -68,17 +67,6 @@ const AuthMode = ({ item, collection }) => {
label: 'API Key',
onClick: () => onModeChange('apikey')
},
{
id: 'akamai-edgegrid',
label: (
<span className="flex items-center gap-2">
Akamai EdgeGrid
<StatusBadge status="info" size="xs">Beta</StatusBadge>
</span>
),
ariaLabel: 'Akamai EdgeGrid (Beta)',
onClick: () => onModeChange('akamai-edgegrid')
},
{
id: 'inherit',
label: 'Inherit',

View File

@@ -1,97 +0,0 @@
import styled from 'styled-components';
import { rgba } from 'polished';
const StyledWrapper = styled.div`
label {
display: flex;
align-items: center;
font-size: ${(props) => props.theme.font.size.sm};
font-weight: 500;
color: ${(props) => props.theme.colors.text.subtext1};
margin-bottom: 0.5rem;
}
.single-line-editor-wrapper {
display: flex;
align-items: center;
max-width: 400px;
margin-bottom: 0.5rem;
padding: 0.15rem 0.4rem;
border-radius: 3px;
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
&:focus-within {
border: solid 1px ${(props) => props.theme.input.focusBorder} !important;
}
}
.advanced-settings-header {
display: flex;
align-items: center;
gap: 10px;
width: fit-content;
margin: 1rem 0 0.75rem;
font-size: ${(props) => props.theme.font.size.sm};
font-weight: 600;
color: ${(props) => props.theme.colors.text.subtext1};
user-select: none;
.advanced-settings-icon {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.375rem 0.625rem;
border-radius: 0.375rem;
background-color: ${(props) => rgba(props.theme.primary.solid, 0.1)};
svg {
color: ${(props) => props.theme.primary.text};
}
}
}
.advanced-settings-hint {
margin: -0.25rem 0 0.75rem;
font-size: ${(props) => props.theme.font.size.sm};
color: rgb(107 114 128);
}
.field-info {
position: relative;
display: inline-flex;
align-items: center;
margin-left: 6px;
cursor: pointer;
svg {
color: rgb(107 114 128);
}
.field-tooltip {
position: absolute;
left: 0;
bottom: 100%;
z-index: 10;
width: max-content;
max-width: 15rem;
margin-bottom: 0.25rem;
padding: 0.5rem;
border-radius: 0.375rem;
background-color: #374151;
color: #fff;
font-size: 0.75rem;
line-height: 1rem;
font-weight: 400;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
}
&:hover .field-tooltip {
opacity: 1;
}
}
`;
export default StyledWrapper;

View File

@@ -1,174 +0,0 @@
import { IconAdjustmentsHorizontal, IconInfoCircle } from '@tabler/icons';
import get from 'lodash/get';
import React from 'react';
import { useDispatch } from 'react-redux';
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
import SingleLineEditor from 'components/SingleLineEditor';
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import { sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import { useTheme } from 'providers/Theme';
import StyledWrapper from './StyledWrapper';
interface AkamaiEdgeGridAuthValues {
accessToken?: string;
clientToken?: string;
clientSecret?: string;
nonce?: string;
timestamp?: string;
baseURL?: string;
headersToSign?: string;
maxBodySize?: number | null;
}
type EdgeGridField = keyof AkamaiEdgeGridAuthValues;
// Coerce the Max Body Size editor string into the numeric value the model stores (empty/invalid -> null).
const toMaxBodySize = (value: string): number | null => {
if (value === '' || value == null) return null;
const num = Number(value);
return Number.isNaN(num) ? null : num;
};
interface AkamaiEdgeGridAuthProps {
item: any;
collection: any;
request: any;
updateAuth: (payload: any) => any;
save: () => void;
}
const FIELDS: Array<{ key: EdgeGridField; label: string; tooltip?: string; isSecret?: boolean }> = [
{ key: 'accessToken', label: 'Access Token' },
{ key: 'clientToken', label: 'Client Token' },
{ key: 'clientSecret', label: 'Client Secret', isSecret: true },
{ key: 'baseURL', label: 'Base URL', tooltip: 'Defaults to the request URL if not specified.' },
{
key: 'nonce',
label: 'Nonce',
tooltip: 'A unique nonce is required per request. Defaults to an auto-generated UUID v4 if not provided.'
},
{
key: 'timestamp',
label: 'Timestamp',
tooltip:
'UTC timestamp of when the request is signed (yyyyMMddTHH:mm:ss+0000). Defaults to current time if not provided.'
},
{
key: 'headersToSign',
label: 'Headers to Sign',
tooltip: 'Comma-separated list of headers to include in the signature.'
},
{
key: 'maxBodySize',
label: 'Max Body Size',
tooltip: 'Maximum message body size to include in the signature, in bytes. Defaults to 131072.'
}
];
type EdgeGridFieldConfig = (typeof FIELDS)[number];
// Fields shown up front vs. those grouped under the "Advanced Settings" section
const BASIC_FIELDS = FIELDS.slice(0, 3);
const ADVANCED_FIELDS = FIELDS.slice(3);
const EdgeGridAuth: React.FC<AkamaiEdgeGridAuthProps> = ({ item, collection, updateAuth, request, save }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const edgeGridAuth: AkamaiEdgeGridAuthValues = get(request, 'auth.akamaiEdgegrid') || {};
const requestUrl = get(request, 'url', '');
const { isSensitive } = useDetectSensitiveField(collection);
const { showWarning: showClientSecretWarning, warningMessage: clientSecretWarningMessage } = isSensitive(
edgeGridAuth?.clientSecret
);
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleSave = () => {
save();
};
const handleFieldChange = (field: EdgeGridField, value: string) => {
const content: AkamaiEdgeGridAuthValues = {
accessToken: edgeGridAuth.accessToken || '',
clientToken: edgeGridAuth.clientToken || '',
clientSecret: edgeGridAuth.clientSecret || '',
nonce: edgeGridAuth.nonce || '',
timestamp: edgeGridAuth.timestamp || '',
baseURL: edgeGridAuth.baseURL || '',
headersToSign: edgeGridAuth.headersToSign || '',
maxBodySize: edgeGridAuth.maxBodySize ?? null
};
if (field === 'maxBodySize') {
content.maxBodySize = toMaxBodySize(value);
} else {
(content as Record<string, unknown>)[field] = value || '';
}
dispatch(
updateAuth({
mode: 'akamai-edgegrid',
collectionUid: collection.uid,
itemUid: item.uid,
content
})
);
};
const renderField = ({ key, label, tooltip, isSecret }: EdgeGridFieldConfig) => {
const showWarning = isSecret && showClientSecretWarning;
const rawValue = key === 'baseURL' ? edgeGridAuth.baseURL || requestUrl : edgeGridAuth[key];
const fieldValue = rawValue === null || rawValue === undefined ? '' : String(rawValue);
return (
<div key={key}>
<label>
{label}
{tooltip && (
<span className="field-info">
<IconInfoCircle size={16} />
<span className="field-tooltip">{tooltip}</span>
</span>
)}
</label>
<div className="single-line-editor-wrapper">
<SingleLineEditor
value={fieldValue}
theme={storedTheme}
onSave={handleSave}
onChange={(val: string) => handleFieldChange(key, val)}
onRun={handleRun}
collection={collection}
item={item}
isSecret={isSecret}
isCompact
/>
{showWarning && (
<SensitiveFieldWarning fieldName="edgegrid-client-secret" warningMessage={clientSecretWarningMessage} />
)}
</div>
</div>
);
};
return (
<StyledWrapper className="mt-2 w-full">
{BASIC_FIELDS.map(renderField)}
<div className="advanced-settings-header">
<span className="advanced-settings-icon">
<IconAdjustmentsHorizontal size={16} />
</span>
<span>Advanced Settings</span>
</div>
<>
{ADVANCED_FIELDS.map(renderField)}
</>
</StyledWrapper>
);
};
export default EdgeGridAuth;

View File

@@ -12,7 +12,6 @@ import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { useDispatch } from 'react-redux';
import ApiKeyAuth from './ApiKeyAuth';
import EdgeGridAuth from './EdgeGridAuth';
import StyledWrapper from './StyledWrapper';
import { humanizeRequestAuthMode } from 'utils/collections';
import OAuth2 from './OAuth2/index';
@@ -69,9 +68,6 @@ const Auth = ({ item, collection }) => {
case 'apikey': {
return <ApiKeyAuth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}
case 'akamai-edgegrid': {
return <EdgeGridAuth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}
case 'inherit': {
return (
<>

View File

@@ -13,7 +13,6 @@ import Assertions from 'components/RequestPane/Assertions';
import Script from 'components/RequestPane/Script';
import Tests from 'components/RequestPane/Tests';
import Settings from 'components/RequestPane/Settings';
import AppCodeEditor from 'components/RequestPane/AppCodeEditor';
import Documentation from 'components/Documentation/index';
import StatusDot from 'components/StatusDot';
import ResponsiveTabs from 'ui/ResponsiveTabs';
@@ -31,7 +30,6 @@ const TAB_CONFIG = [
{ key: 'assert', label: 'Assert' },
{ key: 'tests', label: 'Tests' },
{ key: 'docs', label: 'Docs' },
{ key: 'app', label: 'App' },
{ key: 'settings', label: 'Settings' }
];
@@ -45,7 +43,6 @@ const TAB_PANELS = {
script: Script,
tests: Tests,
docs: Documentation,
app: AppCodeEditor,
settings: Settings
};
@@ -74,7 +71,6 @@ const HttpRequestPane = ({ item, collection }) => {
const responseVars = getProperty('request.vars.res');
const auth = getProperty('request.auth');
const tags = getProperty('tags');
const app = item.draft ? get(item, 'draft.app') : get(item, 'app');
const activeCounts = useMemo(() => ({
params: params.filter((p) => p.enabled).length,
@@ -110,10 +106,9 @@ const HttpRequestPane = ({ item, collection }) => {
assert: activeCounts.assertions > 0 ? <sup className="font-medium">{activeCounts.assertions}</sup> : null,
tests: tests?.length > 0 ? (hasTestError ? <StatusDot type="error" /> : <StatusDot />) : null,
docs: docs?.length > 0 ? <StatusDot /> : null,
app: app?.code?.length > 0 ? <StatusDot dataTestId="app" /> : null,
settings: tags?.length > 0 ? <StatusDot /> : null
};
}, [activeCounts, body.mode, hasAuth, script, item.preRequestScriptErrorMessage, item.postResponseScriptErrorMessage, item.testScriptErrorMessage, tests, docs, app, tags]);
}, [activeCounts, body.mode, hasAuth, script, item.preRequestScriptErrorMessage, item.postResponseScriptErrorMessage, item.testScriptErrorMessage, tests, docs, tags]);
const allTabs = useMemo(
() => TAB_CONFIG.map(({ key, label }) => ({ key, label, indicator: indicators[key] })),

View File

@@ -4,7 +4,7 @@ import find from 'lodash/find';
import { useDispatch, useSelector } from 'react-redux';
import CodeEditor from 'components/CodeEditor';
import AIAssist from 'components/AIAssist';
import { buildAiContextPayload } from 'utils/ai';
import { buildRequestContextFromItem } from 'utils/ai';
import { updateRequestScript, updateResponseScript } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs';
@@ -95,10 +95,7 @@ const Script = ({ item, collection }) => {
const onRun = () => dispatch(sendRequest(item, collection.uid));
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
const { requestContext, variables: aiVariables } = useMemo(
() => buildAiContextPayload(item, collection),
[item, collection]
);
const requestContext = useMemo(() => buildRequestContextFromItem(item), [item]);
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
const hasPostResponseScript = responseScript && responseScript.trim().length > 0;
@@ -130,7 +127,6 @@ const Script = ({ item, collection }) => {
<CodeEditor
ref={preRequestEditorRef}
collection={collection}
item={item}
docKey="script:pre-request"
value={requestScript || ''}
theme={displayedTheme}
@@ -141,7 +137,6 @@ const Script = ({ item, collection }) => {
onRun={onRun}
onSave={onSave}
showHintsFor={['req', 'bru']}
scriptType="pre-request"
initialScroll={preReqScroll}
onScroll={setPreReqScroll}
/>
@@ -149,7 +144,6 @@ const Script = ({ item, collection }) => {
scriptType="pre-request"
currentScript={requestScript || ''}
requestContext={requestContext}
variables={aiVariables}
onApply={onRequestScriptEdit}
/>
</div>
@@ -160,7 +154,6 @@ const Script = ({ item, collection }) => {
<CodeEditor
ref={postResponseEditorRef}
collection={collection}
item={item}
docKey="script:post-response"
value={responseScript || ''}
theme={displayedTheme}
@@ -171,7 +164,6 @@ const Script = ({ item, collection }) => {
onRun={onRun}
onSave={onSave}
showHintsFor={['req', 'res', 'bru']}
scriptType="post-response"
initialScroll={postResScroll}
onScroll={setPostResScroll}
/>
@@ -179,7 +171,6 @@ const Script = ({ item, collection }) => {
scriptType="post-response"
currentScript={responseScript || ''}
requestContext={requestContext}
variables={aiVariables}
onApply={onResponseScriptEdit}
/>
</div>

View File

@@ -3,7 +3,7 @@ import get from 'lodash/get';
import { useDispatch, useSelector } from 'react-redux';
import CodeEditor from 'components/CodeEditor';
import AIAssist from 'components/AIAssist';
import { buildAiContextPayload } from 'utils/ai';
import { buildRequestContextFromItem } from 'utils/ai';
import { updateRequestTests } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { useTheme } from 'providers/Theme';
@@ -38,17 +38,13 @@ const Tests = ({ item, collection }) => {
scriptPhase: 'test'
});
const { requestContext, variables: aiVariables } = useMemo(
() => buildAiContextPayload(item, collection),
[item, collection]
);
const requestContext = useMemo(() => buildRequestContextFromItem(item), [item]);
return (
<div data-testid="test-script-editor" className="relative h-full">
<CodeEditor
ref={testsEditorRef}
collection={collection}
item={item}
docKey="tests"
value={tests || ''}
theme={displayedTheme}
@@ -59,11 +55,10 @@ const Tests = ({ item, collection }) => {
onRun={onRun}
onSave={onSave}
showHintsFor={['req', 'res', 'bru']}
scriptType="tests"
initialScroll={testsScroll}
onScroll={setTestsScroll}
/>
<AIAssist scriptType="tests" currentScript={tests || ''} requestContext={requestContext} variables={aiVariables} onApply={onEdit} />
<AIAssist scriptType="tests" currentScript={tests || ''} requestContext={requestContext} onApply={onEdit} />
</div>
);
};

View File

@@ -85,10 +85,6 @@ export const SingleWSMessage = ({
} else if (e.key === 'Escape') {
setEditValue(displayName);
setIsEditing(false);
} else if ((e.metaKey || e.ctrlKey) && (e.key === 's' || e.key === 'S')) {
e.preventDefault();
saveName(editValue);
dispatch(saveRequest(item.uid, collection.uid));
}
};

View File

@@ -1,6 +1,5 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import find from 'lodash/find';
import get from 'lodash/get';
import toast from 'react-hot-toast';
import { useSelector, useDispatch } from 'react-redux';
import GraphQLRequestPane from 'components/RequestPane/GraphQLRequestPane';
@@ -21,8 +20,6 @@ import CollectionSettings from 'components/CollectionSettings';
import { DocExplorer } from '@usebruno/graphql-docs';
import FileEditor from 'components/FileEditor';
import AppView from 'components/AppView';
import CollectionApp from 'components/CollectionApp';
import StyledWrapper from './StyledWrapper';
import FolderSettings from 'components/FolderSettings';
import { getGlobalEnvironmentVariables, getGlobalEnvironmentVariablesMasked } from 'utils/collections/index';
@@ -295,58 +292,6 @@ const RequestTabPanel = () => {
};
}, [handleMouseUp, handleMouseMove]);
// Clamp leftPaneWidth when the main section shrinks (AI sidebar opens, or
// the window narrows). Without this the stored pixel width can exceed the
// available container, the section scrolls horizontally, and the response
// pane is pushed off-screen.
//
// Important: we ONLY react to genuine shrinks vs the last stable width. The
// initial observation and any growth are ignored. During mount Windows can
// emit a few transient narrow sizes (often 0) before layout settles — if
// we treated those as shrinks we'd lock leftPaneWidth at the transient value
// and never recover, which made several CodeMirror-driven tests flaky on
// Windows CI while passing on Linux.
const leftPaneWidthRef = useRef(leftPaneWidth);
useEffect(() => { leftPaneWidthRef.current = leftPaneWidth; }, [leftPaneWidth]);
useEffect(() => {
const el = mainSectionRef.current;
if (!el || isVerticalLayout) return;
let lastWidth = null;
let frame = null;
const observer = new ResizeObserver((entries) => {
if (frame) return;
frame = requestAnimationFrame(() => {
frame = null;
const width = entries[0]?.contentRect?.width || el.getBoundingClientRect().width;
if (!width) return;
// Skip the first observation (initial layout) and any non-shrink — we
// only clamp on real reductions in available width.
if (lastWidth === null || width >= lastWidth) {
lastWidth = width;
return;
}
lastWidth = width;
const maxLeft = width - MIN_RIGHT_PANE_WIDTH;
if (leftPaneWidthRef.current > maxLeft) {
// Floor at MIN_LEFT_PANE_WIDTH even if maxLeft is smaller — losing
// a few px from the response is preferable to collapsing the
// request pane to zero.
setLeftPaneWidth(Math.max(MIN_LEFT_PANE_WIDTH, maxLeft));
}
});
});
observer.observe(el);
return () => {
observer.disconnect();
if (frame) cancelAnimationFrame(frame);
};
}, [setLeftPaneWidth, isVerticalLayout]);
useEffect(() => {
if (!isVerticalLayout) return;
if (responsePaneCollapsed) return;
@@ -548,30 +493,6 @@ const RequestTabPanel = () => {
);
}
// Standalone app item (collection- or folder-level). Renders as its own tab
// with a Code/Preview toggle and its own ctx API surface.
if (item.type === 'app') {
return (
<ScopedPersistenceProvider scope={focusedTab.uid}>
<StyledWrapper className="flex flex-col flex-grow relative overflow-hidden">
<CollectionApp item={item} collection={collection} />
</StyledWrapper>
</ScopedPersistenceProvider>
);
}
const appEnabled = item.draft ? get(item, 'draft.app.enabled', false) : get(item, 'app.enabled', false);
if (appEnabled) {
const appCode = item.draft ? get(item, 'draft.app.code', '') : get(item, 'app.code', '');
return (
<ScopedPersistenceProvider scope={focusedTab.uid}>
<StyledWrapper className="flex flex-col flex-grow relative overflow-hidden">
<AppView item={item} collection={collection} code={appCode} />
</StyledWrapper>
</ScopedPersistenceProvider>
);
}
const renderQueryUrl = () => {
if (isGrpcRequest) {
return <GrpcQueryUrl item={item} collection={collection} handleRun={handleRun} />;

View File

@@ -151,62 +151,6 @@ const StyledWrapper = styled.div`
color: ${(props) => props.theme.colors.text.danger};
margin-left: 8px;
}
.migrate-yml-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 4px 2px 8px;
border: 1px solid ${(props) => props.theme.input.border};
border-radius: 999px;
background: transparent;
color: ${(props) => props.theme.text};
font-size: 12px;
line-height: 1;
transition: background-color 0.15s ease;
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
}
.pill-main {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0;
border: none;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
}
.pill-label {
font-weight: 500;
}
.pill-dismiss {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
color: inherit;
cursor: pointer;
opacity: 0.6;
transition: opacity 0.15s ease, background-color 0.15s ease;
&:hover {
opacity: 1;
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
}
}
}
.display-icon{
padding: 4px;
box-sizing: content-box;
@@ -215,40 +159,6 @@ const StyledWrapper = styled.div`
border-radius: ${(props) => props.theme.border.radius.sm}
}
}
.mode-toggle {
display: inline-flex;
align-items: stretch;
padding: 2px;
gap: 2px;
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
border-radius: ${(props) => props.theme.border.radius.base};
.mode-btn {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
padding: 0;
border: 1px solid transparent;
background: transparent;
color: ${(props) => props.theme.colors.text.muted};
border-radius: ${(props) => props.theme.border.radius.sm};
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
&:hover:not(.active) {
color: ${(props) => props.theme.text};
}
&.active {
background: ${(props) => props.theme.bg};
color: ${(props) => props.theme.text};
border-color: ${(props) => props.theme.input.border};
box-shadow: ${(props) => props.theme.shadow.sm};
}
}
}
`;
export default StyledWrapper;

View File

@@ -14,22 +14,13 @@ import {
IconFolder,
IconUpload,
IconFileCode,
IconFileOff,
IconCode,
IconAppWindow,
IconTransform,
IconStars
IconFileOff
} from '@tabler/icons';
import OpenAPISyncIcon from 'components/Icons/OpenAPISync';
import { switchWorkspace, renameWorkspaceAction, exportWorkspaceAction, confirmWorkspaceCreation, cancelWorkspaceCreation } from 'providers/ReduxStore/slices/workspaces/actions';
import { updateWorkspace } from 'providers/ReduxStore/slices/workspaces';
import { showInFolder } from 'providers/ReduxStore/slices/collections/actions';
import { toggleCollectionFileMode, toggleAppMode } from 'providers/ReduxStore/slices/collections';
import { toggleAiSidebar } from 'providers/ReduxStore/slices/chat';
import MigrateToYmlModal from 'components/CollectionSettings/Overview/Migration/MigrateToYmlModal';
import { findItemInCollection, findItemInCollectionByPathname } from 'utils/collections';
import find from 'lodash/find';
import get from 'lodash/get';
import { toggleCollectionFileMode } from 'providers/ReduxStore/slices/collections';
import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs';
import { uuid } from 'utils/common';
import toast from 'react-hot-toast';
@@ -47,79 +38,23 @@ import classNames from 'classnames';
import StyledWrapper from './StyledWrapper';
import { useTheme } from 'providers/Theme';
const MIGRATE_PILL_DISMISSED_KEY = 'bruno.migrateToYmlPill.dismissed';
const readDismissedCollections = () => {
try {
const raw = localStorage.getItem(MIGRATE_PILL_DISMISSED_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
};
const CollectionHeader = ({ collection, isScratchCollection }) => {
const dispatch = useDispatch();
const workspaces = useSelector((state) => state.workspaces.workspaces);
const activeWorkspaceUid = useSelector((state) => state.workspaces.activeWorkspaceUid);
const collections = useSelector((state) => state.collections.collections);
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const preferences = useSelector((state) => state.app.preferences);
const isAiEnabled = get(preferences, 'ai.enabled', false);
const isAiSidebarOpen = useSelector((state) => state.chat.isOpen);
// Get the current active workspace
const currentWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
const gitRootPath = collection?.git?.gitRootPath;
// Active request (used by the Request / App / File view-mode toggle)
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
const activeItem = focusedTab && collection
? (findItemInCollection(collection, activeTabUid)
|| (focusedTab.pathname ? findItemInCollectionByPathname(collection, focusedTab.pathname) : null))
: null;
const isHttpRequestActive = activeItem?.type === 'http-request';
const appEnabled = activeItem
? (activeItem.draft ? get(activeItem, 'draft.app.enabled', false) : get(activeItem, 'app.enabled', false))
: false;
const handleToggleAppMode = (enabled) => {
if (isHttpRequestActive) {
dispatch(toggleAppMode({ enabled, itemUid: activeItem.uid, collectionUid: collection.uid }));
}
};
// Workspace rename state
const [isRenamingWorkspace, setIsRenamingWorkspace] = useState(false);
const [workspaceNameInput, setWorkspaceNameInput] = useState('');
const [workspaceNameError, setWorkspaceNameError] = useState('');
const [closeWorkspaceModalOpen, setCloseWorkspaceModalOpen] = useState(false);
const [createWorkspaceModalOpen, setCreateWorkspaceModalOpen] = useState(false);
const [showMigrateModal, setShowMigrateModal] = useState(false);
// Migrate-to-YML pill dismissal state (persisted by collection pathname)
const [migratePillDismissed, setMigratePillDismissed] = useState(true);
useEffect(() => {
if (!collection?.pathname) return;
const dismissed = readDismissedCollections();
setMigratePillDismissed(dismissed.includes(collection.pathname));
}, [collection?.pathname]);
const dismissMigratePill = (e) => {
e?.stopPropagation();
if (!collection?.pathname) return;
const dismissed = readDismissedCollections();
if (!dismissed.includes(collection.pathname)) {
dismissed.push(collection.pathname);
try {
localStorage.setItem(MIGRATE_PILL_DISMISSED_KEY, JSON.stringify(dismissed));
} catch { }
}
setMigratePillDismissed(true);
};
const switcherRef = useRef();
const workspaceActionsRef = useRef();
@@ -296,11 +231,7 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
// Build overflow menu items for the "..." dropdown
const overflowMenuItems = [
{ id: 'variables', label: 'Variables', leftSection: IconEye, onClick: viewVariables },
// File mode is exposed via the Request/App/File view-mode toggle when a request is active;
// keep it in the overflow as a fallback for non-request contexts.
...(!isHttpRequestActive
? [{ id: 'file-mode', label: collection.fileMode ? 'Switch to Code Mode' : 'Switch to File Mode', leftSection: collection.fileMode ? IconFileOff : IconFileCode, onClick: handleFileModeClick }]
: []),
{ id: 'file-mode', label: collection.fileMode ? 'Switch to Code Mode' : 'Switch to File Mode', leftSection: collection.fileMode ? IconFileOff : IconFileCode, onClick: handleFileModeClick },
...(!hasOpenApiSyncConfigured
? [{ id: 'openapi-sync', label: 'OpenAPI', leftSection: OpenAPISyncIcon, onClick: viewOpenApiSync }]
: []),
@@ -650,136 +581,45 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
)}
</div>
<div className="flex flex-grow gap-1.5 items-center justify-end">
{!isScratchCollection && (
<>
{isHttpRequestActive && (
<div className="mode-toggle" data-testid="view-mode-toggle">
<ToolHint text="Request" toolhintId="ViewModeRequestToolhintId" place="bottom">
<button
type="button"
data-testid="view-mode-request"
aria-label="Request view"
className={`mode-btn ${!appEnabled && !collection.fileMode ? 'active' : ''}`}
onClick={() => {
if (collection.fileMode) handleFileModeClick();
if (appEnabled) handleToggleAppMode(false);
}}
>
<IconCode size={16} strokeWidth={1.5} />
</button>
</ToolHint>
<ToolHint text="App" toolhintId="ViewModeAppToolhintId" place="bottom">
<button
type="button"
data-testid="view-mode-app"
aria-label="App view"
className={`mode-btn ${appEnabled && !collection.fileMode ? 'active' : ''}`}
onClick={() => {
if (collection.fileMode) handleFileModeClick();
if (!appEnabled) handleToggleAppMode(true);
}}
>
<IconAppWindow size={16} strokeWidth={1.5} />
</button>
</ToolHint>
<ToolHint text="File" toolhintId="ViewModeFileToolhintId" place="bottom">
<button
type="button"
data-testid="view-mode-file"
aria-label="File view"
className={`mode-btn ${collection.fileMode ? 'active' : ''}`}
onClick={() => {
if (appEnabled) handleToggleAppMode(false);
if (!collection.fileMode) handleFileModeClick();
}}
>
<IconFileCode size={16} strokeWidth={1.5} />
</button>
</ToolHint>
</div>
)}
{isAiEnabled && (
<ToolHint text="AI Assistant" toolhintId="AiAssistantToolhintId" place="bottom">
<ActionIcon
onClick={() => dispatch(toggleAiSidebar())}
aria-label="AI Assistant"
size="sm"
data-testid="ai-assistant"
className={isAiSidebarOpen ? 'active' : ''}
>
<IconStars size={16} strokeWidth={1.5} />
</ActionIcon>
</ToolHint>
)}
{collection.format === 'bru' && !migratePillDismissed && (
<div
className="migrate-yml-pill"
data-testid="migrate-yml-pill"
title="Migrate this collection to YML"
>
<button
type="button"
className="pill-main"
onClick={() => setShowMigrateModal(true)}
>
<IconTransform size={13} strokeWidth={1.5} />
<span className="pill-label">Migrate to YML</span>
</button>
<button
type="button"
className="pill-dismiss"
onClick={dismissMigratePill}
aria-label="Dismiss"
data-testid="migrate-yml-pill-dismiss"
>
<IconX size={12} strokeWidth={2} />
</button>
</div>
)}
{/* OpenAPI Sync - standalone only when configured and beta enabled */}
{hasOpenApiSyncConfigured && (
<ToolHint
text={hasOpenApiError ? 'OpenAPI Error' : hasOpenApiUpdates ? 'OpenAPI Updates Available' : 'OpenAPI'}
toolhintId="OpenApiSyncToolhintId"
place="bottom"
>
<ActionIcon onClick={viewOpenApiSync} aria-label="OpenAPI" size="sm" className="relative">
<OpenAPISyncIcon size={15} />
{(hasOpenApiUpdates || hasOpenApiError) && (
<span className="absolute top-0 right-0 w-1.5 h-1.5 rounded-full" style={{ backgroundColor: hasOpenApiError ? theme.status.danger.text : theme.status.warning.text }} />
)}
</ActionIcon>
</ToolHint>
)}
{/* Runner - always visible */}
<ToolHint text="Runner" toolhintId="RunnerToolhintId" place="bottom">
<ActionIcon onClick={handleRun} aria-label="Runner" size="sm" data-testid="runner">
<IconRun size={16} strokeWidth={1.5} />
{/* Right side: Actions (only for regular collections) */}
{!isScratchCollection && (
<div className="flex flex-grow gap-1.5 items-center justify-end">
{/* OpenAPI Sync - standalone only when configured and beta enabled */}
{hasOpenApiSyncConfigured && (
<ToolHint
text={hasOpenApiError ? 'OpenAPI Error' : hasOpenApiUpdates ? 'OpenAPI Updates Available' : 'OpenAPI'}
toolhintId="OpenApiSyncToolhintId"
place="bottom"
>
<ActionIcon onClick={viewOpenApiSync} aria-label="OpenAPI" size="sm" className="relative">
<OpenAPISyncIcon size={15} />
{(hasOpenApiUpdates || hasOpenApiError) && (
<span className="absolute top-0 right-0 w-1.5 h-1.5 rounded-full" style={{ backgroundColor: hasOpenApiError ? theme.status.danger.text : theme.status.warning.text }} />
)}
</ActionIcon>
</ToolHint>
{/* JS Sandbox Mode - always visible */}
<JsSandboxMode collection={collection} />
{/* Overflow menu */}
<MenuDropdown items={overflowMenuItems} placement="bottom-end" data-testid="more-actions">
<ActionIcon label="More actions" size="sm" style={{ border: `1px solid ${theme.border.border1}`, borderRadius: theme.border.radius.base, width: 24, marginRight: 4, marginLeft: 4 }}>
<IconDots size={16} strokeWidth={1.5} />
</ActionIcon>
</MenuDropdown>
{/* Environment Selector - always visible */}
<span>
<EnvironmentSelector collection={collection} />
</span>
</>
)}
</div>
)}
{/* Runner - always visible */}
<ToolHint text="Runner" toolhintId="RunnerToolhintId" place="bottom">
<ActionIcon onClick={handleRun} aria-label="Runner" size="sm" data-testid="runner">
<IconRun size={16} strokeWidth={1.5} />
</ActionIcon>
</ToolHint>
{/* JS Sandbox Mode - always visible */}
<JsSandboxMode collection={collection} />
{/* Overflow menu */}
<MenuDropdown items={overflowMenuItems} placement="bottom-end" data-testid="more-actions">
<ActionIcon label="More actions" size="sm" style={{ border: `1px solid ${theme.border.border1}`, borderRadius: theme.border.radius.base, width: 24, marginRight: 4, marginLeft: 4 }}>
<IconDots size={16} strokeWidth={1.5} />
</ActionIcon>
</MenuDropdown>
{/* Environment Selector - always visible */}
<span>
<EnvironmentSelector collection={collection} />
</span>
</div>
)}
</div>
{showMigrateModal && (
<MigrateToYmlModal
collection={collection}
onClose={() => setShowMigrateModal(false)}
/>
)}
</StyledWrapper>
);
};

View File

@@ -34,7 +34,7 @@ const StyledWrapper = styled.div.attrs((props) => ({
);
}
li:hover &:not(.no-close),
li:hover &,
&.has-changes {
opacity: 1;
pointer-events: auto;
@@ -59,12 +59,6 @@ const StyledWrapper = styled.div.attrs((props) => ({
}
}
&.no-close .close-icon-container {
&:hover {
background-color: transparent;
}
}
.close-icon {
color: ${(props) => props.theme.requestTabs.icon.color};
width: 12px;
@@ -88,35 +82,22 @@ const StyledWrapper = styled.div.attrs((props) => ({
}
&.has-changes:not(li:hover &) {
.draft-icon-wrapper {
.draft-icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
}
.close-icon-wrapper {
display: none;
.close-icon-wrapper {
display: none;
}
}
/* Closable tabs: hovering shows the close icon, replacing the draft icon */
li:hover &:not(.no-close) {
.draft-icon-wrapper {
display: none;
li:hover &.has-changes {
.draft-icon-wrapper {
display: none;
}
.close-icon-wrapper {
display: flex;
}
}
/* Non-closable tabs with changes: keep the draft icon visible even on hover */
&.no-close.has-changes {
.draft-icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
}
.close-icon-wrapper {
display: none;
.close-icon-wrapper {
display: flex;
}
}
`;

View File

@@ -4,23 +4,15 @@ import DraftTabIcon from '../DraftTabIcon';
import StyledWrapper from './StyledWrapper';
const GradientCloseButton = ({ onClick, hasChanges = false }) => {
const canClose = typeof onClick === 'function';
return (
<StyledWrapper className={`close-gradient ${hasChanges ? 'has-changes' : ''} ${canClose ? '' : 'no-close'}`}>
<div
className="close-icon-container"
onClick={canClose ? onClick : undefined}
data-testid={canClose ? 'request-tab-close-icon' : 'request-tab-draft-icon'}
>
<span className="draft-icon-wrapper" data-testid="tab-draft-icon">
<StyledWrapper className={`close-gradient ${hasChanges ? 'has-changes' : ''}`}>
<div className="close-icon-container" onClick={onClick} data-testid="request-tab-close-icon">
<span className="draft-icon-wrapper">
<DraftTabIcon />
</span>
{canClose && (
<span className="close-icon-wrapper">
<CloseTabIcon />
</span>
)}
<span className="close-icon-wrapper">
<CloseTabIcon />
</span>
</div>
</StyledWrapper>
);

View File

@@ -121,8 +121,7 @@ const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDra
>
{getTabInfo(type, tabName)}
</div>
<GradientCloseButton hasChanges={hasDraft} onClick={handleCloseClick} />
{handleCloseClick && <GradientCloseButton hasChanges={hasDraft} onClick={(e) => handleCloseClick(e)} />}
</>
);
};

View File

@@ -16,7 +16,6 @@ import ConfirmCloseEnvironment from 'components/Environments/ConfirmCloseEnviron
import RequestTabNotFound from './RequestTabNotFound';
import RequestTabLoading from './RequestTabLoading';
import SpecialTab from './SpecialTab';
import { IconAppWindow } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
import MenuDropdown from 'ui/MenuDropdown';
import CloneCollectionItem from 'components/Sidebar/Collections/Collection/CollectionItem/CloneCollectionItem/index';
@@ -202,7 +201,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
const hasFolderDraft = tab.type === 'folder-settings' && folder?.draft;
const hasEnvironmentDraft = tab.type === 'environment-settings' && collection?.environmentsDraft;
const globalEnvironmentDraft = useSelector((state) => state.globalEnvironments.globalEnvironmentDraft);
const hasGlobalEnvironmentDraft = (tab.type === 'global-environment-settings' || tab.type === 'workspaceEnvironments') && globalEnvironmentDraft;
const hasGlobalEnvironmentDraft = tab.type === 'global-environment-settings' && globalEnvironmentDraft;
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const isActive = tab.uid === activeTabUid;
@@ -256,20 +255,16 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
if (environmentUid?.startsWith('dotenv:')) {
window.dispatchEvent(new Event('dotenv-save'));
} else {
dispatch(saveEnvironment(variables, environmentUid, collection.uid))
.then(() => toast.success('Changes saved successfully'))
.catch(() => toast.error('An error occurred while saving the changes'));
dispatch(saveEnvironment(variables, environmentUid, collection.uid));
}
}
} else if (tab.type === 'global-environment-settings' || tab.type === 'workspaceEnvironments') {
} else if (tab.type === 'global-environment-settings') {
if (globalEnvironmentDraft) {
const { environmentUid, variables } = globalEnvironmentDraft;
if (environmentUid?.startsWith('dotenv:')) {
window.dispatchEvent(new Event('dotenv-save'));
} else {
dispatch(saveGlobalEnvironment({ variables, environmentUid }))
.then(() => toast.success('Changes saved successfully'))
.catch(() => toast.error('An error occurred while saving the changes'));
dispatch(saveGlobalEnvironment({ variables, environmentUid }));
}
}
} else if (tab.type === 'folder-settings') {
@@ -483,7 +478,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
) : tab.type === 'workspaceOverview' ? (
<SpecialTab handleCloseClick={null} type={tab.type} />
) : tab.type === 'workspaceEnvironments' ? (
<SpecialTab handleCloseClick={null} type={tab.type} hasDraft={hasGlobalEnvironmentDraft} />
<SpecialTab handleCloseClick={null} type={tab.type} />
) : (
<SpecialTab handleCloseClick={handleCloseClick} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} />
)}
@@ -581,15 +576,9 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
}
}}
>
{item.type === 'app' ? (
<span className="tab-method flex items-center" aria-label="App">
<IconAppWindow size={14} strokeWidth={1.5} />
</span>
) : (
<span className="tab-method uppercase" style={{ color: getMethodColor(method) }}>
{method}
</span>
)}
<span className="tab-method uppercase" style={{ color: getMethodColor(method) }}>
{method}
</span>
<span ref={tabNameRef} className="ml-1 tab-name" title={item.name}>
{item.name}
</span>

View File

@@ -22,7 +22,6 @@ import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
import { showImportIssuesToast } from 'components/Toast/ImportIssuesToast';
import get from 'lodash/get';
import { DEFAULT_COLLECTION_FORMAT } from 'utils/common/constants';
const STATUS = {
LOADING: 'loading',
@@ -155,7 +154,7 @@ export const BulkImportCollectionLocation = ({
const [applyToGlobal, setApplyToGlobal] = useState(true);
const [applyToCollection, setApplyToCollection] = useState(false);
const [groupingType, setGroupingType] = useState('tags');
const [collectionFormat, setCollectionFormat] = useState(DEFAULT_COLLECTION_FORMAT);
const [collectionFormat, setCollectionFormat] = useState('bru');
const [renamedCollectionNames, setRenamedCollectionNames] = useState({});
const [renamedEnvironmentNames, setRenamedEnvironmentNames] = useState({});
const [importIssues, setImportIssues] = useState({});
@@ -586,7 +585,6 @@ export const BulkImportCollectionLocation = ({
<Modal
size="md"
title="Bulk Import"
dataTestId="bulk-import-collection-location-modal"
confirmText={importStarted ? 'Close' : 'Import'}
confirmDisabled={Boolean(!selectedCollections?.length)}
handleConfirm={onSubmit}
@@ -838,7 +836,6 @@ export const BulkImportCollectionLocation = ({
<div className="font-semibold mb-2">Location</div>
<input
id="collection-location"
data-testid="bulk-import-collection-location-input"
type="text"
placeholder="Select a location to save the collection"
name="collectionLocation"
@@ -881,7 +878,6 @@ export const BulkImportCollectionLocation = ({
<select
id="format"
name="format"
data-testid="bulk-import-collection-format-selector"
className="block textbox mt-2 w-full"
value={collectionFormat}
onChange={(e) => setCollectionFormat(e.target.value)}

View File

@@ -1,5 +1,5 @@
import RequestMethod from '../RequestMethod';
import { IconLoader2, IconAlertTriangle, IconAlertCircle, IconAppWindow } from '@tabler/icons';
import { IconLoader2, IconAlertTriangle, IconAlertCircle } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
const CollectionItemIcon = ({ item }) => {
@@ -15,10 +15,6 @@ const CollectionItemIcon = ({ item }) => {
return <StyledWrapper><IconAlertTriangle size={18} className="w-fit mr-2 partial" strokeWidth={1.5} /></StyledWrapper>;
}
if (item?.type === 'app') {
return <IconAppWindow className="w-fit mr-2" size={16} strokeWidth={1.5} />;
}
return <RequestMethod item={item} />;
};

View File

@@ -185,7 +185,6 @@ const ExampleItem = ({ example, item, collection }) => {
return (
<StyledWrapper
ref={exampleRef}
data-testid="sidebar-response-example-item"
className={itemRowClassName}
onClick={handleExampleClick}
onDoubleClick={handleDoubleClick}

View File

@@ -18,8 +18,7 @@ import {
IconTrash,
IconSettings,
IconInfoCircle,
IconTerminal2,
IconAppWindow
IconTerminal2
} from '@tabler/icons';
import { useSelector, useDispatch } from 'react-redux';
import { addTab, focusTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
@@ -30,7 +29,6 @@ import { uuid } from 'utils/common';
import { copyRequest, setFocusedSidebarPath } from 'providers/ReduxStore/slices/app';
import NewRequest from 'components/Sidebar/NewRequest';
import NewFolder from 'components/Sidebar/NewFolder';
import NewApp from 'components/Sidebar/NewApp';
import RenameCollectionItem from './RenameCollectionItem';
import CloneCollectionItem from './CloneCollectionItem';
import DeleteCollectionItem from './DeleteCollectionItem';
@@ -97,7 +95,6 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
const [generateCodeItemModalOpen, setGenerateCodeItemModalOpen] = useState(false);
const [newRequestModalOpen, setNewRequestModalOpen] = useState(false);
const [newFolderModalOpen, setNewFolderModalOpen] = useState(false);
const [newAppModalOpen, setNewAppModalOpen] = useState(false);
const [runCollectionModalOpen, setRunCollectionModalOpen] = useState(false);
const [itemInfoModalOpen, setItemInfoModalOpen] = useState(false);
const [examplesExpanded, setExamplesExpanded] = useState(false);
@@ -130,12 +127,6 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
return false;
}, { enabled: isKeyboardFocused, deps: [isKeyboardFocused] });
useKeybinding('newRequest', () => {
if (!isFolder) return false;
setNewRequestModalOpen(true);
return false;
}, { enabled: isKeyboardFocused && isFolder, deps: [isKeyboardFocused, isFolder] });
const [dropType, setDropType] = useState(null); // 'adjacent' or 'inside'
const [{ isDragging }, drag, dragPreview] = useDrag({
@@ -260,8 +251,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
// scroll to the active tab
setTimeout(scrollToTheActiveTab, 50);
const isRequest = isItemARequest(item);
const isApp = item.type === 'app';
if (isRequest || isApp) {
if (isRequest) {
if (isTabForItemPresent) {
dispatch(
focusTab({
@@ -274,7 +264,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
addTab({
uid: item.uid,
collectionUid: collectionUid,
...(isRequest ? { requestPaneTab: getDefaultRequestPaneTab(item) } : {}),
requestPaneTab: getDefaultRequestPaneTab(item),
type: item.type,
pathname: item.pathname
})
@@ -355,12 +345,6 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
label: 'New Folder',
onClick: () => setNewFolderModalOpen(true)
},
{
id: 'new-app',
leftSection: IconAppWindow,
label: 'New App',
onClick: () => setNewAppModalOpen(true)
},
{
id: 'run',
leftSection: IconPlayerPlay,
@@ -557,10 +541,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
};
const folderItems = sortByNameThenSequence(filter(item.items, (i) => isItemAFolder(i) && !i.isTransient));
// Standalone 'app' items live alongside requests in the folder listing.
const requestItems = sortItemsBySequence(
filter(item.items, (i) => (isItemARequest(i) || i.type === 'app') && !i.isTransient)
);
const requestItems = sortItemsBySequence(filter(item.items, (i) => isItemARequest(i) && !i.isTransient));
const showEmptyFolderMessage = isFolder && !hasSearchText && !folderItems?.length && !requestItems?.length;
const emptyFolderMenuItems = createEmptyStateMenuItems({ dispatch, collection, itemUid: item.uid });
@@ -644,9 +625,6 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
{newFolderModalOpen && (
<NewFolder item={item} collectionUid={collectionUid} onClose={() => setNewFolderModalOpen(false)} />
)}
{newAppModalOpen && (
<NewApp item={item} collectionUid={collectionUid} onClose={() => setNewAppModalOpen(false)} />
)}
{runCollectionModalOpen && (
<RunCollectionItem collectionUid={collectionUid} item={item} onClose={() => setRunCollectionModalOpen(false)} />
)}

View File

@@ -1,30 +0,0 @@
import React, { memo, Fragment } from 'react';
const CollectionVersionInfo = ({ name, version, folderCount = 0, requestCount = 0, environmentCount = 0 }) => {
const folderLabel = folderCount === 1 ? 'Folder' : 'Folders';
const requestLabel = requestCount === 1 ? 'request' : 'requests';
return (
<div className="version-info" data-testid="version-info">
<div className="version-line">
<span className="collection-name" data-testid="collection-name">{name}</span>
{version ? (
<span className="version-value" data-testid="version-value">{`Version: ${version}`}</span>
) : null}
</div>
<p className="version-summary" data-testid="version-summary">
<span>{`${folderCount} ${folderLabel}`}</span>
<span className="version-dot" aria-hidden="true" />
<span>{`${requestCount} ${requestLabel}`}</span>
{environmentCount === 0 ? (
<Fragment>
<span className="version-dot" aria-hidden="true" />
<span>0 environments</span>
</Fragment>
) : null}
</p>
</div>
);
};
export default memo(CollectionVersionInfo);

View File

@@ -1,39 +0,0 @@
import '@testing-library/jest-dom';
import React from 'react';
import { render, screen } from '@testing-library/react';
import CollectionVersionInfo from './index';
describe('CollectionVersionInfo', () => {
it('shows the raw collection version with a "Version:" prefix and no "v"/semver formatting', () => {
render(<CollectionVersionInfo name="Hotel Booking API" version="1" />);
expect(screen.getByTestId('version-value')).toHaveTextContent('Version: 1');
});
it('shows whatever version string the collection has, unchanged', () => {
render(<CollectionVersionInfo name="API" version="2.3-beta" />);
expect(screen.getByTestId('version-value')).toHaveTextContent('Version: 2.3-beta');
});
it('omits the version when the collection has none', () => {
render(<CollectionVersionInfo name="API" />);
expect(screen.queryByTestId('version-value')).not.toBeInTheDocument();
});
it('renders folder and request counts and pluralizes them', () => {
const { rerender } = render(<CollectionVersionInfo name="API" folderCount={2} requestCount={5} environmentCount={3} />);
expect(screen.getByTestId('version-summary')).toHaveTextContent('2 Folders');
expect(screen.getByTestId('version-summary')).toHaveTextContent('5 requests');
rerender(<CollectionVersionInfo name="API" folderCount={1} requestCount={1} environmentCount={3} />);
expect(screen.getByTestId('version-summary')).toHaveTextContent('1 Folder');
expect(screen.getByTestId('version-summary')).toHaveTextContent('1 request');
});
it('shows "0 environments" only when there are no environments', () => {
const { rerender } = render(<CollectionVersionInfo name="API" folderCount={0} requestCount={0} environmentCount={0} />);
expect(screen.getByTestId('version-summary')).toHaveTextContent('0 environments');
rerender(<CollectionVersionInfo name="API" folderCount={0} requestCount={0} environmentCount={2} />);
expect(screen.getByTestId('version-summary')).not.toHaveTextContent('environments');
});
});

Some files were not shown because too many files have changed in this diff Show More