mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-05 02:18:32 +00:00
Compare commits
61 Commits
feat/test-
...
feat/updat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98fc043da2 | ||
|
|
acc74745de | ||
|
|
e7197d6363 | ||
|
|
b4e783d9e9 | ||
|
|
efd61bf682 | ||
|
|
876a4c4cd3 | ||
|
|
37aa720813 | ||
|
|
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 | ||
|
|
a93e1dc8bf | ||
|
|
4fffef51ba |
@@ -39,7 +39,8 @@ reviews:
|
||||
- path: 'tests/**/**.*'
|
||||
instructions: |
|
||||
Review the following e2e test code written using the Playwright test library. Ensure that:
|
||||
- Follow best practices for Playwright code and e2e automation
|
||||
- Follow the guidance in `docs/playwright-testing-guide.md` - the canonical E2E guide (structure, fixtures, best practices).
|
||||
- For anything the guide above doesn't cover, follow standard Playwright and e2e automation best practices
|
||||
- Try to reduce usage of `page.waitForTimeout();` in code unless absolutely necessary and the locator cannot be found using existing `expect()` playwright calls
|
||||
- Avoid using `page.pause()` in code
|
||||
- Use locator variables for locators
|
||||
|
||||
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
|
||||
|
||||
15
.github/workflows/tests-linux.yml
vendored
15
.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)
|
||||
@@ -49,7 +53,7 @@ jobs:
|
||||
|
||||
e2e-test:
|
||||
name: Playwright E2E Tests (Linux)
|
||||
timeout-minutes: 180
|
||||
timeout-minutes: 240
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -67,10 +71,13 @@ 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 playwright Tests
|
||||
- name: Run E2E Tests
|
||||
uses: ./.github/actions/tests/run-e2e-tests
|
||||
with:
|
||||
os: ubuntu
|
||||
|
||||
6
.github/workflows/tests-macos.yml
vendored
6
.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)
|
||||
@@ -49,7 +53,7 @@ jobs:
|
||||
|
||||
e2e-test:
|
||||
name: Playwright E2E Tests (macOS)
|
||||
timeout-minutes: 180
|
||||
timeout-minutes: 240
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
6
.github/workflows/tests-windows.yml
vendored
6
.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)
|
||||
@@ -58,7 +62,7 @@ jobs:
|
||||
|
||||
e2e-test:
|
||||
name: Playwright E2E Tests (Windows)
|
||||
timeout-minutes: 180
|
||||
timeout-minutes: 240
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -35,10 +35,10 @@
|
||||
Remember, these rules are here to make our codebase harmonious. If something doesn't fit perfectly, let's chat about it. Happy coding! 🚀
|
||||
|
||||
|
||||
## Tests
|
||||
## Tests
|
||||
|
||||
- Add tests for any new functionality or meaningful changes. If code is added, removed, or significantly modified, corresponding tests should be updated or created.
|
||||
|
||||
|
||||
- Prioritise high-value tests over maximum coverage. Focus on testing behaviour that is critical, complex, or likely to break—don’t chase coverage numbers for their own sake.
|
||||
|
||||
- Write behaviour-driven tests, not implementation-driven ones. Tests should validate real expected output and observable behaviour, not internal details or mocked-out logic unless absolutely necessary.
|
||||
@@ -59,7 +59,7 @@ Remember, these rules are here to make our codebase harmonious. If something doe
|
||||
|
||||
- Tests should be fast enough to run continuously. Avoid long-running operations unless absolutely necessary; prefer lightweight fixtures and isolated units.
|
||||
|
||||
### E2E Tests
|
||||
### E2E Tests
|
||||
|
||||
When reviewing Electron-specific Playwright tests, treat `<project-root>/tests/**` as the canonical location for specs, typically matching `<project-root>/tests/**/*.spec.{ts,js}`. For broader Playwright workflow guidance, also refer to `docs/playwright-testing-guide.md`.
|
||||
|
||||
@@ -94,6 +94,8 @@ Rules:
|
||||
- Assert: verify observable behavioural outcome.
|
||||
- Cleanup: remove isolated resources.
|
||||
|
||||
6. Centralise locators and actions in page modules under `tests/utils/page/*` — never inline raw selectors in a spec. See the **Best Practices** section of `docs/playwright-testing-guide.md` for the page-module pattern and `buildCommonLocators` usage.
|
||||
|
||||
For each test file:
|
||||
- Identify behavioural vs non-behavioural tests.
|
||||
- Flag brittle selectors, hardcoded waits, shared state, serial dependencies, and fake assertions.
|
||||
@@ -101,12 +103,12 @@ For each test file:
|
||||
- Make them parallel-ready.
|
||||
- Explain briefly why each rewrite is better.
|
||||
|
||||
## UI Specific instructions
|
||||
## UI Specific instructions
|
||||
|
||||
### React
|
||||
|
||||
- Use styled component's theme prop to manage CSS colors and not CSS variables when in the context of a styled component or any react component using the styled component
|
||||
- Styled Components are used as wrappers to define both self and children components style, tailwind classes are used specifically for layout based styles.
|
||||
- Use styled component's theme prop to manage CSS colors and not CSS variables when in the context of a styled component or any react component using the styled component
|
||||
- Styled Components are used as wrappers to define both self and children components style, tailwind classes are used specifically for layout based styles.
|
||||
- Styled Component CSS might also change layout but tailwind classes shouldn't define colors.
|
||||
- MUST: Prefer custom hooks for business logic, data fetching, and side-effects.
|
||||
- MUST: Avoid `useEffect` unless absolutely needed. Prefer derived state, event handlers.
|
||||
@@ -128,3 +130,4 @@ For each test file:
|
||||
- Follow functional programming but just enough to be readable, we don't need to go as deep as ADTs and Monads, we want to keep the code pipeline obvious and easy for everyone to read and contribute to.
|
||||
- Avoid single line abstractions where all that's being done is increasing the call stack with one additional function.
|
||||
- Add in meaningful comments instead of obvious ones where complex code flow is explained properly.
|
||||
- Avoid optional chaining (`?.`) where it doesn't make sense — it hides whether a value can genuinely be null and works against TypeScript's guarantees. Only use it when the null case is handled right there (fallback, early return, or guard); otherwise fix the type or narrow first.
|
||||
|
||||
@@ -45,7 +45,7 @@ npm run test:codegen
|
||||
2. Playwright Inspector opens in a separate window
|
||||
3. You interact with the Bruno UI
|
||||
4. Actions are recorded and converted to test code
|
||||
5. The generated test file is saved in `e2e-tests/`
|
||||
5. The generated test file is saved in `tests/`
|
||||
|
||||
### Codegen Workflow
|
||||
|
||||
@@ -97,20 +97,24 @@ test('Test with temporary data', async ({ page, createTmpDir }) => {
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
e2e-tests/
|
||||
├── 001-sanity-tests/ # Basic functionality tests
|
||||
│ ├── 001-home-screen.spec.ts
|
||||
│ └── 002-create-new-collection-and-new-request.spec.ts
|
||||
├── 002-feature-tests/ # Specific feature tests
|
||||
├── 003-integration-tests/ # Complex workflow tests
|
||||
└── bruno-testbench/ # Test utilities and helpers
|
||||
tests/
|
||||
├── common/ # Basic functionality tests
|
||||
│ ├── home-screen.spec.ts
|
||||
│ └── create-new-collection.spec.ts
|
||||
├── feature-a/ # Specific feature tests
|
||||
├── utils/ # Test utilities and helpers
|
||||
│ ├── page/
|
||||
│ │ ├── locators.ts # common locators and merged sub feature locators
|
||||
│ │ ├── actions.ts # common actions
|
||||
│ │ ├── feature-a.ts # Feature A specific locator builder and actions
|
||||
│ │ └── feature-b.ts # Feature B specific locator builder and actions
|
||||
```
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
- **Files**: Use descriptive names with `.spec.ts` extension
|
||||
- **Tests**: Use clear, descriptive test names
|
||||
- **Folders**: Use numbered prefixes for ordering
|
||||
- **Folders**: Use for grouping the tests
|
||||
|
||||
### Test File Template
|
||||
|
||||
@@ -167,10 +171,10 @@ test('Test with multiple fixtures', async ({ page, createTmpDir, electronApp })
|
||||
npm run test:e2e
|
||||
|
||||
# Run specific test file
|
||||
npx playwright test e2e-tests/001-sanity-tests/001-home-screen.spec.ts
|
||||
npx playwright test tests/common/home-screen.spec.ts
|
||||
|
||||
# Run tests in a specific folder
|
||||
npx playwright test e2e-tests/001-sanity-tests/
|
||||
npx playwright test tests/folder-a/
|
||||
```
|
||||
|
||||
### Advanced Options
|
||||
@@ -192,113 +196,253 @@ npx playwright test --debug
|
||||
npx playwright test --trace on
|
||||
```
|
||||
|
||||
### CI/CD Integration
|
||||
|
||||
```bash
|
||||
# Install browsers for CI
|
||||
npx playwright install
|
||||
|
||||
# Run tests in CI mode
|
||||
npm run test:e2e
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Use Semantic Selectors
|
||||
### 1. Centralize Locators and Actions in Page Modules
|
||||
|
||||
**Never inline raw `page.locator(...)` / `page.getByTestId(...)` selectors in a spec.** Locators and the interactions that use them live in **page modules** under `tests/utils/page/*`, and specs consume them through the shared builders and exported actions. This keeps selectors single-sourced, so a UI change is fixed in one place instead of across every spec.
|
||||
|
||||
**A page module owns one section.** Each file under `tests/utils/page/*` covers a single UI section/domain and exports *both* a locator builder and the actions for that section, co-located. [`mounting.ts`](../tests/utils/page/mounting.ts) is the reference shape — `buildCollectionTreeLocators(page)` lives alongside its actions (`waitForCollectionMount`, `openCollectionFromPath`, `getCollectionTreeStructure`, …) in one file.
|
||||
|
||||
**New section → new file → link into common.** Create a new page module for a new section rather than growing the inline object in `locators.ts`, then map its locator builder into `buildCommonLocators` so specs get it for free. This is **required** for every new page module.
|
||||
|
||||
A spec builds locators once and destructures the groups it needs — no per-section imports at the call site:
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '../../playwright';
|
||||
import { buildCommonLocators, createCollection, createRequest, closeAllCollections } from '../utils/page';
|
||||
|
||||
test('should rename a request via the sidebar', async ({ page, createTmpDir }) => {
|
||||
const { sidebar, actions, dropdown } = buildCommonLocators(page);
|
||||
const testDir = await createTmpDir('rename-test');
|
||||
|
||||
await createCollection(page, 'My Collection', testDir);
|
||||
await createRequest(page, 'Test Request', 'My Collection');
|
||||
|
||||
await sidebar.request('Test Request').hover();
|
||||
await actions.collectionItemActions('Test Request').click();
|
||||
await dropdown.item('Rename').click();
|
||||
|
||||
await closeAllCollections(page);
|
||||
});
|
||||
```
|
||||
|
||||
Defining a new page module (locator builder **and** its actions in one file):
|
||||
|
||||
```typescript
|
||||
// tests/utils/page/notifications.ts — one file owns the "notifications" section
|
||||
import { test, Page } from '../../../playwright';
|
||||
|
||||
export const buildNotificationLocators = (page: Page) => ({
|
||||
toast: (text: string) => page.getByTestId('notification-toast').filter({ hasText: text }),
|
||||
dismissButton: () => page.getByTestId('notification-dismiss')
|
||||
});
|
||||
|
||||
export const dismissNotification = async (page: Page, text: string) => {
|
||||
await test.step(`Dismiss notification "${text}"`, async () => {
|
||||
const notifications = buildNotificationLocators(page);
|
||||
await notifications.toast(text).waitFor({ state: 'visible' });
|
||||
await notifications.dismissButton().click();
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
Then link the builder into `buildCommonLocators` so every spec can reach it:
|
||||
|
||||
```typescript
|
||||
// tests/utils/page/locators.ts
|
||||
import { buildNotificationLocators } from './notifications';
|
||||
|
||||
export const buildCommonLocators = (page: Page) => ({
|
||||
runner: () => page.getByTestId('run-button'),
|
||||
sidebar: { /* … */ },
|
||||
notifications: buildNotificationLocators(page), // now buildCommonLocators(page).notifications
|
||||
// …
|
||||
});
|
||||
```
|
||||
|
||||
**Scope of enforcement:**
|
||||
|
||||
- **Strict for new tests** — any new spec must go through this pattern; no direct selectors, no duplicated inline queries.
|
||||
- **Lenient for existing tests** — small tweaks to an existing spec need not refactor its existing selectors. But if you rewrite or substantially modify a large portion of a spec, extract its selectors and interactions into a page module as part of the change.
|
||||
- **Don't retro-link existing modules right now** — some already-extracted modules (e.g. [`mounting.ts`](../tests/utils/page/mounting.ts)) aren't wired into `buildCommonLocators` yet; leave them until their specs are next reworked. The rules above apply going forward, not as a migration mandate.
|
||||
- **Long-term goal** — every section is a page module and every module is reachable from `buildCommonLocators`, so locators and actions stay consistent and single-sourced.
|
||||
|
||||
### 2. Use Semantic Selectors
|
||||
|
||||
Applies to the selectors written *inside* a page module (`locators.ts` or a section file), not the spec.
|
||||
|
||||
**Preferred:**
|
||||
|
||||
```typescript
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
await page.getByLabel('Collection Name').fill('test');
|
||||
await page.getByText('Success message').toBeVisible();
|
||||
page.getByTestId('collection-name');
|
||||
page.getByRole('button', { name: 'Create' });
|
||||
page.getByLabel('Collection Name');
|
||||
```
|
||||
|
||||
**Avoid:**
|
||||
|
||||
```typescript
|
||||
await page.locator('.btn-primary').click();
|
||||
await page.locator('#collection-name').fill('test');
|
||||
page.locator('.btn-primary');
|
||||
page.locator('#collection-name');
|
||||
```
|
||||
|
||||
### 2. Create Isolated Tests
|
||||
### 3. Keep `defaultPreferences` in Sync with App Preferences
|
||||
|
||||
Each test should be independent and not rely on other tests:
|
||||
E2E launches seed a fresh userData dir from the `defaultPreferences` mock in [`playwright/index.ts`](../playwright/index.ts) (merged into any `init-user-data/preferences.json`). **Whenever you add or change a key in the app's `preferences.json` schema/defaults, add a matching default to that mock** — otherwise tests run against unset preferences and diverge from real app behaviour.
|
||||
|
||||
```typescript
|
||||
test('should create collection', async ({ page, createTmpDir }) => {
|
||||
const testDir = await createTmpDir('collection-test');
|
||||
// playwright/index.ts
|
||||
const defaultPreferences = {
|
||||
preferences: {
|
||||
onboarding: {
|
||||
hasLaunchedBefore: true,
|
||||
hasSeenWelcomeModal: true,
|
||||
lastSeenVersion: version
|
||||
}
|
||||
// ← add the default for any new preferences.json key here
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
// Test creates its own data
|
||||
await page.getByLabel('Create Collection').click();
|
||||
await page.getByLabel('Name').fill('test-collection');
|
||||
await page.getByLabel('Location').fill(testDir);
|
||||
### 4. Create Isolated Tests
|
||||
|
||||
// Clean up happens automatically via createTmpDir
|
||||
Each test should be independent and not rely on other tests — its own temp dir, its own data, deterministic cleanup:
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '../../playwright';
|
||||
import { buildCommonLocators, createCollection, closeAllCollections } from '../utils/page';
|
||||
|
||||
test('should create a collection', async ({ page, createTmpDir }) => {
|
||||
const { sidebar } = buildCommonLocators(page);
|
||||
const testDir = await createTmpDir('collection-test'); // isolated per test
|
||||
|
||||
await createCollection(page, 'test-collection', testDir);
|
||||
await expect(sidebar.collection('test-collection')).toBeVisible();
|
||||
|
||||
await closeAllCollections(page); // deterministic cleanup
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Add Meaningful Assertions
|
||||
### 5. Add Meaningful Assertions
|
||||
|
||||
Always verify the expected outcomes:
|
||||
Always verify the expected outcome through locators from a page module:
|
||||
|
||||
```typescript
|
||||
test('should save request successfully', async ({ page }) => {
|
||||
import { test, expect } from '../../playwright';
|
||||
import { buildCommonLocators, createCollection, createRequest, saveRequest, closeAllCollections } from '../utils/page';
|
||||
|
||||
test('should save a request', async ({ page, createTmpDir }) => {
|
||||
const { tabs } = buildCommonLocators(page);
|
||||
const testDir = await createTmpDir('save-test');
|
||||
|
||||
// Arrange
|
||||
await page.getByLabel('Create Collection').click();
|
||||
await createCollection(page, 'Save Test', testDir);
|
||||
await createRequest(page, 'Get Ping', 'Save Test', { url: 'http://localhost:8081/ping', method: 'GET' });
|
||||
|
||||
// Act
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
await saveRequest(page);
|
||||
|
||||
// Assert
|
||||
await expect(page.getByText('Request saved successfully')).toBeVisible();
|
||||
await expect(page.getByRole('tab', { name: 'GET request' })).toBeVisible();
|
||||
// Assert — the tab is open and no longer shows the unsaved-changes indicator
|
||||
await expect(tabs.requestTab('Get Ping')).toBeVisible();
|
||||
await expect(tabs.draftIndicator()).toBeHidden();
|
||||
|
||||
await closeAllCollections(page);
|
||||
});
|
||||
```
|
||||
|
||||
### 4. Handle Async Operations
|
||||
### 6. Keep Assertions in Specs, Not Actions
|
||||
|
||||
`expect(...)` belongs in the spec, that's where the test's intent and its pass/fail criteria must be visible. Action helpers in `tests/utils/page/*` perform interactions and synchronize state; they must **not** assert the test's expectations. An action that asserts hides pass/fail logic behind a reusable helper and forces every caller to accept that one expectation.
|
||||
|
||||
**Actions synchronize with `waitFor`; specs verify with `expect`.** When an action needs the UI to reach a state before its next step (a modal to open, a spinner to clear), wait with Playwright's wait utilities like `locator.waitFor({ state })`, `page.waitForLoadState()` etc., not `expect`. `waitFor` synchronizes so the next action is reliable; `expect` decides whether the test passes and stays in the spec.
|
||||
|
||||
```typescript
|
||||
test('should wait for network requests', async ({ page }) => {
|
||||
// Wait for specific network request
|
||||
await page.waitForResponse((response) => response.url().includes('/api/endpoint'));
|
||||
// tests/utils/page/collection.ts — the action WAITS, it does not assert
|
||||
export const openRenameModal = async (page: Page, requestName: string) => {
|
||||
await test.step(`Open rename modal for "${requestName}"`, async () => {
|
||||
const { sidebar, actions, dropdown } = buildCommonLocators(page);
|
||||
await sidebar.request(requestName).hover();
|
||||
await actions.collectionItemActions(requestName).click();
|
||||
await dropdown.item('Rename').click();
|
||||
// Synchronization, not assertion — wait until the modal is ready to interact with
|
||||
await page.locator('.bruno-modal').filter({ hasText: 'Rename Request' }).waitFor({ state: 'visible' });
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
// Or wait for element to be stable
|
||||
await page.waitForSelector('[data-testid="loading"]', { state: 'hidden' });
|
||||
```typescript
|
||||
// the spec OWNS the assertion
|
||||
test('renames a request', async ({ page, createTmpDir }) => {
|
||||
const { modal } = buildCommonLocators(page);
|
||||
// … arrange: create collection + request …
|
||||
|
||||
await openRenameModal(page, 'Test Request');
|
||||
|
||||
await expect(modal.title('Rename Request')).toBeVisible(); // expect lives here, in the spec
|
||||
});
|
||||
```
|
||||
|
||||
### 5. Use Test Data Management
|
||||
### 7. Handle Async Operations
|
||||
|
||||
Prefer auto-retrying assertions and action helpers that encapsulate the wait; never wait on a raw inline selector.
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '../../playwright';
|
||||
import { buildCommonLocators, sendRequestAndWaitForResponse } from '../utils/page';
|
||||
|
||||
test('should wait for async operations', async ({ page }) => {
|
||||
const { response } = buildCommonLocators(page);
|
||||
|
||||
// Action helpers wrap "do the thing + wait for its result"
|
||||
await sendRequestAndWaitForResponse(page, 200);
|
||||
|
||||
// Auto-retrying assertions wait until the condition holds — no hardcoded sleeps
|
||||
await expect(response.statusCode()).toContainText('200');
|
||||
await expect(response.pane()).toBeVisible();
|
||||
|
||||
// page.waitForResponse targets the network (not the DOM), so it's fine to use directly.
|
||||
// Avoid page.waitForSelector('[data-testid="…"]') — wait on a locator from a page module instead.
|
||||
});
|
||||
```
|
||||
|
||||
### 8. Use Test Data Management
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '../../playwright';
|
||||
import { buildCommonLocators, openCollection } from '../utils/page';
|
||||
|
||||
test('should work with test data', async ({ page, createTmpDir }) => {
|
||||
const { sidebar } = buildCommonLocators(page);
|
||||
const testDir = await createTmpDir('test-data');
|
||||
|
||||
// Create test files
|
||||
await fs.writeFile(path.join(testDir, 'test.bru'), testContent);
|
||||
|
||||
// Use in test
|
||||
await page.getByLabel('Open Collection').click();
|
||||
await page.getByText(testDir).click();
|
||||
// Use in test — actions/locators come from page modules, not raw selectors
|
||||
await openCollection(page, 'test-data');
|
||||
await expect(sidebar.collection('test-data')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
All examples use `buildCommonLocators` for locators and the exported action helpers from `tests/utils/page` — see [Best Practices §1](#1-centralize-locators-and-actions-in-page-modules).
|
||||
|
||||
### Example 1: Basic Collection Creation
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '../../playwright';
|
||||
import { buildCommonLocators, createCollection, closeAllCollections } from '../utils/page';
|
||||
|
||||
test('should create a new collection', async ({ page, createTmpDir }) => {
|
||||
const { sidebar } = buildCommonLocators(page);
|
||||
const testDir = await createTmpDir('new-collection');
|
||||
|
||||
await page.getByLabel('Create Collection').click();
|
||||
await page.getByLabel('Name').fill('My Test Collection');
|
||||
await page.getByLabel('Location').fill(testDir);
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
await createCollection(page, 'My Test Collection', testDir);
|
||||
|
||||
await expect(page.getByText('My Test Collection')).toBeVisible();
|
||||
await expect(sidebar.collection('My Test Collection')).toBeVisible();
|
||||
await closeAllCollections(page);
|
||||
});
|
||||
```
|
||||
|
||||
@@ -306,28 +450,29 @@ test('should create a new collection', async ({ page, createTmpDir }) => {
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '../../playwright';
|
||||
import {
|
||||
buildCommonLocators,
|
||||
createCollection,
|
||||
createRequest,
|
||||
sendRequestAndWaitForResponse,
|
||||
closeAllCollections
|
||||
} from '../utils/page';
|
||||
|
||||
test('should create and execute HTTP request', async ({ page, createTmpDir }) => {
|
||||
test('should create and execute an HTTP request', async ({ page, createTmpDir }) => {
|
||||
const { response } = buildCommonLocators(page);
|
||||
const testDir = await createTmpDir('request-test');
|
||||
|
||||
// Create collection
|
||||
await page.getByLabel('Create Collection').click();
|
||||
await page.getByLabel('Name').fill('Request Test');
|
||||
await page.getByLabel('Location').fill(testDir);
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
await createCollection(page, 'Request Test', testDir);
|
||||
await createRequest(page, 'Ping', 'Request Test', {
|
||||
url: 'http://localhost:8081/ping',
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
// Create request
|
||||
await page.locator('#create-new-tab').getByRole('img').click();
|
||||
await page.getByPlaceholder('Request Name').fill('Test Request');
|
||||
await page.locator('#new-request-url .CodeMirror').click();
|
||||
await page.locator('textarea').fill('http://localhost:8081/ping');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
// Sends the active request and asserts the status code in one step
|
||||
await sendRequestAndWaitForResponse(page, 200);
|
||||
|
||||
// Execute request
|
||||
await page.getByTestId('send-arrow-icon').click();
|
||||
|
||||
// Verify response
|
||||
await expect(page.getByRole('main')).toContainText('200 OK');
|
||||
await expect(response.statusCode()).toContainText('200');
|
||||
await closeAllCollections(page);
|
||||
});
|
||||
```
|
||||
|
||||
@@ -335,29 +480,25 @@ test('should create and execute HTTP request', async ({ page, createTmpDir }) =>
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '../../playwright';
|
||||
import {
|
||||
buildCommonLocators,
|
||||
createCollection,
|
||||
createEnvironment,
|
||||
addEnvironmentVariable,
|
||||
closeAllCollections
|
||||
} from '../utils/page';
|
||||
|
||||
test('should create and use environment variables', async ({ page, createTmpDir }) => {
|
||||
const { environment } = buildCommonLocators(page);
|
||||
const testDir = await createTmpDir('env-test');
|
||||
|
||||
// Setup collection
|
||||
await page.getByLabel('Create Collection').click();
|
||||
await page.getByLabel('Name').fill('Environment Test');
|
||||
await page.getByLabel('Location').fill(testDir);
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
await createCollection(page, 'Environment Test', testDir);
|
||||
|
||||
// Create environment
|
||||
await page.getByRole('button', { name: 'Environments' }).click();
|
||||
await page.getByRole('button', { name: 'Add Environment' }).click();
|
||||
await page.getByLabel('Environment Name').fill('Development');
|
||||
await page.getByRole('button', { name: 'Create' }).click();
|
||||
await createEnvironment(page, 'Development');
|
||||
await addEnvironmentVariable(page, { name: 'API_URL', value: 'http://localhost:3000' });
|
||||
|
||||
// Add variable
|
||||
await page.getByRole('button', { name: 'Add Variable' }).click();
|
||||
await page.getByLabel('Variable Name').fill('API_URL');
|
||||
await page.getByLabel('Variable Value').fill('http://localhost:3000');
|
||||
await page.getByRole('button', { name: 'Save' }).click();
|
||||
|
||||
await expect(page.getByText('API_URL')).toBeVisible();
|
||||
await expect(environment.varRow('API_URL')).toBeVisible();
|
||||
await closeAllCollections(page);
|
||||
});
|
||||
```
|
||||
|
||||
@@ -377,10 +518,14 @@ test('should create and use environment variables', async ({ page, createTmpDir
|
||||
|
||||
2. **Tests Timing Out**
|
||||
|
||||
First find out *why* it's slow - a timeout is usually a symptom, not the problem. Replace hardcoded waits with auto-retrying assertions, wait on the app-ready signal (`[data-app-state="loaded"]`), and use action helpers that wait for their own result. Bumping the timeout hides the real issue and slows the suite for everyone.
|
||||
|
||||
Only raise the timeout when the test genuinely does long-running work (large import, a slow real endpoint) - and scope it to that test, not globally:
|
||||
|
||||
```typescript
|
||||
// Increase timeout for specific test
|
||||
test('slow test', async ({ page }) => {
|
||||
test.setTimeout(60000); // 60 seconds
|
||||
// Justified: this test imports a very large collection that legitimately takes ~40s.
|
||||
test('imports a large collection', async ({ page }) => {
|
||||
test.setTimeout(60000); // 60s — the work is genuinely long, not a masked flake
|
||||
// Test steps
|
||||
});
|
||||
```
|
||||
@@ -412,7 +557,7 @@ test('should create and use environment variables', async ({ page, createTmpDir
|
||||
npx playwright test --debug
|
||||
|
||||
# Run specific test in debug mode
|
||||
npx playwright test --debug e2e-tests/001-sanity-tests/001-home-screen.spec.ts
|
||||
npx playwright test --debug tests/common/home-screen.spec.ts
|
||||
```
|
||||
|
||||
### Trace Analysis
|
||||
@@ -431,11 +576,11 @@ The Playwright configuration is in `playwright.config.ts`:
|
||||
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
testDir: './e2e-tests',
|
||||
fullyParallel: false,
|
||||
testDir: './tests',
|
||||
fullyParallel: tue,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
workers: process.env.CI ? undefined : 1,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: undefined,
|
||||
|
||||
projects: [
|
||||
{
|
||||
|
||||
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;
|
||||
@@ -2,6 +2,7 @@ import React, { useMemo, useCallback } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { IconCaretDown } from '@tabler/icons';
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
import StatusBadge from 'ui/StatusBadge/index';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { updateCollectionAuthMode } from 'providers/ReduxStore/slices/collections';
|
||||
import { humanizeRequestAuthMode } from 'utils/collections';
|
||||
@@ -66,6 +67,17 @@ const AuthMode = ({ collection }) => {
|
||||
label: 'API Key',
|
||||
onClick: () => onModeChange('apikey')
|
||||
},
|
||||
{
|
||||
id: 'akamai-edgegrid',
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
Akamai EdgeGrid
|
||||
<StatusBadge status="info" size="xs">Beta</StatusBadge>
|
||||
</span>
|
||||
),
|
||||
ariaLabel: 'Akamai EdgeGrid (Beta)',
|
||||
onClick: () => onModeChange('akamai-edgegrid')
|
||||
},
|
||||
{
|
||||
id: 'none',
|
||||
label: 'No Auth',
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import styled from 'styled-components';
|
||||
import { rgba } from 'polished';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
font-weight: 500;
|
||||
color: ${(props) => props.theme.colors.text.subtext1};
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.single-line-editor-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
max-width: 400px;
|
||||
margin-bottom: 0.5rem;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
border: solid 1px ${(props) => props.theme.input.border};
|
||||
background-color: ${(props) => props.theme.input.bg};
|
||||
|
||||
&:focus-within {
|
||||
border: solid 1px ${(props) => props.theme.input.focusBorder} !important;
|
||||
}
|
||||
}
|
||||
|
||||
.advanced-settings-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: fit-content;
|
||||
margin: 1rem 0 0.75rem;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.colors.text.subtext1};
|
||||
user-select: none;
|
||||
|
||||
.advanced-settings-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-radius: 0.375rem;
|
||||
background-color: ${(props) => rgba(props.theme.primary.solid, 0.1)};
|
||||
|
||||
svg {
|
||||
color: ${(props) => props.theme.primary.text};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.advanced-settings-hint {
|
||||
margin: -0.25rem 0 0.75rem;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: rgb(107 114 128);
|
||||
}
|
||||
|
||||
.field-info {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 6px;
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
color: rgb(107 114 128);
|
||||
}
|
||||
|
||||
.field-tooltip {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 100%;
|
||||
z-index: 10;
|
||||
width: max-content;
|
||||
max-width: 15rem;
|
||||
margin-bottom: 0.25rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
background-color: #374151;
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
font-weight: 400;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
&:hover .field-tooltip {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,165 @@
|
||||
import { IconAdjustmentsHorizontal, IconInfoCircle } from '@tabler/icons';
|
||||
import get from 'lodash/get';
|
||||
import React from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
|
||||
import { updateCollectionAuth } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveCollectionRoot } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
interface AkamaiEdgeGridAuthValues {
|
||||
accessToken?: string;
|
||||
clientToken?: string;
|
||||
clientSecret?: string;
|
||||
nonce?: string;
|
||||
timestamp?: string;
|
||||
baseURL?: string;
|
||||
headersToSign?: string;
|
||||
maxBodySize?: number | null;
|
||||
}
|
||||
|
||||
type EdgeGridField = keyof AkamaiEdgeGridAuthValues;
|
||||
|
||||
const toMaxBodySize = (value: string): number | null => {
|
||||
if (value === '' || value == null) return null;
|
||||
const num = Number(value);
|
||||
return Number.isNaN(num) ? null : num;
|
||||
};
|
||||
|
||||
interface AkamaiEdgeGridAuthProps {
|
||||
collection: any;
|
||||
}
|
||||
|
||||
const FIELDS: Array<{ key: EdgeGridField; label: string; tooltip?: string; isSecret?: boolean }> = [
|
||||
{ key: 'accessToken', label: 'Access Token' },
|
||||
{ key: 'clientToken', label: 'Client Token' },
|
||||
{ key: 'clientSecret', label: 'Client Secret', isSecret: true },
|
||||
{ key: 'baseURL', label: 'Base URL', tooltip: 'Defaults to the request URL if not specified.' },
|
||||
{
|
||||
key: 'nonce',
|
||||
label: 'Nonce',
|
||||
tooltip: 'A unique nonce is required per request. Defaults to an auto-generated UUID v4 if not provided.'
|
||||
},
|
||||
{
|
||||
key: 'timestamp',
|
||||
label: 'Timestamp',
|
||||
tooltip:
|
||||
'UTC timestamp of when the request is signed (yyyyMMddTHH:mm:ss+0000). Defaults to current time if not provided.'
|
||||
},
|
||||
{
|
||||
key: 'headersToSign',
|
||||
label: 'Headers to Sign',
|
||||
tooltip: 'Comma-separated list of headers to include in the signature.'
|
||||
},
|
||||
{
|
||||
key: 'maxBodySize',
|
||||
label: 'Max Body Size',
|
||||
tooltip: 'Maximum message body size to include in the signature, in bytes. Defaults to 131072.'
|
||||
}
|
||||
];
|
||||
|
||||
type EdgeGridFieldConfig = (typeof FIELDS)[number];
|
||||
|
||||
// Fields shown up front vs. those grouped under the "Advanced Settings" section
|
||||
const BASIC_FIELDS = FIELDS.slice(0, 3);
|
||||
const ADVANCED_FIELDS = FIELDS.slice(3);
|
||||
|
||||
const EdgeGridAuth: React.FC<AkamaiEdgeGridAuthProps> = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
|
||||
const edgeGridAuth: AkamaiEdgeGridAuthValues =
|
||||
(collection.draft?.root
|
||||
? get(collection, 'draft.root.request.auth.akamaiEdgegrid')
|
||||
: get(collection, 'root.request.auth.akamaiEdgegrid')) || {};
|
||||
const { isSensitive } = useDetectSensitiveField(collection);
|
||||
const { showWarning: showClientSecretWarning, warningMessage: clientSecretWarningMessage } = isSensitive(
|
||||
edgeGridAuth?.clientSecret
|
||||
);
|
||||
|
||||
const handleSave = () => dispatch(saveCollectionRoot(collection.uid));
|
||||
|
||||
const handleFieldChange = (field: EdgeGridField, value: string) => {
|
||||
const content: AkamaiEdgeGridAuthValues = {
|
||||
accessToken: edgeGridAuth.accessToken || '',
|
||||
clientToken: edgeGridAuth.clientToken || '',
|
||||
clientSecret: edgeGridAuth.clientSecret || '',
|
||||
nonce: edgeGridAuth.nonce || '',
|
||||
timestamp: edgeGridAuth.timestamp || '',
|
||||
baseURL: edgeGridAuth.baseURL || '',
|
||||
headersToSign: edgeGridAuth.headersToSign || '',
|
||||
maxBodySize: edgeGridAuth.maxBodySize ?? null
|
||||
};
|
||||
|
||||
if (field === 'maxBodySize') {
|
||||
content.maxBodySize = toMaxBodySize(value);
|
||||
} else {
|
||||
(content as Record<string, unknown>)[field] = value || '';
|
||||
}
|
||||
|
||||
dispatch(
|
||||
updateCollectionAuth({
|
||||
mode: 'akamai-edgegrid',
|
||||
collectionUid: collection.uid,
|
||||
content
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const renderField = ({ key, label, tooltip, isSecret }: EdgeGridFieldConfig) => {
|
||||
const showWarning = isSecret && showClientSecretWarning;
|
||||
const rawValue = edgeGridAuth[key];
|
||||
const fieldValue = rawValue === null || rawValue === undefined ? '' : String(rawValue);
|
||||
return (
|
||||
<div key={key}>
|
||||
<label>
|
||||
{label}
|
||||
{tooltip && (
|
||||
<span className="field-info">
|
||||
<IconInfoCircle size={16} />
|
||||
<span className="field-tooltip">{tooltip}</span>
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<div className="single-line-editor-wrapper">
|
||||
<SingleLineEditor
|
||||
value={fieldValue}
|
||||
theme={storedTheme}
|
||||
onSave={handleSave}
|
||||
onChange={(val: string) => handleFieldChange(key, val)}
|
||||
collection={collection}
|
||||
isSecret={isSecret}
|
||||
isCompact
|
||||
/>
|
||||
{showWarning && (
|
||||
<SensitiveFieldWarning fieldName="edgegrid-client-secret" warningMessage={clientSecretWarningMessage} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="mt-2 w-full">
|
||||
{BASIC_FIELDS.map(renderField)}
|
||||
|
||||
<div className="advanced-settings-header">
|
||||
<span className="advanced-settings-icon">
|
||||
<IconAdjustmentsHorizontal size={16} />
|
||||
</span>
|
||||
<span>Advanced Settings</span>
|
||||
</div>
|
||||
|
||||
<>
|
||||
{ADVANCED_FIELDS.map(renderField)}
|
||||
</>
|
||||
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default EdgeGridAuth;
|
||||
@@ -8,6 +8,7 @@ import BasicAuth from './BasicAuth';
|
||||
import DigestAuth from './DigestAuth';
|
||||
import WsseAuth from './WsseAuth';
|
||||
import ApiKeyAuth from './ApiKeyAuth/';
|
||||
import EdgeGridAuth from './EdgeGridAuth';
|
||||
import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import OAuth2 from './OAuth2';
|
||||
@@ -50,6 +51,9 @@ const Auth = ({ collection }) => {
|
||||
case 'apikey': {
|
||||
return <ApiKeyAuth collection={collection} />;
|
||||
}
|
||||
case 'akamai-edgegrid': {
|
||||
return <EdgeGridAuth collection={collection} />;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -39,6 +39,15 @@ const StyledWrapper = styled.div`
|
||||
}
|
||||
}
|
||||
|
||||
&.version {
|
||||
background-color: ${(props) => rgba(props.theme.colors.text.yellow, 0.1)};
|
||||
border: 1px solid ${(props) => rgba(props.theme.colors.text.yellow, 0.1)};
|
||||
|
||||
svg {
|
||||
color: ${(props) => props.theme.colors.text.yellow};
|
||||
}
|
||||
}
|
||||
|
||||
&.generate-docs {
|
||||
background-color: ${(props) => rgba(props.theme.accents.primary, 0.08)};
|
||||
border: 1px solid ${(props) => rgba(props.theme.accents.primary, 0.09)};
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import React from 'react';
|
||||
import { getTotalRequestCountInCollection } from 'utils/collections/';
|
||||
import { IconFolder, IconWorld, IconApi, IconShare, IconBook } from '@tabler/icons';
|
||||
import { areItemsLoading, getItemsLoadStats } from 'utils/collections/index';
|
||||
import { IconFolder, IconWorld, IconApi, IconShare, IconBook, IconTag } from '@tabler/icons';
|
||||
import { areItemsLoading, getItemsLoadStats, getCollectionVersion } from 'utils/collections/index';
|
||||
import { useState } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import ShareCollection from 'components/ShareCollection/index';
|
||||
import GenerateDocumentation from 'components/Sidebar/Collections/Collection/GenerateDocumentation';
|
||||
import ChangeCollectionVersion from 'components/Sidebar/Collections/Collection/ChangeCollectionVersion';
|
||||
import { addTab } from 'providers/ReduxStore/slices/tabs';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import Migration from '../Migration';
|
||||
|
||||
const Info = ({ collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -17,6 +19,9 @@ const Info = ({ collection }) => {
|
||||
const { loading: itemsLoadingCount, total: totalItems } = getItemsLoadStats(collection);
|
||||
const [showShareCollectionModal, toggleShowShareCollectionModal] = useState(false);
|
||||
const [showGenerateDocumentationModal, setShowGenerateDocumentationModal] = useState(false);
|
||||
const [showChangeVersionModal, setShowChangeVersionModal] = useState(false);
|
||||
|
||||
const collectionVersion = getCollectionVersion(collection);
|
||||
|
||||
const globalEnvironments = useSelector((state) => state.globalEnvironments.globalEnvironments);
|
||||
|
||||
@@ -44,6 +49,22 @@ const Info = ({ collection }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start group cursor-pointer" onClick={() => setShowChangeVersionModal(true)} data-testid="info-version-row">
|
||||
<div className="icon-box version flex-shrink-0 p-3 rounded-lg">
|
||||
<IconTag className="w-5 h-5" stroke={1.5} />
|
||||
</div>
|
||||
<div className="ml-4 h-full flex flex-col justify-start">
|
||||
<div className="font-medium h-fit my-auto">Version</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{collectionVersion
|
||||
? <span className="text-muted" data-testid="info-version-value">{collectionVersion}</span>
|
||||
: <span className="text-muted italic" data-testid="info-version-value">Not Set</span>}
|
||||
<span className="group-hover:underline text-link" data-testid="info-version-change">change</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{showChangeVersionModal && <ChangeCollectionVersion collectionUid={collection.uid} onClose={() => setShowChangeVersionModal(false)} />}
|
||||
|
||||
{/* Environments Row */}
|
||||
<div className="flex items-start">
|
||||
<div className="icon-box environments flex-shrink-0 p-3 rounded-lg">
|
||||
@@ -126,6 +147,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="Save All" onClick={handleSaveAll} data-testid="save-all-env">
|
||||
<IconDeviceFloppy size={15} strokeWidth={1.5} />
|
||||
</ActionIcon>
|
||||
<ActionIcon label="Rename" onClick={handleRenameClick} data-testid="env-rename-action">
|
||||
<IconEdit size={15} strokeWidth={1.5} />
|
||||
</ActionIcon>
|
||||
<ActionIcon label="Copy" onClick={() => setOpenCopyModal(true)} data-testid="env-copy-action">
|
||||
<IconCopy size={15} strokeWidth={1.5} />
|
||||
</ActionIcon>
|
||||
<ActionIcon label="Delete" onClick={() => setOpenDeleteModal(true)} colorOnHover="danger" data-testid="env-delete-action">
|
||||
<IconTrash size={15} strokeWidth={1.5} />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tabs-container">
|
||||
<ResponsiveTabs
|
||||
tabs={TABS}
|
||||
activeTab={activeTab}
|
||||
onTabSelect={setActiveTab}
|
||||
rightContent={(
|
||||
<div ref={rightContentRef} className="env-search-container">
|
||||
{isSearchExpanded ? (
|
||||
<div className="search-input-wrapper">
|
||||
<IconSearch size={14} strokeWidth={1.5} className="search-icon" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
placeholder={activeTab === 'secrets' ? 'Search secrets...' : 'Search variables...'}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onBlur={handleSearchBlur}
|
||||
className="search-input"
|
||||
data-testid="env-search-input"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
className="clear-search"
|
||||
onClick={handleClearSearch}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
title="Clear search"
|
||||
data-testid="env-clear-search"
|
||||
>
|
||||
<IconX size={14} strokeWidth={1.5} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ActionIcon label="Search" onClick={handleSearchIconClick} data-testid="env-search-action">
|
||||
<IconSearch size={15} strokeWidth={1.5} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</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,6 +4,8 @@ const StyledWrapper = styled.div`
|
||||
color: ${(props) => props.theme.colors.danger};
|
||||
pre {
|
||||
color: ${(props) => props.theme.colors.danger};
|
||||
max-height: 60vh;
|
||||
overflow: auto;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import OAuth2AuthorizationCode from 'components/RequestPane/Auth/OAuth2/AuthorizationCode/index';
|
||||
import { updateFolderAuth as _updateFolderAuth } from 'providers/ReduxStore/slices/collections';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import OAuth2PasswordCredentials from 'components/RequestPane/Auth/OAuth2/PasswordCredentials/index';
|
||||
import OAuth2ClientCredentials from 'components/RequestPane/Auth/OAuth2/ClientCredentials/index';
|
||||
import OAuth2Implicit from 'components/RequestPane/Auth/OAuth2/Implicit/index';
|
||||
import GrantTypeSelector from 'components/RequestPane/Auth/OAuth2/GrantTypeSelector/index';
|
||||
import AuthMode from '../AuthMode';
|
||||
import ApiKeyAuth from 'components/RequestPane/Auth/ApiKeyAuth';
|
||||
import AwsV4Auth from 'components/RequestPane/Auth/AwsV4Auth';
|
||||
import BasicAuth from 'components/RequestPane/Auth/BasicAuth';
|
||||
import BearerAuth from 'components/RequestPane/Auth/BearerAuth';
|
||||
import DigestAuth from 'components/RequestPane/Auth/DigestAuth';
|
||||
import EdgeGridAuth from 'components/RequestPane/Auth/EdgeGridAuth';
|
||||
import NTLMAuth from 'components/RequestPane/Auth/NTLMAuth';
|
||||
import OAuth1 from 'components/RequestPane/Auth/OAuth1';
|
||||
import OAuth2AuthorizationCode from 'components/RequestPane/Auth/OAuth2/AuthorizationCode/index';
|
||||
import OAuth2ClientCredentials from 'components/RequestPane/Auth/OAuth2/ClientCredentials/index';
|
||||
import GrantTypeSelector from 'components/RequestPane/Auth/OAuth2/GrantTypeSelector/index';
|
||||
import OAuth2Implicit from 'components/RequestPane/Auth/OAuth2/Implicit/index';
|
||||
import OAuth2PasswordCredentials from 'components/RequestPane/Auth/OAuth2/PasswordCredentials/index';
|
||||
import WsseAuth from 'components/RequestPane/Auth/WsseAuth';
|
||||
import ApiKeyAuth from 'components/RequestPane/Auth/ApiKeyAuth';
|
||||
import AwsV4Auth from 'components/RequestPane/Auth/AwsV4Auth';
|
||||
import { humanizeRequestAuthMode } from 'utils/collections/index';
|
||||
import get from 'lodash/get';
|
||||
import { updateFolderAuth as _updateFolderAuth } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import Button from 'ui/Button';
|
||||
import { getEffectiveAuthSource } from 'utils/auth';
|
||||
import { humanizeRequestAuthMode } from 'utils/collections/index';
|
||||
import AuthMode from '../AuthMode';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const GrantTypeComponentMap = ({ collection, folder, updateFolderAuth }) => {
|
||||
const dispatch = useDispatch();
|
||||
@@ -182,6 +183,19 @@ const Auth = ({ collection, folder }) => {
|
||||
</>
|
||||
);
|
||||
}
|
||||
case 'akamai-edgegrid': {
|
||||
return (
|
||||
<>
|
||||
<EdgeGridAuth
|
||||
collection={collection}
|
||||
item={folder}
|
||||
updateAuth={updateFolderAuth}
|
||||
request={request}
|
||||
save={() => handleSave()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
case 'none': {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useMemo, useCallback } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { IconCaretDown } from '@tabler/icons';
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
import StatusBadge from 'ui/StatusBadge/index';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { updateFolderAuthMode } from 'providers/ReduxStore/slices/collections';
|
||||
import { humanizeRequestAuthMode } from 'utils/collections';
|
||||
@@ -67,6 +68,17 @@ const AuthMode = ({ collection, folder }) => {
|
||||
label: 'API Key',
|
||||
onClick: () => onModeChange('apikey')
|
||||
},
|
||||
{
|
||||
id: 'akamai-edgegrid',
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
Akamai EdgeGrid
|
||||
<StatusBadge status="info" size="xs">Beta</StatusBadge>
|
||||
</span>
|
||||
),
|
||||
ariaLabel: 'Akamai EdgeGrid (Beta)',
|
||||
onClick: () => onModeChange('akamai-edgegrid')
|
||||
},
|
||||
{
|
||||
id: 'inherit',
|
||||
label: 'Inherit',
|
||||
|
||||
@@ -4,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;
|
||||
@@ -2,6 +2,7 @@ import React, { useMemo, useCallback } from 'react';
|
||||
import get from 'lodash/get';
|
||||
import { IconCaretDown } from '@tabler/icons';
|
||||
import MenuDropdown from 'ui/MenuDropdown';
|
||||
import StatusBadge from 'ui/StatusBadge/index';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { updateRequestAuthMode } from 'providers/ReduxStore/slices/collections';
|
||||
import { humanizeRequestAuthMode } from 'utils/collections';
|
||||
@@ -67,6 +68,17 @@ const AuthMode = ({ item, collection }) => {
|
||||
label: 'API Key',
|
||||
onClick: () => onModeChange('apikey')
|
||||
},
|
||||
{
|
||||
id: 'akamai-edgegrid',
|
||||
label: (
|
||||
<span className="flex items-center gap-2">
|
||||
Akamai EdgeGrid
|
||||
<StatusBadge status="info" size="xs">Beta</StatusBadge>
|
||||
</span>
|
||||
),
|
||||
ariaLabel: 'Akamai EdgeGrid (Beta)',
|
||||
onClick: () => onModeChange('akamai-edgegrid')
|
||||
},
|
||||
{
|
||||
id: 'inherit',
|
||||
label: 'Inherit',
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import styled from 'styled-components';
|
||||
import { rgba } from 'polished';
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
font-weight: 500;
|
||||
color: ${(props) => props.theme.colors.text.subtext1};
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.single-line-editor-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
max-width: 400px;
|
||||
margin-bottom: 0.5rem;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
border: solid 1px ${(props) => props.theme.input.border};
|
||||
background-color: ${(props) => props.theme.input.bg};
|
||||
|
||||
&:focus-within {
|
||||
border: solid 1px ${(props) => props.theme.input.focusBorder} !important;
|
||||
}
|
||||
}
|
||||
|
||||
.advanced-settings-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: fit-content;
|
||||
margin: 1rem 0 0.75rem;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
font-weight: 600;
|
||||
color: ${(props) => props.theme.colors.text.subtext1};
|
||||
user-select: none;
|
||||
|
||||
.advanced-settings-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-radius: 0.375rem;
|
||||
background-color: ${(props) => rgba(props.theme.primary.solid, 0.1)};
|
||||
|
||||
svg {
|
||||
color: ${(props) => props.theme.primary.text};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.advanced-settings-hint {
|
||||
margin: -0.25rem 0 0.75rem;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
color: rgb(107 114 128);
|
||||
}
|
||||
|
||||
.field-info {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 6px;
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
color: rgb(107 114 128);
|
||||
}
|
||||
|
||||
.field-tooltip {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 100%;
|
||||
z-index: 10;
|
||||
width: max-content;
|
||||
max-width: 15rem;
|
||||
margin-bottom: 0.25rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
background-color: #374151;
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
font-weight: 400;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
&:hover .field-tooltip {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
@@ -0,0 +1,174 @@
|
||||
import { IconAdjustmentsHorizontal, IconInfoCircle } from '@tabler/icons';
|
||||
import get from 'lodash/get';
|
||||
import React from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
|
||||
import SingleLineEditor from 'components/SingleLineEditor';
|
||||
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
|
||||
import { sendRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
interface AkamaiEdgeGridAuthValues {
|
||||
accessToken?: string;
|
||||
clientToken?: string;
|
||||
clientSecret?: string;
|
||||
nonce?: string;
|
||||
timestamp?: string;
|
||||
baseURL?: string;
|
||||
headersToSign?: string;
|
||||
maxBodySize?: number | null;
|
||||
}
|
||||
|
||||
type EdgeGridField = keyof AkamaiEdgeGridAuthValues;
|
||||
|
||||
// Coerce the Max Body Size editor string into the numeric value the model stores (empty/invalid -> null).
|
||||
const toMaxBodySize = (value: string): number | null => {
|
||||
if (value === '' || value == null) return null;
|
||||
const num = Number(value);
|
||||
return Number.isNaN(num) ? null : num;
|
||||
};
|
||||
|
||||
interface AkamaiEdgeGridAuthProps {
|
||||
item: any;
|
||||
collection: any;
|
||||
request: any;
|
||||
updateAuth: (payload: any) => any;
|
||||
save: () => void;
|
||||
}
|
||||
|
||||
const FIELDS: Array<{ key: EdgeGridField; label: string; tooltip?: string; isSecret?: boolean }> = [
|
||||
{ key: 'accessToken', label: 'Access Token' },
|
||||
{ key: 'clientToken', label: 'Client Token' },
|
||||
{ key: 'clientSecret', label: 'Client Secret', isSecret: true },
|
||||
{ key: 'baseURL', label: 'Base URL', tooltip: 'Defaults to the request URL if not specified.' },
|
||||
{
|
||||
key: 'nonce',
|
||||
label: 'Nonce',
|
||||
tooltip: 'A unique nonce is required per request. Defaults to an auto-generated UUID v4 if not provided.'
|
||||
},
|
||||
{
|
||||
key: 'timestamp',
|
||||
label: 'Timestamp',
|
||||
tooltip:
|
||||
'UTC timestamp of when the request is signed (yyyyMMddTHH:mm:ss+0000). Defaults to current time if not provided.'
|
||||
},
|
||||
{
|
||||
key: 'headersToSign',
|
||||
label: 'Headers to Sign',
|
||||
tooltip: 'Comma-separated list of headers to include in the signature.'
|
||||
},
|
||||
{
|
||||
key: 'maxBodySize',
|
||||
label: 'Max Body Size',
|
||||
tooltip: 'Maximum message body size to include in the signature, in bytes. Defaults to 131072.'
|
||||
}
|
||||
];
|
||||
|
||||
type EdgeGridFieldConfig = (typeof FIELDS)[number];
|
||||
|
||||
// Fields shown up front vs. those grouped under the "Advanced Settings" section
|
||||
const BASIC_FIELDS = FIELDS.slice(0, 3);
|
||||
const ADVANCED_FIELDS = FIELDS.slice(3);
|
||||
|
||||
const EdgeGridAuth: React.FC<AkamaiEdgeGridAuthProps> = ({ item, collection, updateAuth, request, save }) => {
|
||||
const dispatch = useDispatch();
|
||||
const { storedTheme } = useTheme();
|
||||
|
||||
const edgeGridAuth: AkamaiEdgeGridAuthValues = get(request, 'auth.akamaiEdgegrid') || {};
|
||||
const requestUrl = get(request, 'url', '');
|
||||
const { isSensitive } = useDetectSensitiveField(collection);
|
||||
const { showWarning: showClientSecretWarning, warningMessage: clientSecretWarningMessage } = isSensitive(
|
||||
edgeGridAuth?.clientSecret
|
||||
);
|
||||
|
||||
const handleRun = () => dispatch(sendRequest(item, collection.uid));
|
||||
|
||||
const handleSave = () => {
|
||||
save();
|
||||
};
|
||||
|
||||
const handleFieldChange = (field: EdgeGridField, value: string) => {
|
||||
const content: AkamaiEdgeGridAuthValues = {
|
||||
accessToken: edgeGridAuth.accessToken || '',
|
||||
clientToken: edgeGridAuth.clientToken || '',
|
||||
clientSecret: edgeGridAuth.clientSecret || '',
|
||||
nonce: edgeGridAuth.nonce || '',
|
||||
timestamp: edgeGridAuth.timestamp || '',
|
||||
baseURL: edgeGridAuth.baseURL || '',
|
||||
headersToSign: edgeGridAuth.headersToSign || '',
|
||||
maxBodySize: edgeGridAuth.maxBodySize ?? null
|
||||
};
|
||||
|
||||
if (field === 'maxBodySize') {
|
||||
content.maxBodySize = toMaxBodySize(value);
|
||||
} else {
|
||||
(content as Record<string, unknown>)[field] = value || '';
|
||||
}
|
||||
|
||||
dispatch(
|
||||
updateAuth({
|
||||
mode: 'akamai-edgegrid',
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
content
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const renderField = ({ key, label, tooltip, isSecret }: EdgeGridFieldConfig) => {
|
||||
const showWarning = isSecret && showClientSecretWarning;
|
||||
const rawValue = key === 'baseURL' ? edgeGridAuth.baseURL || requestUrl : edgeGridAuth[key];
|
||||
const fieldValue = rawValue === null || rawValue === undefined ? '' : String(rawValue);
|
||||
return (
|
||||
<div key={key}>
|
||||
<label>
|
||||
{label}
|
||||
{tooltip && (
|
||||
<span className="field-info">
|
||||
<IconInfoCircle size={16} />
|
||||
<span className="field-tooltip">{tooltip}</span>
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
<div className="single-line-editor-wrapper">
|
||||
<SingleLineEditor
|
||||
value={fieldValue}
|
||||
theme={storedTheme}
|
||||
onSave={handleSave}
|
||||
onChange={(val: string) => handleFieldChange(key, val)}
|
||||
onRun={handleRun}
|
||||
collection={collection}
|
||||
item={item}
|
||||
isSecret={isSecret}
|
||||
isCompact
|
||||
/>
|
||||
{showWarning && (
|
||||
<SensitiveFieldWarning fieldName="edgegrid-client-secret" warningMessage={clientSecretWarningMessage} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledWrapper className="mt-2 w-full">
|
||||
{BASIC_FIELDS.map(renderField)}
|
||||
|
||||
<div className="advanced-settings-header">
|
||||
<span className="advanced-settings-icon">
|
||||
<IconAdjustmentsHorizontal size={16} />
|
||||
</span>
|
||||
<span>Advanced Settings</span>
|
||||
</div>
|
||||
|
||||
<>
|
||||
{ADVANCED_FIELDS.map(renderField)}
|
||||
</>
|
||||
|
||||
</StyledWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default EdgeGridAuth;
|
||||
@@ -12,6 +12,7 @@ import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import ApiKeyAuth from './ApiKeyAuth';
|
||||
import EdgeGridAuth from './EdgeGridAuth';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
import { humanizeRequestAuthMode } from 'utils/collections';
|
||||
import OAuth2 from './OAuth2/index';
|
||||
@@ -68,6 +69,9 @@ const Auth = ({ item, collection }) => {
|
||||
case 'apikey': {
|
||||
return <ApiKeyAuth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
|
||||
}
|
||||
case 'akamai-edgegrid': {
|
||||
return <EdgeGridAuth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
|
||||
}
|
||||
case 'inherit': {
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -13,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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -85,6 +85,10 @@ export const SingleWSMessage = ({
|
||||
} else if (e.key === 'Escape') {
|
||||
setEditValue(displayName);
|
||||
setIsEditing(false);
|
||||
} else if ((e.metaKey || e.ctrlKey) && (e.key === 's' || e.key === 'S')) {
|
||||
e.preventDefault();
|
||||
saveName(editValue);
|
||||
dispatch(saveRequest(item.uid, collection.uid));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -34,7 +34,7 @@ const StyledWrapper = styled.div.attrs((props) => ({
|
||||
);
|
||||
}
|
||||
|
||||
li:hover &,
|
||||
li:hover &:not(.no-close),
|
||||
&.has-changes {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
@@ -59,6 +59,12 @@ const StyledWrapper = styled.div.attrs((props) => ({
|
||||
}
|
||||
}
|
||||
|
||||
&.no-close .close-icon-container {
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.close-icon {
|
||||
color: ${(props) => props.theme.requestTabs.icon.color};
|
||||
width: 12px;
|
||||
@@ -82,22 +88,35 @@ const StyledWrapper = styled.div.attrs((props) => ({
|
||||
}
|
||||
|
||||
&.has-changes:not(li:hover &) {
|
||||
.draft-icon-wrapper {
|
||||
.draft-icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.close-icon-wrapper {
|
||||
display: none;
|
||||
.close-icon-wrapper {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
li:hover &.has-changes {
|
||||
.draft-icon-wrapper {
|
||||
display: none;
|
||||
/* Closable tabs: hovering shows the close icon, replacing the draft icon */
|
||||
li:hover &:not(.no-close) {
|
||||
.draft-icon-wrapper {
|
||||
display: none;
|
||||
}
|
||||
.close-icon-wrapper {
|
||||
display: flex;
|
||||
.close-icon-wrapper {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
/* Non-closable tabs with changes: keep the draft icon visible even on hover */
|
||||
&.no-close.has-changes {
|
||||
.draft-icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.close-icon-wrapper {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -4,15 +4,23 @@ import DraftTabIcon from '../DraftTabIcon';
|
||||
import StyledWrapper from './StyledWrapper';
|
||||
|
||||
const GradientCloseButton = ({ onClick, hasChanges = false }) => {
|
||||
const canClose = typeof onClick === 'function';
|
||||
|
||||
return (
|
||||
<StyledWrapper className={`close-gradient ${hasChanges ? 'has-changes' : ''}`}>
|
||||
<div className="close-icon-container" onClick={onClick} data-testid="request-tab-close-icon">
|
||||
<span className="draft-icon-wrapper">
|
||||
<StyledWrapper className={`close-gradient ${hasChanges ? 'has-changes' : ''} ${canClose ? '' : 'no-close'}`}>
|
||||
<div
|
||||
className="close-icon-container"
|
||||
onClick={canClose ? onClick : undefined}
|
||||
data-testid={canClose ? 'request-tab-close-icon' : 'request-tab-draft-icon'}
|
||||
>
|
||||
<span className="draft-icon-wrapper" data-testid="tab-draft-icon">
|
||||
<DraftTabIcon />
|
||||
</span>
|
||||
<span className="close-icon-wrapper">
|
||||
<CloseTabIcon />
|
||||
</span>
|
||||
{canClose && (
|
||||
<span className="close-icon-wrapper">
|
||||
<CloseTabIcon />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</StyledWrapper>
|
||||
);
|
||||
|
||||
@@ -121,7 +121,8 @@ const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDra
|
||||
>
|
||||
{getTabInfo(type, tabName)}
|
||||
</div>
|
||||
{handleCloseClick && <GradientCloseButton hasChanges={hasDraft} onClick={(e) => handleCloseClick(e)} />}
|
||||
|
||||
<GradientCloseButton hasChanges={hasDraft} onClick={handleCloseClick} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
@@ -201,7 +202,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
const hasFolderDraft = tab.type === 'folder-settings' && folder?.draft;
|
||||
const hasEnvironmentDraft = tab.type === 'environment-settings' && collection?.environmentsDraft;
|
||||
const globalEnvironmentDraft = useSelector((state) => state.globalEnvironments.globalEnvironmentDraft);
|
||||
const hasGlobalEnvironmentDraft = tab.type === 'global-environment-settings' && globalEnvironmentDraft;
|
||||
const hasGlobalEnvironmentDraft = (tab.type === 'global-environment-settings' || tab.type === 'workspaceEnvironments') && globalEnvironmentDraft;
|
||||
|
||||
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
|
||||
const isActive = tab.uid === activeTabUid;
|
||||
@@ -255,16 +256,20 @@ 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') {
|
||||
} else if (tab.type === 'global-environment-settings' || tab.type === 'workspaceEnvironments') {
|
||||
if (globalEnvironmentDraft) {
|
||||
const { environmentUid, variables } = globalEnvironmentDraft;
|
||||
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') {
|
||||
@@ -478,7 +483,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
|
||||
) : tab.type === 'workspaceOverview' ? (
|
||||
<SpecialTab handleCloseClick={null} type={tab.type} />
|
||||
) : tab.type === 'workspaceEnvironments' ? (
|
||||
<SpecialTab handleCloseClick={null} type={tab.type} />
|
||||
<SpecialTab handleCloseClick={null} type={tab.type} hasDraft={hasGlobalEnvironmentDraft} />
|
||||
) : (
|
||||
<SpecialTab handleCloseClick={handleCloseClick} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} />
|
||||
)}
|
||||
@@ -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)}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const ModalTitle = styled.div`
|
||||
color: ${(props) => props.theme.colors.text.subtext2};
|
||||
font-weight: 600;
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
line-height: 18px;
|
||||
`;
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
max-width: 100%;
|
||||
.subheader {
|
||||
margin-bottom: 0.5rem;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
font-weight: 400;
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
line-height: 18px;
|
||||
|
||||
.collection-name {
|
||||
color: ${(props) => props.theme.text};
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.version-card {
|
||||
width: 100%;
|
||||
border: 1px solid ${(props) => props.theme.border.border1};
|
||||
border-radius: ${(props) => props.theme.border.radius.md};
|
||||
overflow: hidden;
|
||||
|
||||
.version-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.version-col {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.col-label {
|
||||
color: ${(props) => props.theme.text};
|
||||
font-weight: 500;
|
||||
font-size: ${(props) => props.theme.font.size.base};
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.current-value {
|
||||
color: ${(props) => props.theme.text};
|
||||
font-weight: 400;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
line-height: 20px;
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
.unset {
|
||||
font-family: inherit;
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
.arrow {
|
||||
align-self: center;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.input-wrap {
|
||||
position: relative;
|
||||
|
||||
.textbox {
|
||||
padding-right: 2.25rem;
|
||||
color: ${(props) => props.theme.text};
|
||||
font-weight: 400;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0.5rem;
|
||||
transform: translateY(-50%);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.25rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: ${(props) => props.theme.text};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
padding: 0.625rem 1rem;
|
||||
border-top: 1px solid ${(props) => props.theme.border.border1};
|
||||
background-color: ${(props) => props.theme.background.mantle};
|
||||
color: ${(props) => props.theme.text};
|
||||
font-weight: 400;
|
||||
font-size: ${(props) => props.theme.font.size.sm};
|
||||
line-height: 20px;
|
||||
|
||||
strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.old {
|
||||
color: ${(props) => props.theme.colors.text.danger};
|
||||
font-weight: 700;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.new {
|
||||
font-family: monospace;
|
||||
color: ${(props) => props.theme.colors.text.green};
|
||||
font-weight: 700;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.preview-arrow {
|
||||
color: ${(props) => props.theme.colors.text.muted};
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default StyledWrapper;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user