mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-03 17:38:36 +00:00
Compare commits
1 Commits
main
...
feat/test-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aeb6e87860 |
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@@ -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
|
||||
|
||||
13
.github/ISSUE_TEMPLATE/BugReport.yaml
vendored
13
.github/ISSUE_TEMPLATE/BugReport.yaml
vendored
@@ -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
|
||||
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/config.yaml
vendored
2
.github/ISSUE_TEMPLATE/config.yaml
vendored
@@ -1,4 +1,4 @@
|
||||
blank_issues_enabled: false
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Discussions
|
||||
url: https://github.com/usebruno/bruno/discussions
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
7
.github/workflows/benchmarks.yml
vendored
7
.github/workflows/benchmarks.yml
vendored
@@ -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
|
||||
|
||||
7
.github/workflows/flaky-test-detector.yml
vendored
7
.github/workflows/flaky-test-detector.yml
vendored
@@ -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
|
||||
|
||||
11
.github/workflows/tests-linux.yml
vendored
11
.github/workflows/tests-linux.yml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/tests-macos.yml
vendored
4
.github/workflows/tests-macos.yml
vendored
@@ -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)
|
||||
|
||||
4
.github/workflows/tests-windows.yml
vendored
4
.github/workflows/tests-windows.yml
vendored
@@ -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
81
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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...' }
|
||||
};
|
||||
@@ -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 > 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;
|
||||
@@ -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());
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}`);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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} />;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 ↗
|
||||
</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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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} />
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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><string></code>, <code><number></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;
|
||||
@@ -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); }
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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] })),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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)} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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} />;
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)} />
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
@@ -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
Reference in New Issue
Block a user