mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-03 09:28:33 +00:00
Compare commits
52 Commits
feat/fix-e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf3abec50e | ||
|
|
eca0939f31 | ||
|
|
408aef9eef | ||
|
|
022426a3ab | ||
|
|
bd271ffad8 | ||
|
|
6f96dfd58c | ||
|
|
488c9f2444 | ||
|
|
99538283a3 | ||
|
|
9157eb4190 | ||
|
|
7b155c0e0a | ||
|
|
4f31e9b073 | ||
|
|
ed468dba88 | ||
|
|
4ab68fc71f | ||
|
|
2f0f2e1c79 | ||
|
|
bb21d4c1c9 | ||
|
|
cfb0db3f90 | ||
|
|
c01e4a0ee2 | ||
|
|
49b9af1046 | ||
|
|
f6f76423bc | ||
|
|
1c9355e49e | ||
|
|
87f74262bb | ||
|
|
30b4512983 | ||
|
|
33e8f5ca4a | ||
|
|
0a3ee95310 | ||
|
|
7756564201 | ||
|
|
d6e17e1dab | ||
|
|
24bbd8cc36 | ||
|
|
b1b43be197 | ||
|
|
1ceea0797e | ||
|
|
d8d2fec166 | ||
|
|
437e0c7dac | ||
|
|
7765320aa5 | ||
|
|
42e9c9a309 | ||
|
|
84c53b65c2 | ||
|
|
cbc812b131 | ||
|
|
577730609c | ||
|
|
7adbc6b14e | ||
|
|
800ab6fea0 | ||
|
|
3f69977176 | ||
|
|
bf0e9bcf23 | ||
|
|
27c1970076 | ||
|
|
5f55a5924d | ||
|
|
222ac80f5e | ||
|
|
46498e8423 | ||
|
|
d2de2d590e | ||
|
|
683d487181 | ||
|
|
d1ebf578b2 | ||
|
|
21efded2bf | ||
|
|
fd30eaeccf | ||
|
|
ccf62641dd | ||
|
|
099cdaf232 | ||
|
|
34a61ebe28 |
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@@ -1 +1 @@
|
||||
* @helloanoop @maintainer-bruno @bijin-bruno @lohit-bruno @naman-bruno @sid-bruno @vijayh-bruno
|
||||
* @helloanoop @bijin-bruno @lohit-bruno @naman-bruno @sid-bruno @vijayh-bruno @utkarsh-bruno @sanish-bruno
|
||||
|
||||
13
.github/ISSUE_TEMPLATE/BugReport.yaml
vendored
13
.github/ISSUE_TEMPLATE/BugReport.yaml
vendored
@@ -49,14 +49,21 @@ body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the bug
|
||||
description: A clear and concise description of the bug and how it's effecting your work along with steps to reproduce.
|
||||
description: A clear and concise description of the bug and how it's affecting your work
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: .bru file to reproduce the bug
|
||||
description: Attach your .bru file here that can reproduce the problem.
|
||||
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
|
||||
validations:
|
||||
required: false
|
||||
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/config.yaml
vendored
2
.github/ISSUE_TEMPLATE/config.yaml
vendored
@@ -1,4 +1,4 @@
|
||||
blank_issues_enabled: true
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Discussions
|
||||
url: https://github.com/usebruno/bruno/discussions
|
||||
|
||||
@@ -11,5 +11,8 @@ runs:
|
||||
libglib2.0-0 libnss3 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libgtk-3-0 libasound2t64 \
|
||||
xvfb
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
@@ -11,5 +11,8 @@ runs:
|
||||
libglib2.0-0 libnss3 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libgtk-3-0 libasound2t64 \
|
||||
xvfb libxml2-utils
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
7
.github/workflows/benchmarks.yml
vendored
7
.github/workflows/benchmarks.yml
vendored
@@ -45,8 +45,11 @@ jobs:
|
||||
- name: Configure Chrome Sandbox
|
||||
if: matrix.os-name == 'ubuntu'
|
||||
run: |
|
||||
sudo chown root node_modules/electron/dist/chrome-sandbox
|
||||
sudo chmod 4755 node_modules/electron/dist/chrome-sandbox
|
||||
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
|
||||
|
||||
- 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,8 +40,11 @@ jobs:
|
||||
- name: Install npm dependencies
|
||||
run: |
|
||||
npm ci --legacy-peer-deps
|
||||
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
|
||||
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
|
||||
|
||||
- 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,6 +6,10 @@ 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)
|
||||
@@ -67,8 +71,11 @@ jobs:
|
||||
|
||||
- name: Configure Chrome Sandbox
|
||||
run: |
|
||||
sudo chown root node_modules/electron/dist/chrome-sandbox
|
||||
sudo chmod 4755 node_modules/electron/dist/chrome-sandbox
|
||||
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
|
||||
|
||||
- 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,6 +6,10 @@ 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,6 +6,10 @@ 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,11 +37,13 @@
|
||||
"@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",
|
||||
@@ -12678,6 +12680,16 @@
|
||||
"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",
|
||||
@@ -14176,9 +14188,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/adm-zip": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz",
|
||||
"integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==",
|
||||
"version": "0.5.17",
|
||||
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz",
|
||||
"integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0"
|
||||
@@ -23867,9 +23879,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.18.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.0.tgz",
|
||||
"integrity": "sha512-l1mfj2atMqndAHI3ls7XqPxEjV2J9ZkcNyHpoZA3r2T1LLwDB69jgkMWh71YKwhBbK0G2f4WSn05ahmQXVxupA==",
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
@@ -32605,6 +32617,12 @@
|
||||
"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",
|
||||
@@ -32937,6 +32955,7 @@
|
||||
"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",
|
||||
@@ -32964,7 +32983,7 @@
|
||||
"jsonschema": "^1.5.0",
|
||||
"know-your-http-well": "^0.5.0",
|
||||
"linkify-it": "^5.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash": "4.18.1",
|
||||
"markdown-it": "^13.0.2",
|
||||
"markdown-it-replace-link": "^1.2.0",
|
||||
"mime-types": "^3.0.2",
|
||||
@@ -34549,7 +34568,7 @@
|
||||
"https-proxy-agent": "^7.0.2",
|
||||
"iconv-lite": "^0.6.3",
|
||||
"js-yaml": "^4.1.1",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash": "4.18.1",
|
||||
"qs": "^6.14.1",
|
||||
"socks-proxy-agent": "^8.0.2",
|
||||
"xmlbuilder": "^15.1.1",
|
||||
@@ -35086,7 +35105,7 @@
|
||||
"@usebruno/schema": "^0.7.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
"jscodeshift": "^17.3.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash": "4.18.1",
|
||||
"nanoid": "3.3.8",
|
||||
"xml2js": "^0.6.2"
|
||||
},
|
||||
@@ -35218,7 +35237,7 @@
|
||||
"iconv-lite": "^0.6.3",
|
||||
"is-valid-path": "^0.1.1",
|
||||
"js-yaml": "^4.1.1",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash": "4.18.1",
|
||||
"mime-types": "^2.1.35",
|
||||
"nanoid": "3.3.8",
|
||||
"qs": "^6.14.1",
|
||||
@@ -35226,6 +35245,7 @@
|
||||
"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"
|
||||
},
|
||||
@@ -35768,7 +35788,7 @@
|
||||
"@usebruno/common": "0.1.0",
|
||||
"@usebruno/lang": "0.12.0",
|
||||
"ajv": "^8.17.1",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash": "4.18.1",
|
||||
"yaml": "^2.3.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -35982,7 +36002,7 @@
|
||||
"crypto-js": "^4.2.0",
|
||||
"json-query": "^2.2.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash": "4.18.1",
|
||||
"moment": "^2.29.4",
|
||||
"nanoid": "3.3.8",
|
||||
"node-fetch": "^2.7.0",
|
||||
@@ -36153,10 +36173,29 @@
|
||||
"@usebruno/common": "0.1.0",
|
||||
"arcsecond": "^5.0.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash": "4.18.1",
|
||||
"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",
|
||||
@@ -36385,6 +36424,16 @@
|
||||
"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",
|
||||
@@ -36402,7 +36451,7 @@
|
||||
"http-proxy": "^1.18.1",
|
||||
"js-yaml": "^4.1.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash": "4.18.1",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"ws": "^8.18.3"
|
||||
}
|
||||
@@ -36654,8 +36703,8 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"lodash": "^4.17.21"
|
||||
"lodash": "4.18.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,11 +30,13 @@
|
||||
"@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",
|
||||
@@ -83,6 +85,7 @@
|
||||
"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,6 +27,7 @@
|
||||
"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",
|
||||
@@ -54,7 +55,7 @@
|
||||
"jsonschema": "^1.5.0",
|
||||
"know-your-http-well": "^0.5.0",
|
||||
"linkify-it": "^5.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash": "4.18.1",
|
||||
"markdown-it": "^13.0.2",
|
||||
"markdown-it-replace-link": "^1.2.0",
|
||||
"mime-types": "^3.0.2",
|
||||
|
||||
@@ -34,16 +34,15 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
`;
|
||||
|
||||
// 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;
|
||||
|
||||
.popup-header {
|
||||
display: flex;
|
||||
@@ -219,6 +218,26 @@ const StyledWrapper = 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,9 +1,10 @@
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import get from 'lodash/get';
|
||||
import { IconStars, IconX, IconArrowBackUp } from '@tabler/icons';
|
||||
import { aiGenerateScript } from 'utils/ai';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import Tippy from '@tippyjs/react';
|
||||
import { IconStars, IconX, IconArrowBackUp, IconPlayerStop } from '@tabler/icons';
|
||||
import { aiGenerateScript, stopAiGeneration } from 'utils/ai';
|
||||
import StyledWrapper, { PopupWrapper } from './StyledWrapper';
|
||||
|
||||
const SUGGESTIONS = {
|
||||
'tests': [
|
||||
@@ -21,58 +22,84 @@ 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'
|
||||
'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'
|
||||
};
|
||||
|
||||
const isValidType = (t) => SUGGESTIONS[t] !== undefined;
|
||||
|
||||
const AIAssist = ({ scriptType, currentScript, requestContext, onApply }) => {
|
||||
const AIAssist = ({ scriptType, currentScript, requestContext, docsContext, variables, 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 buttonRef = useRef(null);
|
||||
const streamIdRef = useRef(null);
|
||||
const tippyRef = useRef(null);
|
||||
|
||||
const focusOnMount = useCallback((el) => {
|
||||
el?.focus();
|
||||
}, []);
|
||||
// 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 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(() => {
|
||||
setIsOpen(false);
|
||||
setError(null);
|
||||
tippyRef.current?.hide();
|
||||
}, []);
|
||||
|
||||
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();
|
||||
@@ -80,13 +107,22 @@ const AIAssist = ({ scriptType, currentScript, requestContext, onApply }) => {
|
||||
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
|
||||
requestContext,
|
||||
docsContext,
|
||||
variables,
|
||||
streamId
|
||||
});
|
||||
if (result?.stopped) {
|
||||
return;
|
||||
}
|
||||
if (result?.error) {
|
||||
setError(result.error);
|
||||
return;
|
||||
@@ -99,12 +135,19 @@ const AIAssist = ({ scriptType, currentScript, requestContext, onApply }) => {
|
||||
} catch (err) {
|
||||
setError(err?.message || 'Failed to generate script');
|
||||
} finally {
|
||||
streamIdRef.current = null;
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[prompt, isLoading, scriptType, currentScript, requestContext]
|
||||
[prompt, isLoading, scriptType, currentScript, requestContext, docsContext, variables]
|
||||
);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
if (streamIdRef.current) {
|
||||
stopAiGeneration(streamIdRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleApply = useCallback(() => {
|
||||
if (generated == null) return;
|
||||
onApply(generated);
|
||||
@@ -122,109 +165,136 @@ const AIAssist = ({ scriptType, currentScript, requestContext, onApply }) => {
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
className={`ai-assist-trigger ${isOpen ? 'open' : ''}`}
|
||||
onClick={() => setIsOpen((v) => !v)}
|
||||
title={title}
|
||||
type="button"
|
||||
aria-label={title}
|
||||
>
|
||||
<IconStars size={14} strokeWidth={1.75} />
|
||||
</button>
|
||||
<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>
|
||||
|
||||
{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
|
||||
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}
|
||||
/>
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{!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>
|
||||
{error && <div className="popup-error">{error}</div>}
|
||||
</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>
|
||||
)}
|
||||
<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
|
||||
className={`ai-assist-trigger ${isOpen ? 'open' : ''}`}
|
||||
title={title}
|
||||
type="button"
|
||||
aria-label={title}
|
||||
>
|
||||
<IconStars size={14} strokeWidth={1.75} />
|
||||
</button>
|
||||
</Tippy>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
404
packages/bruno-app/src/components/AIAssist/index.spec.js
Normal file
404
packages/bruno-app/src/components/AIAssist/index.spec.js
Normal file
@@ -0,0 +1,404 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
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;
|
||||
@@ -0,0 +1,298 @@
|
||||
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;
|
||||
@@ -0,0 +1,210 @@
|
||||
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;
|
||||
831
packages/bruno-app/src/components/AiChatSidebar/StyledWrapper.js
Normal file
831
packages/bruno-app/src/components/AiChatSidebar/StyledWrapper.js
Normal file
@@ -0,0 +1,831 @@
|
||||
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;
|
||||
54
packages/bruno-app/src/components/AiChatSidebar/constants.js
Normal file
54
packages/bruno-app/src/components/AiChatSidebar/constants.js
Normal file
@@ -0,0 +1,54 @@
|
||||
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...' }
|
||||
};
|
||||
864
packages/bruno-app/src/components/AiChatSidebar/index.js
Normal file
864
packages/bruno-app/src/components/AiChatSidebar/index.js
Normal file
@@ -0,0 +1,864 @@
|
||||
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;
|
||||
63
packages/bruno-app/src/components/AiChatSidebar/utils.js
Normal file
63
packages/bruno-app/src/components/AiChatSidebar/utils.js
Normal file
@@ -0,0 +1,63 @@
|
||||
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,8 +683,6 @@ 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,6 +146,8 @@ const AppTitleBar = () => {
|
||||
};
|
||||
|
||||
const handleWorkspaceSwitch = (workspaceUid) => {
|
||||
if (workspaceUid === activeWorkspaceUid) return;
|
||||
|
||||
dispatch(switchWorkspace(workspaceUid));
|
||||
toast.success(`Switched to ${getWorkspaceDisplayName(workspaces.find((w) => w.uid === workspaceUid)?.name)}`);
|
||||
};
|
||||
|
||||
48
packages/bruno-app/src/components/AppView/EmptyAppState.js
Normal file
48
packages/bruno-app/src/components/AppView/EmptyAppState.js
Normal file
@@ -0,0 +1,48 @@
|
||||
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;
|
||||
52
packages/bruno-app/src/components/AppView/StyledWrapper.js
Normal file
52
packages/bruno-app/src/components/AppView/StyledWrapper.js
Normal file
@@ -0,0 +1,52 @@
|
||||
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;
|
||||
307
packages/bruno-app/src/components/AppView/index.js
Normal file
307
packages/bruno-app/src/components/AppView/index.js
Normal file
@@ -0,0 +1,307 @@
|
||||
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;
|
||||
200
packages/bruno-app/src/components/AppView/webview-bridge.js
Normal file
200
packages/bruno-app/src/components/AppView/webview-bridge.js
Normal file
@@ -0,0 +1,200 @@
|
||||
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,6 +45,15 @@ 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,9 +6,12 @@
|
||||
*/
|
||||
|
||||
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';
|
||||
@@ -260,6 +263,24 @@ 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
|
||||
@@ -392,6 +413,7 @@ class CodeEditor extends React.Component {
|
||||
});
|
||||
}
|
||||
|
||||
this.aiAutocompleteCleanup?.();
|
||||
this.editor?._destroyLinkAware?.();
|
||||
this.editor.off('change', this._onEdit);
|
||||
|
||||
@@ -470,7 +492,15 @@ class CodeEditor extends React.Component {
|
||||
|
||||
const CodeEditorWithPersistenceScope = React.forwardRef((props, ref) => {
|
||||
const persistenceScope = usePersistenceScope();
|
||||
return <CodeEditor {...props} persistenceScope={persistenceScope} ref={ref} />;
|
||||
const aiPreferences = useSelector((state) => state.app.preferences?.ai);
|
||||
return (
|
||||
<CodeEditor
|
||||
{...props}
|
||||
persistenceScope={persistenceScope}
|
||||
aiPreferences={aiPreferences}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
CodeEditorWithPersistenceScope.displayName = 'CodeEditor';
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
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;
|
||||
396
packages/bruno-app/src/components/CollectionApp/index.js
Normal file
396
packages/bruno-app/src/components/CollectionApp/index.js
Normal file
@@ -0,0 +1,396 @@
|
||||
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;
|
||||
@@ -4,11 +4,13 @@ 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 { useRef } from 'react';
|
||||
import { useMemo, 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';
|
||||
@@ -25,6 +27,8 @@ 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.
|
||||
@@ -85,18 +89,21 @@ const Docs = ({ collection }) => {
|
||||
</div>
|
||||
</div>
|
||||
{isEditing ? (
|
||||
<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="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>
|
||||
) : (
|
||||
<div className="pl-1">
|
||||
<div className="h-[1px] min-h-[500px]">
|
||||
|
||||
@@ -8,6 +8,7 @@ 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();
|
||||
@@ -126,6 +127,8 @@ const Info = ({ collection }) => {
|
||||
</div>
|
||||
</div>
|
||||
{showGenerateDocumentationModal && <GenerateDocumentation collectionUid={collection.uid} onClose={() => setShowGenerateDocumentationModal(false)} />}
|
||||
|
||||
<Migration collection={collection} />
|
||||
</div>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
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;
|
||||
@@ -0,0 +1,92 @@
|
||||
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;
|
||||
@@ -0,0 +1,20 @@
|
||||
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;
|
||||
@@ -0,0 +1,64 @@
|
||||
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,9 +1,10 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import React, { useEffect, useMemo, 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';
|
||||
@@ -101,6 +102,8 @@ 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">
|
||||
@@ -137,12 +140,14 @@ 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>
|
||||
@@ -162,12 +167,14 @@ 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,8 +1,9 @@
|
||||
import React, { useRef } from 'react';
|
||||
import React, { useMemo, 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';
|
||||
@@ -37,6 +38,8 @@ 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>
|
||||
@@ -56,7 +59,7 @@ const Tests = ({ collection }) => {
|
||||
initialScroll={testsScroll}
|
||||
onScroll={setTestsScroll}
|
||||
/>
|
||||
<AIAssist scriptType="tests" currentScript={tests || ''} onApply={onEdit} />
|
||||
<AIAssist scriptType="tests" currentScript={tests || ''} variables={aiVariables} 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; /* Important for proper flex behavior */
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.network-empty {
|
||||
@@ -68,47 +68,40 @@ const StyledWrapper = styled.div`
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
min-height: 0; /* Important for proper flex behavior */
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.col-separator {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: ${(props) => props.theme.console.border};
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
&.is-resizing {
|
||||
cursor: col-resize;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
.requests-header {
|
||||
display: grid;
|
||||
padding: 0;
|
||||
flex-shrink: 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;
|
||||
flex-shrink: 0;
|
||||
|
||||
& > * {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.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};
|
||||
@@ -120,10 +113,7 @@ const StyledWrapper = styled.div`
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
svg { flex-shrink: 0; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,48 +121,70 @@ const StyledWrapper = styled.div`
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
min-height: 0; /* Important for proper scrolling */
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => props.theme.console.logHoverBg};
|
||||
& > * {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&: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};
|
||||
}
|
||||
}
|
||||
|
||||
.request-method {
|
||||
padding: 2px 8px 2px 16px;
|
||||
.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-status {
|
||||
padding: 2px 8px;
|
||||
}
|
||||
.request-method { padding: 2px 8px 2px 16px; }
|
||||
.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;
|
||||
min-width: 45px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.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;
|
||||
@@ -196,6 +208,9 @@ 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 {
|
||||
@@ -204,11 +219,12 @@ 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;
|
||||
@@ -216,6 +232,9 @@ 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,4 +1,5 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { usePersistedState } from 'hooks/usePersistedState';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import {
|
||||
IconNetwork,
|
||||
@@ -8,17 +9,17 @@ import {
|
||||
import {
|
||||
setSelectedRequest
|
||||
} from 'providers/ReduxStore/slices/logs';
|
||||
import { useResizableColumns } from 'hooks/useResizableColumns';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { getGridTemplate, getSeparatorPositions, sortRequests } from './utils';
|
||||
import { 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: 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: '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: 'size', label: 'Size', width: 80, align: 'right' }
|
||||
];
|
||||
|
||||
@@ -133,15 +134,27 @@ const RequestRow = ({ request, isSelected, onClick, gridTemplateColumns }) => {
|
||||
|
||||
const NetworkTab = () => {
|
||||
const dispatch = useDispatch();
|
||||
const [sortConfig, setSortConfig] = useState({ key: null, direction: null });
|
||||
const gridTemplateColumns = useMemo(() => getGridTemplate(COLUMNS), []);
|
||||
const separatorPositions = useMemo(() => getSeparatorPositions(COLUMNS), []);
|
||||
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 { 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
|
||||
@@ -155,7 +168,6 @@ const NetworkTab = () => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return requests.sort((a, b) => a.timestamp - b.timestamp);
|
||||
}, [collections]);
|
||||
|
||||
@@ -166,15 +178,11 @@ 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 };
|
||||
});
|
||||
@@ -195,7 +203,7 @@ const NetworkTab = () => {
|
||||
<span>Requests will appear here as you make API calls</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="requests-container">
|
||||
<div className={`requests-container${resizingIdx !== null ? ' is-resizing' : ''}`}>
|
||||
<div className="requests-header" style={{ gridTemplateColumns }}>
|
||||
{COLUMNS.map((col) => (
|
||||
<div
|
||||
@@ -214,27 +222,30 @@ const NetworkTab = () => {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="requests-list">
|
||||
<div ref={containerRef} 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((pos, i) =>
|
||||
pos ? (
|
||||
<div
|
||||
key={i}
|
||||
className="col-separator"
|
||||
style={'left' in pos ? { left: `${pos.left}px` } : { right: `${pos.right}px` }}
|
||||
/>
|
||||
) : null
|
||||
)}
|
||||
{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}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -29,6 +29,10 @@ 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: {
|
||||
@@ -55,6 +59,10 @@ const renderNetworkTab = (requests = []) => {
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('sort state cycle', () => {
|
||||
const requests = [
|
||||
makeRequest({ itemUid: 'a', method: 'GET' }),
|
||||
@@ -163,6 +171,26 @@ 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,29 +1,3 @@
|
||||
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,6 +314,7 @@ 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,4 +1,5 @@
|
||||
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';
|
||||
@@ -23,8 +24,7 @@ import {
|
||||
setActiveTab,
|
||||
clearDebugErrors,
|
||||
updateNetworkFilter,
|
||||
toggleAllNetworkFilters,
|
||||
updateRequestDetailsPanelWidth
|
||||
toggleAllNetworkFilters
|
||||
} 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 = useSelector((state) => state.logs.requestDetailsPanelWidth);
|
||||
const [savedDetailsPanelWidth, setSavedDetailsPanelWidth] = usePersistedState({ key: 'devtools-details-panel-width', default: 400 });
|
||||
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) => dispatch(updateRequestDetailsPanelWidth({ requestDetailsPanelWidth: newWidth }))
|
||||
onResizeEnd: (newWidth) => setSavedDetailsPanelWidth(newWidth)
|
||||
});
|
||||
|
||||
const logCounts = logs.reduce((counts, log) => {
|
||||
|
||||
@@ -4,11 +4,13 @@ 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 { useRef } from 'react';
|
||||
import { useMemo, 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';
|
||||
@@ -42,6 +44,10 @@ 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;
|
||||
@@ -54,18 +60,27 @@ const Documentation = ({ item, collection }) => {
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<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}
|
||||
/>
|
||||
<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>
|
||||
) : (
|
||||
<Markdown collectionPath={collection.pathname} onDoubleClick={toggleViewMode} content={docs} />
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useCallback, useRef, useState, useEffect, useMemo } from 'react';
|
||||
import { TableVirtuoso } from 'react-virtuoso';
|
||||
import cloneDeep from 'lodash/cloneDeep';
|
||||
import { IconTrash, IconAlertCircle } from '@tabler/icons';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import { IconTrash, IconAlertCircle, IconInfoCircle } from '@tabler/icons';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
|
||||
@@ -24,6 +25,15 @@ 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;
|
||||
@@ -49,8 +59,10 @@ const EnvironmentVariablesTable = ({
|
||||
onDraftClear,
|
||||
setIsModified,
|
||||
renderExtraValueContent,
|
||||
searchQuery = ''
|
||||
searchQuery = '',
|
||||
variableType = 'variables'
|
||||
}) => {
|
||||
const isSecretTab = variableType === 'secrets';
|
||||
const { storedTheme } = useTheme();
|
||||
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
|
||||
const activeWorkspace = useSelector((state) => {
|
||||
@@ -67,7 +79,6 @@ 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
|
||||
@@ -166,15 +177,19 @@ 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]);
|
||||
}, [collection, globalEnvironmentVariables, workspaceProcessEnvVariables, environment.uid]);
|
||||
|
||||
// Reuse the previous initialValues when only uids changed but the content is
|
||||
// identical.
|
||||
const initialValuesRef = useRef(null);
|
||||
const initialValues = useMemo(() => {
|
||||
const vars = environment.variables || [];
|
||||
return [
|
||||
const next = [
|
||||
...vars,
|
||||
{
|
||||
uid: uuid(),
|
||||
@@ -185,6 +200,12 @@ 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({
|
||||
@@ -255,7 +276,7 @@ const EnvironmentVariablesTable = ({
|
||||
name: '',
|
||||
value: '',
|
||||
type: 'text',
|
||||
secret: false,
|
||||
secret: isSecretTab,
|
||||
enabled: true
|
||||
}
|
||||
]);
|
||||
@@ -270,6 +291,18 @@ 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() !== '');
|
||||
@@ -354,7 +387,7 @@ const EnvironmentVariablesTable = ({
|
||||
name: '',
|
||||
value: '',
|
||||
type: 'text',
|
||||
secret: false,
|
||||
secret: isSecretTab,
|
||||
enabled: true
|
||||
}
|
||||
];
|
||||
@@ -369,12 +402,16 @@ 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: false,
|
||||
secret: isSecretTab,
|
||||
enabled: true
|
||||
};
|
||||
setTimeout(() => {
|
||||
@@ -395,25 +432,26 @@ const EnvironmentVariablesTable = ({
|
||||
};
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
const variablesToSave = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
|
||||
const belongsToActiveTab = (variable) => (isSecretTab ? !!variable.secret : !variable.secret);
|
||||
|
||||
const namedValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
|
||||
const savedValues = environment.variables || [];
|
||||
|
||||
// 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));
|
||||
// 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));
|
||||
if (!hasChanges) {
|
||||
toast.error('No changes to save');
|
||||
return;
|
||||
}
|
||||
|
||||
const hasValidationErrors = variablesToSave.some((variable) => {
|
||||
const hasValidationErrors = activeCurrent.some((variable) => {
|
||||
if (!variable.name || variable.name.trim() === '') {
|
||||
return true;
|
||||
}
|
||||
@@ -428,72 +466,182 @@ const EnvironmentVariablesTable = ({
|
||||
return;
|
||||
}
|
||||
|
||||
onSave(cloneDeep(variablesToSave))
|
||||
// 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))
|
||||
.then(() => {
|
||||
toast.success('Changes saved successfully');
|
||||
onDraftClear();
|
||||
const newValues = [
|
||||
...variablesToSave,
|
||||
{
|
||||
uid: uuid(),
|
||||
name: '',
|
||||
value: '',
|
||||
type: 'text',
|
||||
secret: false,
|
||||
enabled: true
|
||||
}
|
||||
];
|
||||
formik.resetForm({ values: newValues });
|
||||
|
||||
formik.resetForm({
|
||||
values: [
|
||||
...persistedVariables,
|
||||
{
|
||||
uid: uuid(),
|
||||
name: '',
|
||||
value: '',
|
||||
type: 'text',
|
||||
secret: isSecretTab,
|
||||
enabled: true
|
||||
}
|
||||
]
|
||||
});
|
||||
setIsModified(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
toast.error('An error occurred while saving the changes');
|
||||
});
|
||||
}, [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]);
|
||||
}, [formik.values, environment.variables, onSave, onDraftClear, setIsModified, isSecretTab]);
|
||||
|
||||
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 allVariables = formik.values.map((variable, index) => ({ variable, index }));
|
||||
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;
|
||||
});
|
||||
|
||||
if (!searchQuery?.trim()) {
|
||||
return allVariables;
|
||||
return tabVariables;
|
||||
}
|
||||
|
||||
const query = searchQuery.toLowerCase().trim();
|
||||
|
||||
const effectivePins = pinnedData.query === searchQuery ? pinnedData.uids : new Set();
|
||||
return allVariables.filter(({ variable }) => {
|
||||
return tabVariables.filter(({ variable }) => {
|
||||
if (effectivePins.has(variable.uid)) return true;
|
||||
const nameMatch = variable.name ? variable.name.toLowerCase().includes(query) : false;
|
||||
const valueText
|
||||
@@ -505,7 +653,7 @@ const EnvironmentVariablesTable = ({
|
||||
const valueMatch = valueText.toLowerCase().includes(query);
|
||||
return !!(nameMatch || valueMatch);
|
||||
});
|
||||
}, [formik.values, searchQuery, pinnedData]);
|
||||
}, [formik.values, searchQuery, pinnedData, isSecretTab]);
|
||||
|
||||
const isSearchActive = !!searchQuery?.trim();
|
||||
|
||||
@@ -535,7 +683,6 @@ const EnvironmentVariablesTable = ({
|
||||
/>
|
||||
</td>
|
||||
<td style={{ width: columnWidths.value }}>Value</td>
|
||||
<td className="text-center">Secret</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
)}
|
||||
@@ -601,11 +748,6 @@ 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(() => {
|
||||
@@ -614,7 +756,7 @@ const EnvironmentVariablesTable = ({
|
||||
name: '',
|
||||
value: '',
|
||||
type: 'text',
|
||||
secret: false,
|
||||
secret: isSecretTab,
|
||||
enabled: true
|
||||
}, false);
|
||||
}, 0);
|
||||
@@ -639,17 +781,6 @@ 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}>
|
||||
<Button color="danger" onClick={onCloseWithoutSave} data-testid="env-unsaved-close-without-save">
|
||||
Don't Save
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" color="secondary" variant="ghost" onClick={onCancel}>
|
||||
<Button size="sm" color="secondary" variant="ghost" onClick={onCancel} data-testid="env-unsaved-cancel">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onSaveAndClose}>
|
||||
<Button onClick={onSaveAndClose} data-testid="env-unsaved-save-and-close">
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -21,14 +21,14 @@ const DeleteEnvironment = ({ onClose, environment, collection }) => {
|
||||
<Portal>
|
||||
<StyledWrapper>
|
||||
<Modal
|
||||
size="sm"
|
||||
size="md"
|
||||
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 = '' }) => {
|
||||
const EnvironmentVariables = ({ environment, setIsModified, collection, searchQuery = '', variableType = 'variables' }) => {
|
||||
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. Mark it as a secret for security"
|
||||
warningMessage="This variable is used in sensitive fields. Add it as a secret in the Secrets tab for security"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -113,6 +113,7 @@ const EnvironmentVariables = ({ environment, setIsModified, collection, searchQu
|
||||
setIsModified={setIsModified}
|
||||
renderExtraValueContent={renderExtraValueContent}
|
||||
searchQuery={searchQuery}
|
||||
variableType={variableType}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -96,6 +96,17 @@ 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;
|
||||
@@ -150,30 +161,6 @@ 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};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,6 +170,7 @@ 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 } from '@tabler/icons';
|
||||
import { IconCopy, IconEdit, IconTrash, IconCheck, IconX, IconSearch, IconDeviceFloppy } from '@tabler/icons';
|
||||
import { useState, useRef } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } 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,8 +8,16 @@ 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 || [];
|
||||
@@ -19,7 +27,11 @@ 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() === '') {
|
||||
@@ -133,6 +145,10 @@ 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 && (
|
||||
@@ -187,48 +203,66 @@ const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuer
|
||||
</div>
|
||||
{nameError && isRenaming && <div className="title-error">{nameError}</div>}
|
||||
<div className="actions">
|
||||
{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>
|
||||
<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">
|
||||
<ActionIcon label="Save" onClick={handleSaveAll} data-testid="save-all-env">
|
||||
<IconDeviceFloppy 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={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>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={handleSearchIconClick} title="Search variables">
|
||||
<IconSearch size={15} strokeWidth={1.5} />
|
||||
</button>
|
||||
)}
|
||||
<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>
|
||||
rightContentRef={rightContentRef}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="content">
|
||||
@@ -237,6 +271,7 @@ 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,6 +111,7 @@ const StyledWrapper = styled.div`
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 8px;
|
||||
border-right: 1px solid ${(props) => props.theme.border.border0};
|
||||
}
|
||||
|
||||
.section-header {
|
||||
@@ -163,7 +164,7 @@ const StyledWrapper = styled.div`
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: background 0.15s ease;
|
||||
|
||||
|
||||
.environment-name {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
@@ -216,18 +217,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};
|
||||
}
|
||||
@@ -239,7 +240,7 @@ const StyledWrapper = styled.div`
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
|
||||
|
||||
.environment-name-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -249,12 +250,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;
|
||||
@@ -273,12 +274,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;
|
||||
@@ -299,25 +300,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,16 +1,33 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { updateTabState } from 'providers/ReduxStore/slices/tabs';
|
||||
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 [selectedEnvironment, setSelectedEnvironment] = useState(() => {
|
||||
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(() => {
|
||||
if (!environments.length) return null;
|
||||
return environments.find((env) => env.uid === collection?.activeEnvironmentUid) || environments[0];
|
||||
});
|
||||
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 } }));
|
||||
};
|
||||
|
||||
const [showExportModal, setShowExportModal] = useState(false);
|
||||
|
||||
return (
|
||||
|
||||
@@ -4,11 +4,13 @@ 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 { useRef } from 'react';
|
||||
import { useMemo, 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';
|
||||
@@ -43,6 +45,8 @@ 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;
|
||||
@@ -56,7 +60,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">
|
||||
<div className="mt-2 flex-1 overflow-auto min-h-0 relative">
|
||||
<CodeEditor
|
||||
collection={collection}
|
||||
theme={displayedTheme}
|
||||
@@ -69,6 +73,7 @@ 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,9 +1,10 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import React, { useEffect, useMemo, 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';
|
||||
@@ -102,6 +103,8 @@ 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);
|
||||
@@ -142,12 +145,14 @@ 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>
|
||||
@@ -167,12 +172,14 @@ 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,8 +1,9 @@
|
||||
import React, { useRef } from 'react';
|
||||
import React, { useMemo, 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';
|
||||
@@ -38,6 +39,8 @@ 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>
|
||||
@@ -57,7 +60,7 @@ const Tests = ({ collection, folder }) => {
|
||||
initialScroll={testsScroll}
|
||||
onScroll={setTestsScroll}
|
||||
/>
|
||||
<AIAssist scriptType="tests" currentScript={tests || ''} onApply={onEdit} />
|
||||
<AIAssist scriptType="tests" currentScript={tests || ''} variables={aiVariables} 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 }) => {
|
||||
const Markdown = ({ collectionPath, onDoubleClick, content, allowHtml = true }) => {
|
||||
const markdownItOptions = {
|
||||
html: true,
|
||||
html: allowHtml,
|
||||
breaks: true,
|
||||
linkify: true,
|
||||
replaceLink: function (link, env) {
|
||||
@@ -35,7 +35,7 @@ const Markdown = ({ collectionPath, onDoubleClick, content }) => {
|
||||
};
|
||||
|
||||
const md = new MarkdownIt(markdownItOptions).use(MarkdownItReplaceLink);
|
||||
const htmlFromMarkdown = useMemo(() => md.render(content || ''), [content, collectionPath]);
|
||||
const htmlFromMarkdown = useMemo(() => md.render(content || ''), [content, collectionPath, allowHtml]);
|
||||
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" onClick={() => this.toggleVisibleSecret()}>
|
||||
<button className="mx-2" data-testid="secret-reveal-toggle" onClick={() => this.toggleVisibleSecret()}>
|
||||
{this.state.maskInput === true ? (
|
||||
<IconEyeOff size={18} strokeWidth={2} />
|
||||
) : (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import DOMPurify from 'dompurify';
|
||||
import Markdown from 'components/MarkDown';
|
||||
import { parseToRgb, rgba } from 'polished';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import { humanizeDate } from 'utils/common';
|
||||
@@ -19,47 +19,9 @@ 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">
|
||||
@@ -81,13 +43,9 @@ const NotificationDetail = ({ notification }) => {
|
||||
</div>
|
||||
<div className="notif-detail-title">{notification.title}</div>
|
||||
</div>
|
||||
<iframe
|
||||
key={notification.id}
|
||||
className="notif-detail-body"
|
||||
title="Notification details"
|
||||
sandbox="allow-popups"
|
||||
srcDoc={buildDescriptionDocument(notification.description)}
|
||||
/>
|
||||
<div key={notification.id} className="notif-detail-body">
|
||||
<Markdown content={notification.description} allowHtml={false} onDoubleClick={() => {}} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -244,8 +244,50 @@ const StyledWrapper = styled.div`
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.notif-empty {
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
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;
|
||||
@@ -0,0 +1,467 @@
|
||||
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;
|
||||
255
packages/bruno-app/src/components/Preferences/AI/SecurityPane.js
Normal file
255
packages/bruno-app/src/components/Preferences/AI/SecurityPane.js
Normal file
@@ -0,0 +1,255 @@
|
||||
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,6 +3,46 @@ 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};
|
||||
@@ -230,6 +270,314 @@ 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,22 +1,56 @@
|
||||
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 { IconStars } from '@tabler/icons';
|
||||
import { IconPlus, IconSettings, IconShieldLock, IconTerminal2 } from '@tabler/icons';
|
||||
import { savePreferences } from 'providers/ReduxStore/slices/app';
|
||||
import ToggleSwitch from 'components/ToggleSwitch';
|
||||
import { getAiStatus } from 'utils/ai';
|
||||
import { clearAiApiKey, 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()
|
||||
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)
|
||||
})
|
||||
});
|
||||
|
||||
const AI = () => {
|
||||
@@ -24,6 +58,7 @@ 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 {
|
||||
@@ -43,6 +78,12 @@ 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) => {
|
||||
@@ -50,14 +91,28 @@ const AI = () => {
|
||||
return acc;
|
||||
}, {}),
|
||||
models: get(preferences, 'ai.models', {}),
|
||||
defaultModel: get(preferences, 'ai.defaultModel', '')
|
||||
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', [])
|
||||
}
|
||||
},
|
||||
validationSchema: aiPreferencesSchema,
|
||||
onSubmit: () => {}
|
||||
});
|
||||
|
||||
const handleSave = useCallback(
|
||||
(values) => {
|
||||
(values) =>
|
||||
dispatch(
|
||||
savePreferences({
|
||||
...preferences,
|
||||
@@ -65,15 +120,35 @@ const AI = () => {
|
||||
enabled: values.enabled,
|
||||
providers: values.providers,
|
||||
models: values.models,
|
||||
defaultModel: values.defaultModel || ''
|
||||
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
|
||||
: []
|
||||
}
|
||||
}
|
||||
})
|
||||
).catch((err) => {
|
||||
console.error('Failed to save AI preferences:', err);
|
||||
toast.error('Failed to save AI preferences');
|
||||
});
|
||||
},
|
||||
[dispatch, preferences]
|
||||
)
|
||||
.then(() => refreshStatus())
|
||||
.catch((err) => {
|
||||
console.error('Failed to save AI preferences:', err);
|
||||
toast.error('Failed to save AI preferences');
|
||||
throw err;
|
||||
}),
|
||||
[dispatch, preferences, refreshStatus]
|
||||
);
|
||||
|
||||
const handleSaveRef = useRef(handleSave);
|
||||
@@ -112,40 +187,135 @@ const AI = () => {
|
||||
formik.setFieldValue(`models.${modelId}.enabled`, next);
|
||||
};
|
||||
|
||||
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) => {
|
||||
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) => {
|
||||
if (!formik.values.providers?.[m.provider]?.enabled) return false;
|
||||
if (!status.providers?.[m.provider]?.configured) return false;
|
||||
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]);
|
||||
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 (
|
||||
<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-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 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>
|
||||
|
||||
{statusError && (
|
||||
@@ -154,46 +324,177 @@ const AI = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!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.
|
||||
{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)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!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 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>
|
||||
)}
|
||||
|
||||
{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);
|
||||
{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>
|
||||
)}
|
||||
|
||||
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>
|
||||
</>
|
||||
{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>
|
||||
)}
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -2,11 +2,85 @@ import styled from 'styled-components';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
color: ${(props) => props.theme.text};
|
||||
|
||||
form.bruno-form {
|
||||
label {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,120 +1,147 @@
|
||||
import React, { useEffect, useCallback, useRef } from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
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 StyledWrapper from './StyledWrapper';
|
||||
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()
|
||||
})
|
||||
});
|
||||
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';
|
||||
|
||||
const Cache = () => {
|
||||
const preferences = useSelector((state) => state.app.preferences);
|
||||
const dispatch = useDispatch();
|
||||
const { theme } = useTheme();
|
||||
const { ipcRenderer } = window;
|
||||
|
||||
const handleSave = useCallback(
|
||||
(newCachePreferences) => {
|
||||
dispatch(
|
||||
savePreferences({
|
||||
...preferences,
|
||||
cache: newCachePreferences
|
||||
})
|
||||
).catch(() => toast.error('Failed to update cache preferences'));
|
||||
},
|
||||
[dispatch, preferences]
|
||||
);
|
||||
const fileCacheEnabled = get(preferences, 'cache.file.enabled', false);
|
||||
const sslSessionEnabled = get(preferences, 'cache.sslSession.enabled', false);
|
||||
|
||||
const handleSaveRef = useRef(handleSave);
|
||||
handleSaveRef.current = handleSave;
|
||||
const [fileCacheSize, setFileCacheSize] = useState(null);
|
||||
|
||||
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),
|
||||
[]
|
||||
);
|
||||
const refreshFileCacheSize = useCallback(() => {
|
||||
if (!ipcRenderer) return;
|
||||
ipcRenderer
|
||||
.invoke('renderer:get-file-cache-size')
|
||||
.then((size) => setFileCacheSize(size))
|
||||
.catch(() => setFileCacheSize(null));
|
||||
}, [ipcRenderer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (formik.dirty && formik.isValid) {
|
||||
debouncedSave(formik.values);
|
||||
}
|
||||
return () => {
|
||||
debouncedSave.flush();
|
||||
};
|
||||
}, [formik.values, formik.dirty, formik.isValid, debouncedSave]);
|
||||
refreshFileCacheSize();
|
||||
}, [refreshFileCacheSize, fileCacheEnabled]);
|
||||
|
||||
const handleAgentCachingChange = (e) => {
|
||||
formik.handleChange(e);
|
||||
// Immediately evict all cached agents when caching is disabled
|
||||
if (!e.target.checked) {
|
||||
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) {
|
||||
dispatch(clearHttpHttpsAgentCache()).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetCache = () => {
|
||||
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 = () => {
|
||||
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">
|
||||
<form className="bruno-form" onSubmit={formik.handleSubmit}>
|
||||
<div className="section-title mt-6 mb-3">Cache SSL Session</div>
|
||||
<div className="cache-section-title">Cache</div>
|
||||
|
||||
<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"
|
||||
<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}
|
||||
/>
|
||||
<label className="block ml-2 select-none" htmlFor="sslSession.enabled">
|
||||
Enable SSL session caching
|
||||
</label>
|
||||
</div>
|
||||
<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 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>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<button type="button" className="text-link cursor-pointer hover:underline" onClick={handleResetCache}>
|
||||
Clear
|
||||
</button>
|
||||
<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>
|
||||
</form>
|
||||
<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>
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
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;
|
||||
@@ -0,0 +1,69 @@
|
||||
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;
|
||||
@@ -13,6 +13,7 @@ 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';
|
||||
@@ -30,6 +31,7 @@ const TAB_CONFIG = [
|
||||
{ key: 'assert', label: 'Assert' },
|
||||
{ key: 'tests', label: 'Tests' },
|
||||
{ key: 'docs', label: 'Docs' },
|
||||
{ key: 'app', label: 'App' },
|
||||
{ key: 'settings', label: 'Settings' }
|
||||
];
|
||||
|
||||
@@ -43,6 +45,7 @@ const TAB_PANELS = {
|
||||
script: Script,
|
||||
tests: Tests,
|
||||
docs: Documentation,
|
||||
app: AppCodeEditor,
|
||||
settings: Settings
|
||||
};
|
||||
|
||||
@@ -71,6 +74,7 @@ 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,
|
||||
@@ -106,9 +110,10 @@ 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, tags]);
|
||||
}, [activeCounts, body.mode, hasAuth, script, item.preRequestScriptErrorMessage, item.postResponseScriptErrorMessage, item.testScriptErrorMessage, tests, docs, app, 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 { buildRequestContextFromItem } from 'utils/ai';
|
||||
import { buildAiContextPayload } 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,7 +95,10 @@ const Script = ({ item, collection }) => {
|
||||
const onRun = () => dispatch(sendRequest(item, collection.uid));
|
||||
const onSave = () => dispatch(saveRequest(item.uid, collection.uid));
|
||||
|
||||
const requestContext = useMemo(() => buildRequestContextFromItem(item), [item]);
|
||||
const { requestContext, variables: aiVariables } = useMemo(
|
||||
() => buildAiContextPayload(item, collection),
|
||||
[item, collection]
|
||||
);
|
||||
|
||||
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
|
||||
const hasPostResponseScript = responseScript && responseScript.trim().length > 0;
|
||||
@@ -127,6 +130,7 @@ const Script = ({ item, collection }) => {
|
||||
<CodeEditor
|
||||
ref={preRequestEditorRef}
|
||||
collection={collection}
|
||||
item={item}
|
||||
docKey="script:pre-request"
|
||||
value={requestScript || ''}
|
||||
theme={displayedTheme}
|
||||
@@ -137,6 +141,7 @@ const Script = ({ item, collection }) => {
|
||||
onRun={onRun}
|
||||
onSave={onSave}
|
||||
showHintsFor={['req', 'bru']}
|
||||
scriptType="pre-request"
|
||||
initialScroll={preReqScroll}
|
||||
onScroll={setPreReqScroll}
|
||||
/>
|
||||
@@ -144,6 +149,7 @@ const Script = ({ item, collection }) => {
|
||||
scriptType="pre-request"
|
||||
currentScript={requestScript || ''}
|
||||
requestContext={requestContext}
|
||||
variables={aiVariables}
|
||||
onApply={onRequestScriptEdit}
|
||||
/>
|
||||
</div>
|
||||
@@ -154,6 +160,7 @@ const Script = ({ item, collection }) => {
|
||||
<CodeEditor
|
||||
ref={postResponseEditorRef}
|
||||
collection={collection}
|
||||
item={item}
|
||||
docKey="script:post-response"
|
||||
value={responseScript || ''}
|
||||
theme={displayedTheme}
|
||||
@@ -164,6 +171,7 @@ const Script = ({ item, collection }) => {
|
||||
onRun={onRun}
|
||||
onSave={onSave}
|
||||
showHintsFor={['req', 'res', 'bru']}
|
||||
scriptType="post-response"
|
||||
initialScroll={postResScroll}
|
||||
onScroll={setPostResScroll}
|
||||
/>
|
||||
@@ -171,6 +179,7 @@ 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 { buildRequestContextFromItem } from 'utils/ai';
|
||||
import { buildAiContextPayload } 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,13 +38,17 @@ const Tests = ({ item, collection }) => {
|
||||
scriptPhase: 'test'
|
||||
});
|
||||
|
||||
const requestContext = useMemo(() => buildRequestContextFromItem(item), [item]);
|
||||
const { requestContext, variables: aiVariables } = useMemo(
|
||||
() => buildAiContextPayload(item, collection),
|
||||
[item, collection]
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-testid="test-script-editor" className="relative h-full">
|
||||
<CodeEditor
|
||||
ref={testsEditorRef}
|
||||
collection={collection}
|
||||
item={item}
|
||||
docKey="tests"
|
||||
value={tests || ''}
|
||||
theme={displayedTheme}
|
||||
@@ -55,10 +59,11 @@ 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} onApply={onEdit} />
|
||||
<AIAssist scriptType="tests" currentScript={tests || ''} requestContext={requestContext} variables={aiVariables} onApply={onEdit} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
@@ -20,6 +21,8 @@ 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';
|
||||
@@ -292,6 +295,58 @@ 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;
|
||||
@@ -493,6 +548,30 @@ 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,6 +151,62 @@ 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;
|
||||
@@ -159,6 +215,40 @@ 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,13 +14,22 @@ import {
|
||||
IconFolder,
|
||||
IconUpload,
|
||||
IconFileCode,
|
||||
IconFileOff
|
||||
IconFileOff,
|
||||
IconCode,
|
||||
IconAppWindow,
|
||||
IconTransform,
|
||||
IconStars
|
||||
} 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 } from 'providers/ReduxStore/slices/collections';
|
||||
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 { addTab, focusTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import { uuid } from 'utils/common';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -38,23 +47,79 @@ 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();
|
||||
@@ -231,7 +296,11 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
// Build overflow menu items for the "..." dropdown
|
||||
const overflowMenuItems = [
|
||||
{ id: 'variables', label: 'Variables', leftSection: IconEye, onClick: viewVariables },
|
||||
{ id: 'file-mode', label: collection.fileMode ? 'Switch to Code Mode' : 'Switch to File Mode', leftSection: collection.fileMode ? IconFileOff : IconFileCode, onClick: handleFileModeClick },
|
||||
// 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 }]
|
||||
: []),
|
||||
...(!hasOpenApiSyncConfigured
|
||||
? [{ id: 'openapi-sync', label: 'OpenAPI', leftSection: OpenAPISyncIcon, onClick: viewOpenApiSync }]
|
||||
: []),
|
||||
@@ -581,45 +650,136 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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 }} />
|
||||
)}
|
||||
<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} />
|
||||
</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} />
|
||||
</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>
|
||||
)}
|
||||
{/* 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ const GradientCloseButton = ({ onClick, hasChanges = false }) => {
|
||||
return (
|
||||
<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">
|
||||
<span className="draft-icon-wrapper" data-testid="tab-draft-icon">
|
||||
<DraftTabIcon />
|
||||
</span>
|
||||
<span className="close-icon-wrapper">
|
||||
|
||||
@@ -16,6 +16,7 @@ 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';
|
||||
@@ -255,7 +256,9 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
if (environmentUid?.startsWith('dotenv:')) {
|
||||
window.dispatchEvent(new Event('dotenv-save'));
|
||||
} else {
|
||||
dispatch(saveEnvironment(variables, environmentUid, collection.uid));
|
||||
dispatch(saveEnvironment(variables, environmentUid, collection.uid))
|
||||
.then(() => toast.success('Changes saved successfully'))
|
||||
.catch(() => toast.error('An error occurred while saving the changes'));
|
||||
}
|
||||
}
|
||||
} else if (tab.type === 'global-environment-settings') {
|
||||
@@ -264,7 +267,9 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
if (environmentUid?.startsWith('dotenv:')) {
|
||||
window.dispatchEvent(new Event('dotenv-save'));
|
||||
} else {
|
||||
dispatch(saveGlobalEnvironment({ variables, environmentUid }));
|
||||
dispatch(saveGlobalEnvironment({ variables, environmentUid }))
|
||||
.then(() => toast.success('Changes saved successfully'))
|
||||
.catch(() => toast.error('An error occurred while saving the changes'));
|
||||
}
|
||||
}
|
||||
} else if (tab.type === 'folder-settings') {
|
||||
@@ -576,9 +581,15 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="tab-method uppercase" style={{ color: getMethodColor(method) }}>
|
||||
{method}
|
||||
</span>
|
||||
{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 ref={tabNameRef} className="ml-1 tab-name" title={item.name}>
|
||||
{item.name}
|
||||
</span>
|
||||
|
||||
@@ -22,6 +22,7 @@ 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',
|
||||
@@ -154,7 +155,7 @@ export const BulkImportCollectionLocation = ({
|
||||
const [applyToGlobal, setApplyToGlobal] = useState(true);
|
||||
const [applyToCollection, setApplyToCollection] = useState(false);
|
||||
const [groupingType, setGroupingType] = useState('tags');
|
||||
const [collectionFormat, setCollectionFormat] = useState('bru');
|
||||
const [collectionFormat, setCollectionFormat] = useState(DEFAULT_COLLECTION_FORMAT);
|
||||
const [renamedCollectionNames, setRenamedCollectionNames] = useState({});
|
||||
const [renamedEnvironmentNames, setRenamedEnvironmentNames] = useState({});
|
||||
const [importIssues, setImportIssues] = useState({});
|
||||
@@ -585,6 +586,7 @@ 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}
|
||||
@@ -836,6 +838,7 @@ 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"
|
||||
@@ -878,6 +881,7 @@ 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 } from '@tabler/icons';
|
||||
import { IconLoader2, IconAlertTriangle, IconAlertCircle, IconAppWindow } from '@tabler/icons';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const CollectionItemIcon = ({ item }) => {
|
||||
@@ -15,6 +15,10 @@ 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,6 +185,7 @@ const ExampleItem = ({ example, item, collection }) => {
|
||||
return (
|
||||
<StyledWrapper
|
||||
ref={exampleRef}
|
||||
data-testid="sidebar-response-example-item"
|
||||
className={itemRowClassName}
|
||||
onClick={handleExampleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
|
||||
@@ -18,7 +18,8 @@ import {
|
||||
IconTrash,
|
||||
IconSettings,
|
||||
IconInfoCircle,
|
||||
IconTerminal2
|
||||
IconTerminal2,
|
||||
IconAppWindow
|
||||
} from '@tabler/icons';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { addTab, focusTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
|
||||
@@ -29,6 +30,7 @@ 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';
|
||||
@@ -95,6 +97,7 @@ 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);
|
||||
@@ -127,6 +130,12 @@ 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({
|
||||
@@ -251,7 +260,8 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
// scroll to the active tab
|
||||
setTimeout(scrollToTheActiveTab, 50);
|
||||
const isRequest = isItemARequest(item);
|
||||
if (isRequest) {
|
||||
const isApp = item.type === 'app';
|
||||
if (isRequest || isApp) {
|
||||
if (isTabForItemPresent) {
|
||||
dispatch(
|
||||
focusTab({
|
||||
@@ -264,7 +274,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
addTab({
|
||||
uid: item.uid,
|
||||
collectionUid: collectionUid,
|
||||
requestPaneTab: getDefaultRequestPaneTab(item),
|
||||
...(isRequest ? { requestPaneTab: getDefaultRequestPaneTab(item) } : {}),
|
||||
type: item.type,
|
||||
pathname: item.pathname
|
||||
})
|
||||
@@ -345,6 +355,12 @@ 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,
|
||||
@@ -541,7 +557,10 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText })
|
||||
};
|
||||
|
||||
const folderItems = sortByNameThenSequence(filter(item.items, (i) => isItemAFolder(i) && !i.isTransient));
|
||||
const requestItems = sortItemsBySequence(filter(item.items, (i) => isItemARequest(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 showEmptyFolderMessage = isFolder && !hasSearchText && !folderItems?.length && !requestItems?.length;
|
||||
|
||||
const emptyFolderMenuItems = createEmptyStateMenuItems({ dispatch, collection, itemUid: item.uid });
|
||||
@@ -625,6 +644,9 @@ 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,36 +1,27 @@
|
||||
import React, { memo } from 'react';
|
||||
import semver from 'semver';
|
||||
import React, { memo, Fragment } from 'react';
|
||||
|
||||
const DEFAULT_VERSION = 'v1.0.0';
|
||||
|
||||
/**
|
||||
* Normalise a raw collection version for display: coerce partials to a full
|
||||
* major.minor.patch ("1" -> "v1.0.0", "2.1" -> "v2.1.0"), keep a single "v"
|
||||
* prefix, preserve pre-releases ("1.0.0-beta" -> "v1.0.0-beta"), and fall back to
|
||||
* the default when the version is unset or unparseable.
|
||||
*/
|
||||
const formatVersion = (version) => {
|
||||
const coerced = semver.coerce(version, { includePrerelease: true });
|
||||
return coerced ? `v${coerced.version}` : DEFAULT_VERSION;
|
||||
};
|
||||
|
||||
/**
|
||||
* Read-only display of the collection's current version and a summary of its
|
||||
* contents (folder + request counts). Presentational and prop-driven so it can be
|
||||
* reused wherever the collection version needs to be shown.
|
||||
*/
|
||||
const CollectionVersionInfo = ({ version, folderCount = 0, requestCount = 0 }) => {
|
||||
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="version-label">Collection Version:</span>{' '}
|
||||
<span className="version-value" data-testid="version-value">{formatVersion(version)}</span>
|
||||
<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">
|
||||
{`${folderCount} ${folderLabel} • ${requestCount} ${requestLabel}`}
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -2,14 +2,14 @@ import React, { useCallback, useEffect, useMemo, useRef, memo } from 'react';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
import ColorBadge from 'components/ColorBadge';
|
||||
|
||||
// Show at most 5 environments at a glance; the list virtualises and scrolls beyond
|
||||
// Show at most 6 environments at a glance; the list virtualises and scrolls beyond
|
||||
// that, so it stays performant even for collections with hundreds of environments
|
||||
// (only the visible rows are ever in the DOM).
|
||||
const MAX_VISIBLE_ROWS = 5;
|
||||
const MAX_VISIBLE_ROWS = 6;
|
||||
|
||||
// Fixed row height (px). MUST stay in sync with the `.env-row` height in StyledWrapper.js,
|
||||
// since it is passed to Virtuoso as `fixedItemHeight`.
|
||||
const ENV_ROW_HEIGHT = 34;
|
||||
const ENV_ROW_HEIGHT = 28;
|
||||
|
||||
/**
|
||||
* A selectable, virtualised list of collection environments (checkbox + color dot + name)
|
||||
|
||||
@@ -14,33 +14,57 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
.config-card {
|
||||
border: 1px solid ${(props) => props.theme.border.border1};
|
||||
border: 1px solid ${(props) => props.theme.table.border};
|
||||
border-radius: ${(props) => props.theme.border.radius.md};
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
|
||||
.version-info {
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: ${(props) => props.theme.background.mantle};
|
||||
|
||||
.version-line {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.text};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.version-label {
|
||||
.collection-name {
|
||||
font-weight: 500;
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
color: ${(props) => props.theme.text};
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.version-value {
|
||||
font-weight: 400;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.colors.text.subtext2};
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.version-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.colors.text.subtext2};
|
||||
}
|
||||
|
||||
.version-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background-color: ${(props) => props.theme.colors.text.subtext0};
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.card-divider {
|
||||
height: 1px;
|
||||
background-color: ${(props) => props.theme.border.border1};
|
||||
background-color: ${(props) => props.theme.table.border};
|
||||
}
|
||||
|
||||
.env-section {
|
||||
@@ -59,7 +83,7 @@ const StyledWrapper = styled.div`
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.env-section-heading {
|
||||
@@ -70,18 +94,18 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
|
||||
.env-section-count {
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.colors.text.subtext2};
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.env-section-title {
|
||||
margin: 0;
|
||||
font-size: ${(props) => props.theme.font.size.xs};
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
color: ${(props) => props.theme.colors.text.subtext2};
|
||||
}
|
||||
|
||||
.env-select-all {
|
||||
@@ -94,7 +118,7 @@ const StyledWrapper = styled.div`
|
||||
|
||||
.env-select-all-label {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
color: ${(props) => props.theme.colors.text.subtext2};
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
@@ -105,12 +129,12 @@ const StyledWrapper = styled.div`
|
||||
gap: 0.5rem;
|
||||
/* Fixed row height — MUST match ENV_ROW_HEIGHT (Virtuoso fixedItemHeight)
|
||||
in EnvironmentSelectionList. The inter-row spacing is baked in here. */
|
||||
height: 34px;
|
||||
height: 28px;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
|
||||
.env-name {
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
color: ${(props) => props.theme.text};
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import { brunoToOpenCollection } from '@usebruno/converters';
|
||||
import { sanitizeName } from 'utils/common/regex';
|
||||
import { escapeHtml } from 'utils/response';
|
||||
|
||||
const CDN_BASE_URL = 'https://cdn.opencollection.com';
|
||||
const CDN_BASE_URL = 'https://cdn.usebruno.com';
|
||||
|
||||
const FEATURES = [
|
||||
'Standalone HTML file - no server required',
|
||||
@@ -36,8 +36,8 @@ const buildHtmlDocument = (collectionName, escapedYamlContent) => `<!DOCTYPE htm
|
||||
body { margin: 0; padding: 0; }
|
||||
#opencollection-container { width: 100vw; height: 100vh; }
|
||||
</style>
|
||||
<link rel="stylesheet" href="${CDN_BASE_URL}/docs.css">
|
||||
<script src="${CDN_BASE_URL}/docs.js"></script>
|
||||
<link rel="stylesheet" href="${CDN_BASE_URL}/docs/index.css">
|
||||
<script src="${CDN_BASE_URL}/docs/index.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="opencollection-container"></div>
|
||||
@@ -189,7 +189,7 @@ const GenerateDocumentation = ({ onClose, collectionUid }) => {
|
||||
handleCancel={onClose}
|
||||
confirmDisabled={isLoading}
|
||||
>
|
||||
<StyledWrapper className="w-[500px]">
|
||||
<StyledWrapper>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center gap-3 py-8">
|
||||
<IconLoader2 size={20} className="animate-spin" />
|
||||
@@ -215,7 +215,7 @@ const GenerateDocumentation = ({ onClose, collectionUid }) => {
|
||||
</ul>
|
||||
|
||||
<div className="config-card mb-4">
|
||||
<CollectionVersionInfo version={currentVersion} folderCount={folderCount} requestCount={requestCount} />
|
||||
<CollectionVersionInfo name={collection.name} version={currentVersion} folderCount={folderCount} requestCount={requestCount} environmentCount={environments.length} />
|
||||
{environments.length > 0 && (
|
||||
<Fragment>
|
||||
<div className="card-divider" />
|
||||
|
||||
@@ -21,7 +21,8 @@ import {
|
||||
IconTerminal2,
|
||||
IconFolder,
|
||||
IconBook,
|
||||
IconFileArrowRight
|
||||
IconFileArrowRight,
|
||||
IconAppWindow
|
||||
} from '@tabler/icons';
|
||||
import OpenAPISyncIcon from 'components/Icons/OpenAPISync';
|
||||
import { toggleCollection, collapseFullCollection } from 'providers/ReduxStore/slices/collections';
|
||||
@@ -32,6 +33,7 @@ import { setFocusedSidebarPath } from 'providers/ReduxStore/slices/app';
|
||||
import toast from 'react-hot-toast';
|
||||
import NewRequest from 'components/Sidebar/NewRequest';
|
||||
import NewFolder from 'components/Sidebar/NewFolder';
|
||||
import NewApp from 'components/Sidebar/NewApp';
|
||||
import CollectionItem from './CollectionItem';
|
||||
import RemoveCollection from './RemoveCollection';
|
||||
import MoveToWorkspace from './MoveToWorkspace';
|
||||
@@ -64,6 +66,7 @@ const Collection = ({ collection, searchText }) => {
|
||||
const { dropdownContainerRef } = useSidebarAccordion();
|
||||
const [showNewFolderModal, setShowNewFolderModal] = useState(false);
|
||||
const [showNewRequestModal, setShowNewRequestModal] = useState(false);
|
||||
const [showNewAppModal, setShowNewAppModal] = useState(false);
|
||||
const [showRenameCollectionModal, setShowRenameCollectionModal] = useState(false);
|
||||
const [showCloneCollectionModalOpen, setShowCloneCollectionModalOpen] = useState(false);
|
||||
const [showShareCollectionModal, setShowShareCollectionModal] = useState(false);
|
||||
@@ -78,7 +81,7 @@ const Collection = ({ collection, searchText }) => {
|
||||
const collectionRef = useRef(null);
|
||||
// Only count persisted requests and folders; transients and file items
|
||||
// (bruno.json, .js scripts) don't affect empty state
|
||||
const itemCount = collection.items?.filter((i) => !i.isTransient && (isItemARequest(i) || isItemAFolder(i))).length || 0;
|
||||
const itemCount = collection.items?.filter((i) => !i.isTransient && (isItemARequest(i) || isItemAFolder(i) || i.type === 'app')).length || 0;
|
||||
|
||||
const isCollectionFocused = useSelector(isTabForItemActive({ itemUid: collection.uid }));
|
||||
const { hasCopiedItems } = useSelector((state) => state.app.clipboard);
|
||||
@@ -228,6 +231,11 @@ const Collection = ({ collection, searchText }) => {
|
||||
return false;
|
||||
}, { enabled: isKeyboardFocused, deps: [isKeyboardFocused] });
|
||||
|
||||
useKeybinding('newRequest', () => {
|
||||
setShowNewRequestModal(true);
|
||||
return false;
|
||||
}, { enabled: isKeyboardFocused, deps: [isKeyboardFocused] });
|
||||
|
||||
const handleFocus = () => {
|
||||
setIsKeyboardFocused(true);
|
||||
dispatch(setFocusedSidebarPath(collection.pathname));
|
||||
@@ -329,7 +337,11 @@ const Collection = ({ collection, searchText }) => {
|
||||
return items.sort((a, b) => a.seq - b.seq);
|
||||
};
|
||||
|
||||
const requestItems = sortItemsBySequence(filter(collection.items, (i) => isItemARequest(i) && !i.isTransient));
|
||||
// Standalone 'app' items sit alongside requests in the listing — both are
|
||||
// file leaves that share the seq-based ordering.
|
||||
const requestItems = sortItemsBySequence(
|
||||
filter(collection.items, (i) => (isItemARequest(i) || i.type === 'app') && !i.isTransient)
|
||||
);
|
||||
const folderItems = sortByNameThenSequence(filter(collection.items, (i) => isItemAFolder(i) && !i.isTransient));
|
||||
const showEmptyCollectionMessage = showEmptyState && !hasSearchText;
|
||||
|
||||
@@ -354,6 +366,15 @@ const Collection = ({ collection, searchText }) => {
|
||||
setShowNewFolderModal(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'new-app',
|
||||
leftSection: IconAppWindow,
|
||||
label: 'New App',
|
||||
onClick: () => {
|
||||
ensureCollectionIsMounted();
|
||||
setShowNewAppModal(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'run',
|
||||
leftSection: IconPlayerPlay,
|
||||
@@ -472,6 +493,7 @@ const Collection = ({ collection, searchText }) => {
|
||||
<StyledWrapper className="flex flex-col" id={`collection-${collection.name.replace(/\s+/g, '-').toLowerCase()}`}>
|
||||
{showNewRequestModal && <NewRequest collectionUid={collection.uid} onClose={() => setShowNewRequestModal(false)} />}
|
||||
{showNewFolderModal && <NewFolder collectionUid={collection.uid} onClose={() => setShowNewFolderModal(false)} />}
|
||||
{showNewAppModal && <NewApp collectionUid={collection.uid} onClose={() => setShowNewAppModal(false)} />}
|
||||
{showRenameCollectionModal && (
|
||||
<RenameCollection collectionUid={collection.uid} onClose={() => setShowRenameCollectionModal(false)} />
|
||||
)}
|
||||
|
||||
87
packages/bruno-app/src/components/Sidebar/NewApp/index.js
Normal file
87
packages/bruno-app/src/components/Sidebar/NewApp/index.js
Normal file
@@ -0,0 +1,87 @@
|
||||
import React from 'react';
|
||||
import { useFormik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import toast from 'react-hot-toast';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import Modal from 'components/Modal';
|
||||
import { newApp } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
|
||||
|
||||
const NewApp = ({ collectionUid, item, onClose }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const collection = useSelector((state) =>
|
||||
state.collections.collections?.find((c) => c.uid === collectionUid)
|
||||
);
|
||||
|
||||
const formik = useFormik({
|
||||
enableReinitialize: true,
|
||||
initialValues: { appName: '' },
|
||||
validationSchema: Yup.object({
|
||||
appName: Yup.string()
|
||||
.trim()
|
||||
.min(1, 'App name is required')
|
||||
.max(255, 'Must be 255 characters or less')
|
||||
.test('valid-name', validateNameError, (value) => validateName(value || ''))
|
||||
.required('App name is required')
|
||||
}),
|
||||
onSubmit: (values) => {
|
||||
const name = values.appName.trim();
|
||||
dispatch(
|
||||
newApp({
|
||||
appName: name,
|
||||
filename: sanitizeName(name),
|
||||
collectionUid,
|
||||
itemUid: item ? item.uid : null
|
||||
})
|
||||
)
|
||||
.then(() => {
|
||||
toast.success('App created');
|
||||
onClose();
|
||||
})
|
||||
.catch((err) => toast.error(err?.message || 'Failed to create app'));
|
||||
}
|
||||
});
|
||||
|
||||
const onSubmit = () => formik.handleSubmit();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
size="sm"
|
||||
title="New App"
|
||||
confirmText="Create"
|
||||
handleConfirm={onSubmit}
|
||||
handleCancel={onClose}
|
||||
disableEscapeKey={false}
|
||||
disableCloseOnOutsideClick={false}
|
||||
>
|
||||
<form className="bruno-form" onSubmit={formik.handleSubmit} data-testid="new-app-form">
|
||||
<label htmlFor="appName" className="block font-semibold">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="appName"
|
||||
type="text"
|
||||
name="appName"
|
||||
data-testid="new-app-name-input"
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
spellCheck="false"
|
||||
className="block textbox mt-2 w-full"
|
||||
value={formik.values.appName}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
/>
|
||||
{formik.touched.appName && formik.errors.appName ? (
|
||||
<div className="text-red-500 text-xs mt-1">{formik.errors.appName}</div>
|
||||
) : (
|
||||
<div className="text-xs mt-2 opacity-70">
|
||||
Creates a standalone app file in {item ? 'this folder' : `collection "${collection?.name || ''}"`}.
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewApp;
|
||||
@@ -302,7 +302,7 @@ class SingleLineEditor extends Component {
|
||||
*/
|
||||
secretEye = (isSecret) => {
|
||||
return isSecret === true ? (
|
||||
<button type="button" className="mx-2" onClick={() => this.toggleVisibleSecret()}>
|
||||
<button type="button" className="mx-2" data-testid="secret-reveal-toggle" onClick={() => this.toggleVisibleSecret()}>
|
||||
{this.state.maskInput === true ? (
|
||||
<IconEyeOff size={18} strokeWidth={2} />
|
||||
) : (
|
||||
|
||||
@@ -52,7 +52,7 @@ export const Label = styled.label`
|
||||
height: 100%;
|
||||
background-color: ${(props) => props.theme.colors.text.muted};
|
||||
border-radius: 24px;
|
||||
transition: transform 0.2s;
|
||||
transition: transform 0.1s ease-out, background-color 0.1s ease-out;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -63,7 +63,7 @@ export const Inner = styled.div`
|
||||
right: 2px;
|
||||
bottom: 2px;
|
||||
background-color: #fafafa;
|
||||
transition: 0.4s;
|
||||
transition: background-color 0.1s ease-out;
|
||||
border-radius: ${(props) => getSizeValues(props.size).height - 2}px;
|
||||
`;
|
||||
|
||||
@@ -74,7 +74,7 @@ export const SwitchButton = styled.div`
|
||||
left: 2px;
|
||||
bottom: 2px;
|
||||
background-color: white;
|
||||
transition: 0.4s;
|
||||
transition: transform 0.1s ease-out;
|
||||
border-radius: 50%;
|
||||
|
||||
&:before {
|
||||
@@ -85,7 +85,7 @@ export const SwitchButton = styled.div`
|
||||
background-color: white;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
transition: 0.4s;
|
||||
transition: transform 0.1s ease-out;
|
||||
border-radius: 50%;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -3,9 +3,17 @@ import { Checkbox, Inner, Label, Switch, SwitchButton } from './StyledWrapper';
|
||||
|
||||
const ToggleSwitch = ({ isOn, handleToggle, size = 'm', activeColor, ...props }) => {
|
||||
const id = useId();
|
||||
|
||||
return (
|
||||
<Switch size={size} {...props} onClick={handleToggle}>
|
||||
<Checkbox checked={isOn} id={id} type="checkbox" size={size} activeColor={activeColor} onChange={() => {}} />
|
||||
<Switch size={size} {...props}>
|
||||
<Checkbox
|
||||
checked={isOn}
|
||||
id={id}
|
||||
type="checkbox"
|
||||
size={size}
|
||||
activeColor={activeColor}
|
||||
onChange={handleToggle}
|
||||
/>
|
||||
<Label htmlFor={id}>
|
||||
<Inner size={size} />
|
||||
<SwitchButton size={size} />
|
||||
|
||||
@@ -21,13 +21,13 @@ const DeleteEnvironment = ({ onClose, environment }) => {
|
||||
<Portal>
|
||||
<StyledWrapper>
|
||||
<Modal
|
||||
size="sm"
|
||||
size="md"
|
||||
title="Delete Environment"
|
||||
confirmText="Delete"
|
||||
handleConfirm={onConfirm}
|
||||
handleCancel={onClose}
|
||||
>
|
||||
Are you sure you want to delete <span className="font-semibold">{environment.name}</span> ?
|
||||
Are you sure you want to delete <span className="font-semibold">{environment.name}</span>?
|
||||
</Modal>
|
||||
</StyledWrapper>
|
||||
</Portal>
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from 'providers/ReduxStore/slices/global-environments';
|
||||
import EnvironmentVariablesTable from 'components/EnvironmentVariablesTable';
|
||||
|
||||
const EnvironmentVariables = ({ environment, setIsModified, collection, searchQuery = '' }) => {
|
||||
const EnvironmentVariables = ({ environment, setIsModified, collection, searchQuery = '', variableType = 'variables' }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { globalEnvironmentDraft } = useSelector((state) => state.globalEnvironments);
|
||||
|
||||
@@ -49,6 +49,7 @@ const EnvironmentVariables = ({ environment, setIsModified, collection, searchQu
|
||||
onDraftClear={handleDraftClear}
|
||||
setIsModified={setIsModified}
|
||||
searchQuery={searchQuery}
|
||||
variableType={variableType}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -96,6 +96,17 @@ 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;
|
||||
@@ -150,39 +161,16 @@ 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};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 20px 20px 20px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import { IconCopy, IconEdit, IconTrash, IconCheck, IconX, IconSearch } from '@tabler/icons';
|
||||
import { IconCopy, IconEdit, IconTrash, IconCheck, IconX, IconSearch, IconDeviceFloppy } from '@tabler/icons';
|
||||
import { useState, useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { renameGlobalEnvironment, updateGlobalEnvironmentColor } from 'providers/ReduxStore/slices/global-environments';
|
||||
import { updateTabState } from 'providers/ReduxStore/slices/tabs';
|
||||
import { validateName, validateNameError } from 'utils/common/regex';
|
||||
import toast from 'react-hot-toast';
|
||||
import CopyEnvironment from '../../CopyEnvironment';
|
||||
import DeleteEnvironment from '../../DeleteEnvironment';
|
||||
import EnvironmentVariables from './EnvironmentVariables';
|
||||
import ColorPicker from 'components/ColorPicker';
|
||||
import ActionIcon from 'ui/ActionIcon';
|
||||
import ResponsiveTabs from 'ui/ResponsiveTabs';
|
||||
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 globalEnvs = useSelector((state) => state?.globalEnvironments?.globalEnvironments);
|
||||
@@ -19,7 +27,11 @@ 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() === '') {
|
||||
@@ -132,6 +144,10 @@ const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuer
|
||||
dispatch(updateGlobalEnvironmentColor(environment.uid, color));
|
||||
};
|
||||
|
||||
const handleSaveAll = () => {
|
||||
window.dispatchEvent(new Event('environment-save-all'));
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
{openDeleteModal && (
|
||||
@@ -189,48 +205,66 @@ const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuer
|
||||
</div>
|
||||
{nameError && isRenaming && <div className="title-error">{nameError}</div>}
|
||||
<div className="actions">
|
||||
{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>
|
||||
<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">
|
||||
<ActionIcon label="Save" onClick={handleSaveAll} data-testid="save-all-env">
|
||||
<IconDeviceFloppy 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={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>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={handleSearchIconClick} title="Search variables">
|
||||
<IconSearch size={15} strokeWidth={1.5} />
|
||||
</button>
|
||||
)}
|
||||
<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>
|
||||
rightContentRef={rightContentRef}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="content">
|
||||
@@ -239,6 +273,7 @@ const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuer
|
||||
setIsModified={setIsModified}
|
||||
collection={collection}
|
||||
searchQuery={debouncedSearchQuery}
|
||||
variableType={activeTab}
|
||||
/>
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
|
||||
@@ -46,7 +46,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};
|
||||
@@ -80,7 +80,7 @@ const StyledWrapper = styled.div`
|
||||
&::placeholder {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
}
|
||||
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: ${(props) => props.theme.colors.accent};
|
||||
@@ -112,6 +112,7 @@ const StyledWrapper = styled.div`
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 8px;
|
||||
border-right: 1px solid ${(props) => props.theme.border.border0};
|
||||
}
|
||||
|
||||
.section-header {
|
||||
@@ -164,7 +165,7 @@ const StyledWrapper = styled.div`
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: background 0.15s ease;
|
||||
|
||||
|
||||
.environment-name {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
@@ -217,18 +218,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 +241,7 @@ const StyledWrapper = styled.div`
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
|
||||
|
||||
.environment-name-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -250,12 +251,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;
|
||||
@@ -318,7 +319,7 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.env-error {
|
||||
padding: 4px 12px;
|
||||
margin-top: 4px;
|
||||
|
||||
@@ -1,21 +1,36 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { updateTabState } from 'providers/ReduxStore/slices/tabs';
|
||||
import EnvironmentList from './EnvironmentList';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import ExportEnvironmentModal from 'components/Environments/Common/ExportEnvironmentModal';
|
||||
|
||||
const WorkspaceEnvironments = ({ workspace }) => {
|
||||
const dispatch = useDispatch();
|
||||
const [isModified, setIsModified] = useState(false);
|
||||
const [showExportModal, setShowExportModal] = useState(false);
|
||||
|
||||
const globalEnvironments = useSelector((state) => state.globalEnvironments.globalEnvironments);
|
||||
const activeGlobalEnvironmentUid = useSelector((state) => state.globalEnvironments.activeGlobalEnvironmentUid);
|
||||
|
||||
const [selectedEnvironment, setSelectedEnvironment] = useState(() => {
|
||||
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 environments = globalEnvironments || [];
|
||||
if (!environments.length) return null;
|
||||
return environments.find((env) => env.uid === activeGlobalEnvironmentUid) || environments[0];
|
||||
});
|
||||
return (
|
||||
environments.find((env) => env.uid === persistedEnvUid)
|
||||
|| environments.find((env) => env.uid === activeGlobalEnvironmentUid)
|
||||
|| environments[0]
|
||||
);
|
||||
}, [globalEnvironments, persistedEnvUid, activeGlobalEnvironmentUid]);
|
||||
|
||||
const setSelectedEnvironment = (env) => {
|
||||
if (!activeTabUid || !env?.uid) return;
|
||||
dispatch(updateTabState({ uid: activeTabUid, tabState: { envUid: env.uid } }));
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user