Merge branch 'main' into oauth2_additional_params

This commit is contained in:
lohit
2025-07-23 15:05:17 +05:30
committed by GitHub
334 changed files with 24446 additions and 3302 deletions

1
.github/CODEOWNERS vendored Normal file
View File

@ -0,0 +1 @@
* @helloanoop @maintainer-bruno @lohit-bruno @naman-bruno

View File

@ -1,44 +0,0 @@
name: Playwright E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
e2e-test:
timeout-minutes: 60
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: v22.11.x
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get --no-install-recommends install -y \
libglib2.0-0 libnss3 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libgtk-3-0 libasound2t64 \
xvfb
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
- name: Build libraries
run: |
npm run build:graphql-docs
npm run build:bruno-query
npm run build:bruno-common
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
npm run build:bruno-converters
npm run build:bruno-requests
- name: Run Playwright tests
run: |
xvfb-run npm run test:e2e
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30

View File

@ -91,5 +91,47 @@ jobs:
uses: EnricoMi/publish-unit-test-result-action@v2
if: always()
with:
check_name: CLI Test Results
files: packages/bruno-tests/collection/junit.xml
comment_mode: always
e2e-test:
name: Playwright E2E Tests
timeout-minutes: 60
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: v22.11.x
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get --no-install-recommends install -y \
libglib2.0-0 libnss3 libdbus-1-3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libgtk-3-0 libasound2t64 \
xvfb
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
- name: Install dependencies for test collection environment
run: |
npm ci --prefix packages/bruno-tests/collection
- name: Build libraries
run: |
npm run build:graphql-docs
npm run build:bruno-query
npm run build:bruno-common
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
npm run build:bruno-converters
npm run build:bruno-requests
- name: Run Playwright tests
run: |
xvfb-run npm run test:e2e
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30

View File

@ -99,14 +99,13 @@ npm run dev
```
#### Customize Electron `userData` path
If `ELECTRON_APP_NAME` env-variable is present and its development mode, then the `appName` and `userData` path is modified accordingly.
If `ELECTRON_USER_DATA_PATH` env-variable is present and its development mode, then `userData` path is modified accordingly.
e.g.
```sh
ELECTRON_APP_NAME=bruno-dev npm run dev:electron
ELECTRON_USER_DATA_PATH=$(realpath ~/Desktop/bruno-test) npm run dev:electron
```
> This doesn't change the name of the window or the names in lot of other places, only the name used by Electron internally.
This will create a `bruno-test` folder on your Desktop and use it as the `userData` path.
### Troubleshooting

View File

@ -1,4 +1,4 @@
[English](../../contributing.md)
[Inglés](../../contributing.md)
## ¡Juntos, hagamos a Bruno mejor!
@ -6,58 +6,111 @@ Estamos encantados de que quieras ayudar a mejorar Bruno. A continuación encont
### Tecnologías utilizadas
Bruno está construido con NextJs y React. También usamos electron para distribuir una versión de escritorio (que soporta colecciones locales).
Bruno está construido con React y Electron
Librerías que utilizamos:
- CSS - Tailwind
- Editores de código - Codemirror
- CSS - TailwindCSS
- Editores de código - CodeMirror
- Manejo del estado - Redux
- Íconos - Tabler Icons
- Formularios - formik
- Validación de esquemas - Yup
- Cliente de peticiones - axios
- Monitor del sistema de archivos - chokidar
- i18n (internacionalización) - i18next
### Dependencias
Necesitarás [Node v20.x o la última versión LTS](https://nodejs.org/es) y npm 8.x. Ten en cuenta que utilizamos espacios de trabajo de npm en el proyecto.
> [!IMPORTANT]
> Necesitarás [Node v22.x o la última versión LTS](https://nodejs.org/es/). Ten en cuenta que Bruno usa los espacios de trabajo de npm
## Desarrollo
Bruno está siendo desarrollado como una aplicación de escritorio. Para ejecutarlo, primero debes ejecutar la aplicación de nextjs en una terminal y luego ejecutar la aplicación de electron en otra terminal.
Bruno es una aplicación de escritorio. A continuación se detallan las instrucciones paso a paso para ejecutar Bruno.
### Dependencias
> Nota: Utilizamos React para el frontend y rsbuild para el servidor de desarrollo.
- NodeJS v18
### Instalar dependencias
```bash
# Use la versión 22.x o LTS (Soporte a Largo Plazo) de Node.js
nvm use 22.11.0
# instalar las dependencias
npm i --legacy-peer-deps
```
> ¿Por qué `--legacy-peer-deps`?: Fuerza la instalación ignorando conflictos en dependencias “peer”, evitando errores de árbol de dependencias.
### Desarrollo local
#### Construir paquetes
##### Opción 1
```bash
# Utiliza la versión 18 de nodejs
nvm use
# Instala las dependencias
npm i --legacy-peer-deps
# Construye la documentación de graphql
# construir paquetes
npm run build:graphql-docs
# Construye bruno-query
npm run build:bruno-query
npm run build:bruno-common
npm run build:bruno-converters
npm run build:bruno-requests
# Ejecuta la aplicación de nextjs (terminal 1)
# empaquetar bibliotecas JavaScript del entorno de pruebas aislado
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
```
##### Opción 2
```bash
# instalar dependencias y configurar el entorno
npm run setup
```
#### Ejecutar la aplicación
```bash
# ejecutar aplicación react (terminal 1)
npm run dev:web
# Ejecuta la aplicación de electron (terminal 2)
# ejecutar aplicación electron (terminal 2)
npm run dev:electron
```
##### Opción 1
```bash
# ejecutar aplicación react (terminal 1)
npm run dev:web
# ejecutar aplicación electron (terminal 2)
npm run dev:electron
```
##### Opción 2
```bash
# ejecutar aplicación electron y react de forma concurrente
npm run dev
```
#### Personalizar la ruta `userData` de Electron
Si la variable de entorno `ELECTRON_USER_DATA_PATH` está presente y se encuentra en modo de desarrollo, entonces la ruta `userData` se modifica en consecuencia.
ejemplo:
```sh
ELECTRON_USER_DATA_PATH=$(realpath ~/Desktop/bruno-test) npm run dev:electron
```
Esto creará una carpeta llamada `bruno-test` en tu escritorio y la usará como la ruta userData.
### Solución de problemas
Es posible que encuentres un error de `Unsupported platform` cuando ejecutes `npm install`. Para solucionarlo, debes eliminar la carpeta `node_modules` y el archivo `package-lock.json`, luego, ejecuta `npm install`. Lo anterior debería instalar todos los paquetes necesarios para ejecutar la aplicación.
Es posible que te encuentres con un error `Unsupported platform` cuando ejecutes `npm install`. Para solucionarlo, tendrás que eliminar las carpetas `node_modules` y el archivo `package-lock.json`, y luego volver a ejecutar `npm install`. Esto debería instalar todos los paquetes necesarios para que la aplicación funcione.
```shell
```sh
# Elimina la carpeta node_modules en los subdirectorios
find ./ -type d -name "node_modules" -print0 | while read -d $'\0' dir; do
rm -rf "$dir"
@ -69,10 +122,42 @@ find . -type f -name "package-lock.json" -delete
### Pruebas
```bash
# ejecutar pruebas de esquema bruno
npm test --workspace=packages/bruno-schema
#### Pruebas individuales
```bash
# ejecutar pruebas de bruno-app
npm run test --workspace=packages/bruno-app
# ejecutar pruebas de bruno-electron
npm run test --workspace=packages/bruno-electron
# ejecutar pruebas de bruno-cli
npm run test --workspace=packages/bruno-cli
# ejecutar pruebas de bruno-common
npm run test --workspace=packages/bruno-common
# ejecutar pruebas de bruno-converters
npm run test --workspace=packages/bruno-converters
# ejecutar pruebas de bruno-schema
npm run test --workspace=packages/bruno-schema
# ejecutar pruebas de bruno-query
npm run test --workspace=packages/bruno-query
# ejecutar pruebas de bruno-js
npm run test --workspace=packages/bruno-js
# ejecutar pruebas de bruno-lang
npm run test --workspace=packages/bruno-lang
# ejecutar pruebas de bruno-toml
npm run test --workspace=packages/bruno-toml
```
#### Pruebas en conjunto
```bash
# ejecutar pruebas en todos los espacios de trabajo
npm test --workspaces --if-present
```

View File

@ -74,12 +74,11 @@ flatpak install com.usebruno.Bruno
# على نظام Linux عبر Apt
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update
sudo apt
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```
### التشغيل عبر منصات متعددة 🖥️

View File

@ -59,12 +59,11 @@ snap install bruno
# Apt এর মাধ্যমে লিনাক্সে
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update
sudo apt install bruno
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```
### একাধিক প্ল্যাটফর্মে চালান 🖥️

View File

@ -63,12 +63,11 @@ snap install bruno
# 在 Linux 上用 Apt 安装
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update
sudo apt install bruno
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```
### 在 Mac 上通过 Homebrew 安装 🖥️

View File

@ -78,12 +78,11 @@ flatpak install com.usebruno.Bruno
# Auf Linux via Apt
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update
sudo apt install bruno
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```
### Einsatz auf verschiedensten Plattformen 🖥️

View File

@ -75,12 +75,11 @@ flatpak install com.usebruno.Bruno
# En Linux con Apt
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update
sudo apt install bruno
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```
### Ejecútalo en múltiples plataformas 🖥️

View File

@ -63,12 +63,11 @@ snap install bruno
# Linux via Apt
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update
sudo apt install bruno
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```
### Fonctionne sur de multiples plateformes 🖥️

151
docs/readme/readme_hi.md Normal file
View File

@ -0,0 +1,151 @@
<br />
<img src="../../assets/images/logo-transparent.png" width="80"/>
### ब्रूनो - API इंटरफेस (API) का अन्वेषण और परीक्षण करने के लिए एक ओपन-सोर्स विकास वातावरण।
[![GitHub संस्करण](https://badge.fury.io/gh/usebruno%2Fbruno.svg)](https://badge.fury.io/gh/usebruno%2Fbruno)
[![CI](https://github.com/usebruno/bruno/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/usebruno/bruno/actions/workflows/tests.yml)
[![कमिट गतिविधि](https://img.shields.io/github/commit-activity/m/usebruno/bruno)](https://github.com/usebruno/bruno/pulse)
[![X](https://img.shields.io/twitter/follow/use_bruno?style=social&logo=x)](https://twitter.com/use_bruno)
[![वेबसाइट](https://img.shields.io/badge/Website-Visit-blue)](https://www.usebruno.com)
[![डाउनलोड](https://img.shields.io/badge/Download-Latest-brightgreen)](https://www.usebruno.com/downloads)
[English](../../readme.md)
| [Українська](./readme_ua.md)
| [Русский](./readme_ru.md)
| [Türkçe](./readme_tr.md)
| [Deutsch](./readme_de.md)
| [Français](./readme_fr.md)
| [Português (BR)](./readme_pt_br.md)
| [한국어](./readme_kr.md)
| [বাংলা](./readme_bn.md)
| [Español](./readme_es.md)
| [Italiano](./readme_it.md)
| [Română](./readme_ro.md)
| [Polski](./readme_pl.md)
| [简体中文](./readme_cn.md)
| [正體中文](./readme_zhtw.md)
| [العربية](./readme_ar.md)
| [日本語](./readme_ja.md)
| [ქართული](./readme_ka.md)
| **हिन्दी**
ब्रूनो एक नया और अभिनव API क्लाइंट है, जिसका उद्देश्य Postman और अन्य समान उपकरणों द्वारा प्रस्तुत स्थिति को बदलना है।
ब्रूनो आपकी कलेक्शनों को सीधे आपकी फाइल सिस्टम के एक फ़ोल्डर में संग्रहीत करता है। हम API अनुरोधों के बारे में जानकारी सहेजने के लिए एक सामान्य टेक्स्ट मार्कअप भाषा, Bru, का उपयोग करते हैं।
आप अपनी API कलेक्शनों पर सहयोग करने के लिए Git या अपनी पसंद के किसी भी संस्करण नियंत्रण प्रणाली का उपयोग कर सकते हैं।
ब्रूनो केवल ऑफ़लाइन उपयोग के लिए है। ब्रूनो में कभी भी क्लाउड-सिंक जोड़ने की कोई योजना नहीं है। हम आपके डेटा की गोपनीयता को महत्व देते हैं और मानते हैं कि इसे आपके डिवाइस पर ही रहना चाहिए। हमारी दीर्घकालिक दृष्टि [यहाँ](https://github.com/usebruno/bruno/discussions/269) पढ़ें।
📢 हमारे हालिया India FOSS 3.0 सम्मेलन में हमारे वार्तालाप को [यहाँ](https://www.youtube.com/watch?v=7bSMFpbcPiY) देखें।
![bruno](/assets/images/landing-2.png) <br /><br />
### गोल्डन संस्करण ✨
हमारी अधिकांश सुविधाएँ मुफ्त और ओपन-सोर्स हैं।
हम [पारदर्शिता और स्थिरता के सिद्धांतों](https://github.com/usebruno/bruno/discussions/269) के बीच एक सामंजस्यपूर्ण संतुलन प्राप्त करने का प्रयास करते हैं।
[गोल्डन संस्करण](https://www.usebruno.com/pricing) के लिए खरीदारी जल्द ही $9 की कीमत पर उपलब्ध होगी! <br/>
[यहाँ सदस्यता लें](https://usebruno.ck.page/4c65576bd4) ताकि आपको लॉन्च पर सूचनाएं मिलें।
### स्थापना
ब्रूनो Mac, Windows और Linux के लिए हमारे [वेबसाइट](https://www.usebruno.com/downloads) पर एक बाइनरी डाउनलोड के रूप में उपलब्ध है।
आप ब्रूनो को Homebrew, Chocolatey, Scoop, Snap, Flatpak और Apt जैसे पैकेज प्रबंधकों के माध्यम से भी स्थापित कर सकते हैं।
```sh
# Mac पर Homebrew के माध्यम से
brew install bruno
# Windows पर Chocolatey के माध्यम से
choco install bruno
# Windows पर Scoop के माध्यम से
scoop bucket add extras
scoop install bruno
# Linux पर Snap के माध्यम से
snap install bruno
# Linux पर Flatpak के माध्यम से
flatpak install com.usebruno.Bruno
# Linux पर Apt के माध्यम से
sudo mkdir -p /etc/apt/keyrings
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update
sudo apt install bruno
कई प्लेटफार्मों पर चलाएं 🖥️
<br /><br />
Git के माध्यम से सहयोग करें 👩‍💻🧑‍💻
या अपनी पसंद के किसी भी संस्करण नियंत्रण प्रणाली का उपयोग करें
<br /><br />
महत्वपूर्ण लिंक 📌
हमारी दीर्घकालिक दृष्टि
रोडमैप
प्रलेखन
Stack Overflow
वेबसाइट
मूल्य निर्धारण
डाउनलोड
GitHub प्रायोजक
प्रस्तुतियाँ 🎥
प्रशंसापत्र
ज्ञान केंद्र
Scriptmania
समर्थन ❤️
यदि आप ब्रूनो को पसंद करते हैं और हमारे ओपन-सोर्स कार्य का समर्थन करना चाहते हैं, तो कृपया GitHub प्रायोजक के माध्यम से हमें प्रायोजित करने पर विचार करें।
प्रशंसापत्र साझा करें 📣
यदि ब्रूनो ने आपके और आपकी टीमों के लिए काम में मदद की है, तो कृपया हमारे GitHub चर्चा में अपने प्रशंसापत्र साझा करना न भूलें
नए पैकेज प्रबंधकों में प्रकाशित करना
अधिक जानकारी के लिए कृपया यहाँ देखें।
हमसे संपर्क करें 🌐
𝕏 (ट्विटर) <br />
वेबसाइट <br />
डिस्कॉर्ड <br />
लिंक्डइन
ट्रेडमार्क
नाम
ब्रूनो एक ट्रेडमार्क है जो अनूप एम डी के स्वामित्व में है।
लोगो
लोगो OpenMoji से लिया गया है। लाइसेंस: CC BY-SA 4.0
योगदान 👩‍💻🧑‍💻
हमें खुशी है कि आप ब्रूनो को बेहतर बनाने में रुचि रखते हैं। कृपया योगदान गाइड देखें।
यदि आप सीधे कोड के माध्यम से योगदान नहीं कर सकते, तो भी कृपया बग्स की रिपोर्ट करने और उन सुविधाओं का अनुरोध करने में संकोच न करें जिन्हें आपकी स्थिति को हल करने के लिए लागू किया जाना चाहिए।
लेखक
<div align="center"> <a href="https://github.com/usebruno/bruno/graphs/contributors"> <img src="https://contrib.rocks/image?repo=usebruno/bruno" /> </a> </div>
लाइसेंस 📄
MIT

View File

@ -59,12 +59,11 @@ snap install bruno
# Su Linux tramite Apt
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update
sudo apt install bruno
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```
### Funziona su diverse piattaforme 🖥️

View File

@ -78,12 +78,11 @@ flatpak install com.usebruno.Bruno
# LinuxでAptを使ってインストール
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update
sudo apt install bruno
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```
### マルチプラットフォームでの実行に対応 🖥️

View File

@ -59,12 +59,11 @@ snap install bruno
# On Linux via Apt
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update
sudo apt install bruno
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```
### 여러 플랫폼에서 실행하세요. 🖥️

View File

@ -69,12 +69,11 @@ flatpak install com.usebruno.Bruno
# On Linux via Apt
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update
sudo apt install bruno
sudo apt update && sudo apt install bruno
```
### Uruchom na wielu platformach 🖥️

View File

@ -76,12 +76,11 @@ flatpak install com.usebruno.Bruno
# No Linux via Apt
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update
sudo apt install bruno
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```
### Execute em várias plataformas 🖥️

View File

@ -59,12 +59,11 @@ snap install bruno
# Pe Linux cu Apt
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update
sudo apt install bruno
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```
### Utilizați pe mai multe platforme 🖥️

View File

@ -63,12 +63,11 @@ snap install bruno
# Apt aracılığıyla Linux'ta
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update
sudo apt install bruno
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```
### Birden fazla platformda çalıştırın 🖥️

View File

@ -63,12 +63,11 @@ snap install bruno
# 在 Linux 上使用 Apt 安裝
sudo mkdir -p /etc/apt/keyrings
sudo apt update && sudo apt install gpg
sudo gpg --list-keys
sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266
echo "deb [signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update
sudo apt install bruno
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list
sudo apt update && sudo apt install bruno
```
### 跨多個平台運行 🖥️

View File

@ -0,0 +1,5 @@
import { test, expect } from '../../playwright';
test('Check if the logo on top left is visible', async ({ page }) => {
await expect(page.getByRole('button', { name: 'bruno' })).toBeVisible();
});

View File

@ -0,0 +1,31 @@
import { test, expect } from '../../playwright';
test('Create new collection and add a simple HTTP request', async ({ page, createTmpDir }) => {
await page.getByLabel('Create Collection').click();
await page.getByLabel('Name').click();
await page.getByLabel('Name').fill('test-collection');
await page.getByLabel('Name').press('Tab');
await page.getByLabel('Location').fill(await createTmpDir('test-collection'));
await page.getByRole('button', { name: 'Create', exact: true }).click();
await page.getByText('test-collection').click();
await page.getByLabel('Safe Mode').check();
await page.getByRole('button', { name: 'Save' }).click();
await page.locator('#create-new-tab').getByRole('img').click();
await page.getByPlaceholder('Request Name').fill('r1');
await page.locator('#new-request-url .CodeMirror').click();
await page.locator('textarea').fill('http://localhost:8081');
await page.getByRole('button', { name: 'Create' }).click();
await page.locator('#request-url .CodeMirror').click();
await page.locator('textarea').fill('/ping');
await page.locator('#send-request').getByRole('img').nth(2).click();
await expect(page.getByRole('main')).toContainText('200 OK');
await page.getByRole('tab', { name: 'GET r1' }).locator('circle').click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
await page.getByText('GETr1').click();
await page.getByRole('button', { name: 'Clear response' }).click();
await page.locator('body').press('ControlOrMeta+Enter');
await expect(page.getByRole('main')).toContainText('200 OK');
});

View File

@ -0,0 +1,4 @@
{
"maximized": true,
"lastOpenedCollections": ["{{projectRoot}}/packages/bruno-tests/collection"]
}

View File

@ -0,0 +1,49 @@
import { test, expect } from '../../playwright';
test.describe.parallel('Run Testbench Requests', () => {
test('Run bruno-testbench in Developer Mode', async ({ pageWithUserData: page }) => {
test.setTimeout(2 * 60 * 1000);
await page.getByText('bruno-testbench').click();
await page.getByLabel('Developer Mode(use only if').check();
await page.getByRole('button', { name: 'Save' }).click();
await page.locator('.environment-selector').nth(1).click();
await page.locator('.dropdown-item').getByText('Prod').click();
await page.locator('.collection-actions').hover();
await page.locator('.collection-actions .icon').click();
await page.getByText('Run', { exact: true }).click();
await page.getByRole('button', { name: 'Run Collection' }).click();
await page.getByRole('button', { name: 'Run Again' }).waitFor({ timeout: 2 * 60 * 1000 });
const result = await page.getByText('Total Requests: ').innerText();
const [totalRequests, passed, failed, skipped] = result
.match(/Total Requests: (\d+), Passed: (\d+), Failed: (\d+), Skipped: (\d+)/)
.slice(1);
await expect(parseInt(failed)).toBe(0);
await expect(parseInt(passed)).toBe(parseInt(totalRequests) - parseInt(skipped) - parseInt(failed));
});
test.fixme('Run bruno-testbench in Safe Mode', async ({ pageWithUserData: page }) => {
test.setTimeout(2 * 60 * 1000);
await page.getByText('bruno-testbench').click();
await page.getByLabel('Safe Mode').check();
await page.getByRole('button', { name: 'Save' }).click();
await page.locator('.environment-selector').nth(1).click();
await page.locator('.dropdown-item').getByText('Prod').click();
await page.locator('.collection-actions').hover();
await page.locator('.collection-actions .icon').click();
await page.getByText('Run', { exact: true }).click();
await page.getByRole('button', { name: 'Run Collection' }).click();
await page.getByRole('button', { name: 'Run Again' }).waitFor({ timeout: 2 * 60 * 1000 });
const result = await page.getByText('Total Requests: ').innerText();
const [totalRequests, passed, failed, skipped] = result
.match(/Total Requests: (\d+), Passed: (\d+), Failed: (\d+), Skipped: (\d+)/)
.slice(1);
await expect(parseInt(failed)).toBe(0);
await expect(parseInt(passed)).toBe(parseInt(totalRequests) - parseInt(skipped) - parseInt(failed));
});
});

View File

@ -0,0 +1,27 @@
import { test, expect } from '../../playwright';
test('Should verify all support links with correct URL in preference > Support tab', async ({ page }) => {
// Open Preferences
await page.getByLabel('Open Preferences').click();
// Verify Support tab
await page.getByRole('tab', { name: 'Support' }).click();
const locator_twitter = page.getByRole('link', { name: 'Twitter' });
expect(await locator_twitter.getAttribute('href')).toEqual('https://twitter.com/use_bruno');
const locator_github = page.getByRole('link', { name: 'GitHub', exact: true });
expect(await locator_github.getAttribute('href')).toEqual('https://github.com/usebruno/bruno');
const locator_discord = page.getByRole('link', { name: 'Discord', exact: true });
expect(await locator_discord.getAttribute('href')).toEqual('https://discord.com/invite/KgcZUncpjq');
const locator_reportissues = page.getByRole('link', { name: 'Report Issues', exact: true });
expect(await locator_reportissues.getAttribute('href')).toEqual('https://github.com/usebruno/bruno/issues');
const locator_documentation = page.getByRole('link', { name: 'Documentation', exact: true });
expect(await locator_documentation.getAttribute('href')).toEqual('https://docs.usebruno.com');
});

View File

@ -1,5 +0,0 @@
import { test, expect } from '../playwright';
test('test-app-start', async ({ page }) => {
await expect(page.getByRole('button', { name: 'bruno' })).toBeVisible();
});

View File

@ -25,6 +25,19 @@ module.exports = defineConfig([
"no-undef": "error",
},
},
{
// It prevents lint errors when using CommonJS exports (module.exports) in Jest mocks.
files: ["packages/bruno-app/src/test-utils/mocks/codemirror.js"],
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
},
rules: {
"no-undef": "error",
},
},
{
files: ["packages/bruno-electron/**/*.{js}"],
ignores: ["**/*.config.js"],

3256
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -40,6 +40,7 @@
"setup": "node ./scripts/setup.js",
"watch:converters": "npm run watch --workspace=packages/bruno-converters",
"dev": "concurrently --kill-others \"npm run dev:web\" \"npm run dev:electron\"",
"dev:watch": "node ./scripts/dev-hot-reload.js",
"dev:web": "npm run dev --workspace=packages/bruno-app",
"build:web": "npm run build --workspace=packages/bruno-app",
"prettier:web": "npm run prettier --workspace=packages/bruno-app",

View File

@ -1,4 +1,4 @@
{
"presets": ["@babel/preset-env"],
"presets": ["@babel/preset-env", "@babel/preset-react"],
"plugins": [["styled-components", { "ssr": true }]]
}

View File

@ -0,0 +1,9 @@
module.exports = {
presets: [
'@babel/preset-env',
['@babel/preset-react', {
runtime: 'automatic'
}]
],
plugins: ['babel-plugin-styled-components']
};

View File

@ -1,5 +1,11 @@
module.exports = {
rootDir: '.',
transform: {
'^.+\\.[jt]sx?$': 'babel-jest',
},
transformIgnorePatterns: [
"/node_modules/(?!strip-json-comments|nanoid|xml-formatter)/",
],
moduleNameMapper: {
'^assets/(.*)$': '<rootDir>/src/assets/$1',
'^components/(.*)$': '<rootDir>/src/components/$1',
@ -8,9 +14,17 @@ module.exports = {
'^api/(.*)$': '<rootDir>/src/api/$1',
'^pageComponents/(.*)$': '<rootDir>/src/pageComponents/$1',
'^providers/(.*)$': '<rootDir>/src/providers/$1',
'^utils/(.*)$': '<rootDir>/src/utils/$1'
'^utils/(.*)$': '<rootDir>/src/utils/$1',
'^test-utils/(.*)$': '<rootDir>/src/test-utils/$1'
},
clearMocks: true,
moduleDirectories: ['node_modules', 'src'],
testEnvironment: 'node'
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['@testing-library/jest-dom'],
setupFiles: [
'<rootDir>/jest.setup.js',
],
testMatch: [
'<rootDir>/src/**/*.spec.[jt]s?(x)'
]
};

View File

@ -0,0 +1,11 @@
jest.mock('nanoid', () => {
return {
nanoid: () => {}
};
});
jest.mock('strip-json-comments', () => {
return {
stripJsonComments: (str) => str
};
});

View File

@ -6,6 +6,7 @@
"baseUrl": "./",
"paths": {
"assets/*": ["src/assets/*"],
"ui/*": ["src/ui/*"],
"components/*": ["src/components/*"],
"hooks/*": ["src/hooks/*"],
"themes/*": ["src/themes/*"],

View File

@ -1,6 +1,7 @@
{
"name": "@usebruno/app",
"version": "2.0.0",
"license": "MIT",
"private": true,
"scripts": {
"dev": "rsbuild dev",
@ -11,7 +12,6 @@
"prettier": "prettier --write \"./src/**/*.{js,jsx,json,ts,tsx}\""
},
"dependencies": {
"@babel/preset-env": "^7.26.0",
"@fontsource/inter": "^5.0.15",
"@prantlf/jsonlint": "^16.0.0",
"@reduxjs/toolkit": "^1.8.0",
@ -67,34 +67,44 @@
"react-hot-toast": "^2.4.0",
"react-i18next": "^15.0.1",
"react-inspector": "^6.0.2",
"react-json-view": "^1.21.3",
"react-pdf": "9.1.1",
"react-player": "^2.16.0",
"react-redux": "^7.2.9",
"react-tooltip": "^5.5.2",
"sass": "^1.46.0",
"semver": "^7.7.1",
"shell-quote": "^1.8.3",
"strip-json-comments": "^5.0.1",
"styled-components": "^5.3.3",
"system": "^2.0.1",
"url": "^0.11.3",
"xml-formatter": "^3.5.0",
"yargs-parser": "^21.1.1",
"yup": "^0.32.11"
},
"devDependencies": {
"@babel/core": "^7.27.1",
"@babel/preset-env": "^7.27.2",
"@babel/preset-react": "^7.27.1",
"@rsbuild/core": "^1.1.2",
"@rsbuild/plugin-babel": "^1.0.3",
"@rsbuild/plugin-node-polyfill": "^1.2.0",
"@rsbuild/plugin-react": "^1.0.7",
"@rsbuild/plugin-sass": "^1.1.0",
"@rsbuild/plugin-styled-components": "1.1.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"autoprefixer": "10.4.20",
"babel-jest": "^29.7.0",
"babel-plugin-react-compiler": "19.0.0-beta-a7bf2bd-20241110",
"babel-plugin-styled-components": "^2.1.4",
"cross-env": "^7.0.3",
"css-loader": "7.1.2",
"file-loader": "^6.2.0",
"html-loader": "^3.0.1",
"html-webpack-plugin": "^5.5.0",
"jest-environment-jsdom": "^29.7.0",
"mini-css-extract-plugin": "^2.4.5",
"postcss": "8.4.47",
"style-loader": "^3.3.1",

View File

@ -20,6 +20,11 @@ export default defineConfig({
],
source: {
tsconfigPath: './jsconfig.json', // Specifies the path to the JavaScript/TypeScript configuration file,
exclude: [
'**/test-utils/**',
'**/*.test.*',
'**/*.spec.*'
]
},
html: {
title: 'Bruno'

View File

@ -0,0 +1,40 @@
import React, { useMemo } from 'react';
import CodeEditor from 'components/CodeEditor';
import { useTheme } from 'providers/Theme';
import { useSelector } from 'react-redux';
import { parseBulkKeyValue, serializeBulkKeyValue } from 'utils/common/bulkKeyValueUtils';
const BulkEditor = ({ params, onChange, onToggle, onSave, onRun }) => {
const preferences = useSelector((state) => state.app.preferences);
const { displayedTheme } = useTheme();
const parsedParams = useMemo(() => serializeBulkKeyValue(params), [params]);
const handleEdit = (value) => {
const parsed = parseBulkKeyValue(value);
onChange(parsed);
};
return (
<>
<div className="h-[200px]">
<CodeEditor
mode="text/plain"
theme={displayedTheme}
font={preferences.codeFont || 'default'}
value={parsedParams}
onEdit={handleEdit}
onSave={onSave}
onRun={onRun}
/>
</div>
<div className="flex btn-action justify-between items-center mt-3">
<button className="text-link select-none ml-auto" onClick={onToggle}>
Key/Value Edit
</button>
</div>
</>
);
};
export default BulkEditor;

View File

@ -8,117 +8,19 @@
import React from 'react';
import { isEqual, escapeRegExp } from 'lodash';
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
import { setupAutoComplete } from 'utils/codemirror/autocomplete';
import StyledWrapper from './StyledWrapper';
import * as jsonlint from '@prantlf/jsonlint';
import { JSHINT } from 'jshint';
import stripJsonComments from 'strip-json-comments';
import { getAllVariables } from 'utils/collections';
let CodeMirror;
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
const CodeMirror = require('codemirror');
window.jsonlint = jsonlint;
window.JSHINT = JSHINT;
const TAB_SIZE = 2;
if (!SERVER_RENDERED) {
CodeMirror = require('codemirror');
window.jsonlint = jsonlint;
window.JSHINT = JSHINT;
//This should be done dynamically if possible
const hintWords = [
'res',
'res.status',
'res.statusText',
'res.headers',
'res.body',
'res.responseTime',
'res.getStatus()',
'res.getStatusText()',
'res.getHeader(name)',
'res.getHeaders()',
'res.getBody()',
'res.setBody(data)',
'res.getResponseTime()',
'req',
'req.url',
'req.method',
'req.headers',
'req.body',
'req.timeout',
'req.getUrl()',
'req.setUrl(url)',
'req.getMethod()',
'req.getAuthMode()',
'req.setMethod(method)',
'req.getHeader(name)',
'req.getHeaders()',
'req.setHeader(name, value)',
'req.setHeaders(data)',
'req.getBody()',
'req.setBody(data)',
'req.setMaxRedirects(maxRedirects)',
'req.getTimeout()',
'req.setTimeout(timeout)',
'req.getExecutionMode()',
'bru',
'bru.cwd()',
'bru.getEnvName()',
'bru.getProcessEnv(key)',
'bru.hasEnvVar(key)',
'bru.getEnvVar(key)',
'bru.getFolderVar(key)',
'bru.getCollectionVar(key)',
'bru.setEnvVar(key,value)',
'bru.deleteEnvVar(key)',
'bru.hasVar(key)',
'bru.getVar(key)',
'bru.setVar(key,value)',
'bru.deleteVar(key)',
'bru.deleteAllVars()',
'bru.setNextRequest(requestName)',
'req.disableParsingResponseJson()',
'bru.getRequestVar(key)',
'bru.runRequest(requestPathName)',
'bru.getAssertionResults()',
'bru.getTestResults()',
'bru.sleep(ms)',
'bru.getGlobalEnvVar(key)',
'bru.setGlobalEnvVar(key, value)',
'bru.runner',
'bru.runner.setNextRequest(requestName)',
'bru.runner.skipRequest()',
'bru.runner.stopExecution()'
];
CodeMirror.registerHelper('hint', 'brunoJS', (editor, options) => {
const cursor = editor.getCursor();
const currentLine = editor.getLine(cursor.line);
let startBru = cursor.ch;
let endBru = startBru;
while (endBru < currentLine.length && /[\w.]/.test(currentLine.charAt(endBru))) ++endBru;
while (startBru && /[\w.]/.test(currentLine.charAt(startBru - 1))) --startBru;
let curWordBru = startBru != endBru && currentLine.slice(startBru, endBru);
let start = cursor.ch;
let end = start;
while (end < currentLine.length && /[\w]/.test(currentLine.charAt(end))) ++end;
while (start && /[\w]/.test(currentLine.charAt(start - 1))) --start;
const jsHinter = CodeMirror.hint.javascript;
let result = jsHinter(editor) || { list: [] };
result.to = CodeMirror.Pos(cursor.line, end);
result.from = CodeMirror.Pos(cursor.line, start);
if (curWordBru) {
hintWords.forEach((h) => {
if (h.includes('.') == curWordBru.includes('.') && h.startsWith(curWordBru)) {
result.list.push(curWordBru.includes('.') ? h.split('.')?.at(-1) : h);
}
});
result.list?.sort();
}
return result;
});
CodeMirror.commands.autocomplete = (cm, hint, options) => {
cm.showHint({ hint, ...options });
};
}
export default class CodeEditor extends React.Component {
constructor(props) {
super(props);
@ -138,12 +40,17 @@ export default class CodeEditor extends React.Component {
}
componentDidMount() {
const variables = getAllVariables(this.props.collection, this.props.item);
const editor = (this.editor = CodeMirror(this._node, {
value: this.props.value || '',
lineNumbers: true,
lineWrapping: true,
tabSize: TAB_SIZE,
mode: this.props.mode || 'application/ld+json',
brunoVarInfo: {
variables
},
keyMap: 'sublime',
autoCloseBrackets: true,
matchBrackets: true,
@ -275,30 +182,24 @@ export default class CodeEditor extends React.Component {
}
return found;
});
if (editor) {
editor.setOption('lint', this.props.mode && editor.getValue().trim().length > 0 ? this.lintOptions : false);
editor.on('change', this._onEdit);
this.addOverlay();
}
if (this.props.mode == 'javascript') {
editor.on('keyup', function (cm, event) {
const cursor = editor.getCursor();
const currentLine = editor.getLine(cursor.line);
let start = cursor.ch;
let end = start;
while (end < currentLine.length && /[^{}();\s\[\]\,]/.test(currentLine.charAt(end))) ++end;
while (start && /[^{}();\s\[\]\,]/.test(currentLine.charAt(start - 1))) --start;
let curWord = start != end && currentLine.slice(start, end);
// Qualify if autocomplete will be shown
if (
/^(?!Shift|Tab|Enter|Escape|ArrowUp|ArrowDown|ArrowLeft|ArrowRight|Meta|Alt|Home|End\s)\w*/.test(event.key) &&
curWord.length > 0 &&
!/\/\/|\/\*|.*{{|`[^$]*{|`[^{]*$/.test(currentLine.slice(0, end)) &&
/(?<!\d)[a-zA-Z\._]$/.test(curWord)
) {
CodeMirror.commands.autocomplete(cm, CodeMirror.hint.brunoJS, { completeSingle: false });
}
});
const getAllVariablesHandler = () => getAllVariables(this.props.collection, this.props.item);
// Setup AutoComplete Helper for all modes
const autoCompleteOptions = {
showHintsFor: this.props.showHintsFor,
getAllVariables: getAllVariablesHandler
};
this.brunoAutoCompleteCleanup = setupAutoComplete(
editor,
autoCompleteOptions
);
}
}
@ -339,6 +240,9 @@ export default class CodeEditor extends React.Component {
}
this._unbindSearchHandler();
if (this.brunoAutoCompleteCleanup) {
this.brunoAutoCompleteCleanup();
}
}
render() {
@ -363,7 +267,7 @@ export default class CodeEditor extends React.Component {
let variables = getAllVariables(this.props.collection, this.props.item);
this.variables = variables;
defineCodeMirrorBrunoVariablesMode(variables, mode);
defineCodeMirrorBrunoVariablesMode(variables, mode, false, this.props.enableVariableHighlighting);
this.editor.setOption('mode', 'brunovariables');
};

View File

@ -0,0 +1,51 @@
import React from 'react';
import { render, act } from '@testing-library/react';
import CodeEditor from './index';
import { ThemeProvider } from 'styled-components';
jest.mock('codemirror', () => {
const codemirror = require('test-utils/mocks/codemirror');
return codemirror;
});
const MOCK_THEME = {
codemirror: {
bg: "#1e1e1e",
border: "#333",
},
textLink: "#007acc",
};
const setupEditorState = (editor, { value, cursorPosition }) => {
editor._currentValue = value;
editor.getCursor.mockReturnValue({ line: 0, ch: cursorPosition });
editor.getRange.mockImplementation((from, to) => {
if (from.line === 0 && from.ch === 0 && to.line === 0 && to.ch === cursorPosition) {
return value;
}
return editor._currentValue.slice(from.ch, to.ch);
});
editor.state = {
completionActive: null,
}
};
const setupEditorWithRef = () => {
const ref = React.createRef();
const { rerender } = render(
<ThemeProvider theme={MOCK_THEME}>
<CodeEditor ref={ref} />
</ThemeProvider>
);
return { ref, rerender };
};
describe('CodeEditor', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.resetModules();
});
it("add CodeEditor related tests here", () => {});
});

View File

@ -21,12 +21,12 @@ const AwsV4Auth = ({ collection }) => {
mode: 'awsv4',
collectionUid: collection.uid,
content: {
accessKeyId: accessKeyId,
secretAccessKey: awsv4Auth.secretAccessKey,
sessionToken: awsv4Auth.sessionToken,
service: awsv4Auth.service,
region: awsv4Auth.region,
profileName: awsv4Auth.profileName
accessKeyId: accessKeyId || '',
secretAccessKey: awsv4Auth.secretAccessKey || '',
sessionToken: awsv4Auth.sessionToken || '',
service: awsv4Auth.service || '',
region: awsv4Auth.region || '',
profileName: awsv4Auth.profileName || ''
}
})
);
@ -38,12 +38,12 @@ const AwsV4Auth = ({ collection }) => {
mode: 'awsv4',
collectionUid: collection.uid,
content: {
accessKeyId: awsv4Auth.accessKeyId,
secretAccessKey: secretAccessKey,
sessionToken: awsv4Auth.sessionToken,
service: awsv4Auth.service,
region: awsv4Auth.region,
profileName: awsv4Auth.profileName
accessKeyId: awsv4Auth.accessKeyId || '',
secretAccessKey: secretAccessKey || '',
sessionToken: awsv4Auth.sessionToken || '',
service: awsv4Auth.service || '',
region: awsv4Auth.region || '',
profileName: awsv4Auth.profileName || ''
}
})
);
@ -55,12 +55,12 @@ const AwsV4Auth = ({ collection }) => {
mode: 'awsv4',
collectionUid: collection.uid,
content: {
accessKeyId: awsv4Auth.accessKeyId,
secretAccessKey: awsv4Auth.secretAccessKey,
sessionToken: sessionToken,
service: awsv4Auth.service,
region: awsv4Auth.region,
profileName: awsv4Auth.profileName
accessKeyId: awsv4Auth.accessKeyId || '',
secretAccessKey: awsv4Auth.secretAccessKey || '',
sessionToken: sessionToken || '',
service: awsv4Auth.service || '',
region: awsv4Auth.region || '',
profileName: awsv4Auth.profileName || ''
}
})
);
@ -72,12 +72,12 @@ const AwsV4Auth = ({ collection }) => {
mode: 'awsv4',
collectionUid: collection.uid,
content: {
accessKeyId: awsv4Auth.accessKeyId,
secretAccessKey: awsv4Auth.secretAccessKey,
sessionToken: awsv4Auth.sessionToken,
service: service,
region: awsv4Auth.region,
profileName: awsv4Auth.profileName
accessKeyId: awsv4Auth.accessKeyId || '',
secretAccessKey: awsv4Auth.secretAccessKey || '',
sessionToken: awsv4Auth.sessionToken || '',
service: service || '',
region: awsv4Auth.region || '',
profileName: awsv4Auth.profileName || ''
}
})
);
@ -89,12 +89,12 @@ const AwsV4Auth = ({ collection }) => {
mode: 'awsv4',
collectionUid: collection.uid,
content: {
accessKeyId: awsv4Auth.accessKeyId,
secretAccessKey: awsv4Auth.secretAccessKey,
sessionToken: awsv4Auth.sessionToken,
service: awsv4Auth.service,
region: region,
profileName: awsv4Auth.profileName
accessKeyId: awsv4Auth.accessKeyId || '',
secretAccessKey: awsv4Auth.secretAccessKey || '',
sessionToken: awsv4Auth.sessionToken || '',
service: awsv4Auth.service || '',
region: region || '',
profileName: awsv4Auth.profileName || ''
}
})
);
@ -106,12 +106,12 @@ const AwsV4Auth = ({ collection }) => {
mode: 'awsv4',
collectionUid: collection.uid,
content: {
accessKeyId: awsv4Auth.accessKeyId,
secretAccessKey: awsv4Auth.secretAccessKey,
sessionToken: awsv4Auth.sessionToken,
service: awsv4Auth.service,
region: awsv4Auth.region,
profileName: profileName
accessKeyId: awsv4Auth.accessKeyId || '',
secretAccessKey: awsv4Auth.secretAccessKey || '',
sessionToken: awsv4Auth.sessionToken || '',
service: awsv4Auth.service || '',
region: awsv4Auth.region || '',
profileName: profileName || ''
}
})
);

View File

@ -21,8 +21,8 @@ const BasicAuth = ({ collection }) => {
mode: 'basic',
collectionUid: collection.uid,
content: {
username: username,
password: basicAuth.password
username: username || '',
password: basicAuth.password || ''
}
})
);
@ -34,8 +34,8 @@ const BasicAuth = ({ collection }) => {
mode: 'basic',
collectionUid: collection.uid,
content: {
username: basicAuth.username,
password: password
username: basicAuth.username || '',
password: password || ''
}
})
);

View File

@ -21,8 +21,8 @@ const DigestAuth = ({ collection }) => {
mode: 'digest',
collectionUid: collection.uid,
content: {
username: username,
password: digestAuth.password
username: username || '',
password: digestAuth.password || ''
}
})
);
@ -34,8 +34,8 @@ const DigestAuth = ({ collection }) => {
mode: 'digest',
collectionUid: collection.uid,
content: {
username: digestAuth.username,
password: password
username: digestAuth.username || '',
password: password || ''
}
})
);

View File

@ -28,9 +28,9 @@ const NTLMAuth = ({ collection }) => {
mode: 'ntlm',
collectionUid: collection.uid,
content: {
username: username,
password: ntlmAuth.password,
domain: ntlmAuth.domain
username: username || '',
password: ntlmAuth.password || '',
domain: ntlmAuth.domain || ''
}
})
@ -43,9 +43,9 @@ const NTLMAuth = ({ collection }) => {
mode: 'ntlm',
collectionUid: collection.uid,
content: {
username: ntlmAuth.username,
password: password,
domain: ntlmAuth.domain
username: ntlmAuth.username || '',
password: password || '',
domain: ntlmAuth.domain || ''
}
})
);
@ -57,9 +57,9 @@ const NTLMAuth = ({ collection }) => {
mode: 'ntlm',
collectionUid: collection.uid,
content: {
username: ntlmAuth.username,
password: ntlmAuth.password,
domain: domain
username: ntlmAuth.username || '',
password: ntlmAuth.password || '',
domain: domain || ''
}
})
);

View File

@ -7,6 +7,7 @@ import { updateCollectionAuth } from 'providers/ReduxStore/slices/collections/in
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';
const GrantTypeComponentMap = ({collection }) => {
@ -29,6 +30,9 @@ const GrantTypeComponentMap = ({collection }) => {
case 'client_credentials':
return <OAuth2ClientCredentials save={save} request={request} updateAuth={updateCollectionAuth} collection={collection} />;
break;
case 'implicit':
return <OAuth2Implicit save={save} request={request} updateAuth={updateCollectionAuth} collection={collection} />;
break;
default:
return <div>TBD</div>;
break;

View File

@ -21,8 +21,8 @@ const WsseAuth = ({ collection }) => {
mode: 'wsse',
collectionUid: collection.uid,
content: {
username,
password: wsseAuth.password
username: username || '',
password: wsseAuth.password || ''
}
})
);
@ -34,8 +34,8 @@ const WsseAuth = ({ collection }) => {
mode: 'wsse',
collectionUid: collection.uid,
content: {
username: wsseAuth.username,
password
username: wsseAuth.username || '',
password: password || ''
}
})
);

View File

@ -53,7 +53,7 @@ const Info = ({ collection }) => {
</div>
<div className="ml-4">
<div className="font-semibold text-sm">Requests</div>
<div className="mt-1 text-sm text-muted font-mono">
<div className="mt-1 text-sm text-muted">
{
isCollectionLoading? `${totalItems - itemsLoadingCount} out of ${totalItems} requests in the collection loaded` : `${totalRequestsInCollection} request${totalRequestsInCollection !== 1 ? 's' : ''} in collection`
}

View File

@ -53,6 +53,7 @@ const Script = ({ collection }) => {
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'bru']}
/>
</div>
<div className="flex-1 mt-6">
@ -66,6 +67,7 @@ const Script = ({ collection }) => {
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
/>
</div>

View File

@ -37,6 +37,7 @@ const Tests = ({ collection }) => {
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
/>
<div className="mt-6">

View File

@ -15,17 +15,9 @@ import Test from './Tests';
import Presets from './Presets';
import StyledWrapper from './StyledWrapper';
import Vars from './Vars/index';
import DotIcon from 'components/Icons/Dot';
import StatusDot from 'components/StatusDot';
import Overview from './Overview/index';
const ContentIndicator = () => {
return (
<sup className="ml-[.125rem] opacity-80 font-medium">
<DotIcon width="10"></DotIcon>
</sup>
);
};
const CollectionSettings = ({ collection }) => {
const dispatch = useDispatch();
const tab = collection.settingsSelectedTab;
@ -49,7 +41,7 @@ const CollectionSettings = ({ collection }) => {
const requestVars = get(collection, 'root.request.vars.req', []);
const responseVars = get(collection, 'root.request.vars.res', []);
const activeVarsCount = requestVars.filter((v) => v.enabled).length + responseVars.filter((v) => v.enabled).length;
const auth = get(collection, 'root.request.auth', {}).mode;
const authMode = get(collection, 'root.request.auth', {}).mode || 'none';
const proxyConfig = get(collection, 'brunoConfig.proxy', {});
const clientCertConfig = get(collection, 'brunoConfig.clientCertificates.certs', []);
@ -140,7 +132,7 @@ const CollectionSettings = ({ collection }) => {
};
return (
<StyledWrapper className="flex flex-col h-full relative px-4 py-4">
<StyledWrapper className="flex flex-col h-full relative px-4 py-4 overflow-scroll">
<div className="flex flex-wrap items-center tabs" role="tablist">
<div className={getTabClassname('overview')} role="tab" onClick={() => setTab('overview')}>
Overview
@ -155,29 +147,29 @@ const CollectionSettings = ({ collection }) => {
</div>
<div className={getTabClassname('auth')} role="tab" onClick={() => setTab('auth')}>
Auth
{auth !== 'none' && <ContentIndicator />}
{authMode !== 'none' && <StatusDot />}
</div>
<div className={getTabClassname('script')} role="tab" onClick={() => setTab('script')}>
Script
{hasScripts && <ContentIndicator />}
{hasScripts && <StatusDot />}
</div>
<div className={getTabClassname('tests')} role="tab" onClick={() => setTab('tests')}>
Tests
{hasTests && <ContentIndicator />}
{hasTests && <StatusDot />}
</div>
<div className={getTabClassname('presets')} role="tab" onClick={() => setTab('presets')}>
Presets
</div>
<div className={getTabClassname('proxy')} role="tab" onClick={() => setTab('proxy')}>
Proxy
{Object.keys(proxyConfig).length > 0 && <ContentIndicator />}
{Object.keys(proxyConfig).length > 0 && <StatusDot />}
</div>
<div className={getTabClassname('clientCert')} role="tab" onClick={() => setTab('clientCert')}>
Client Certificates
{clientCertConfig.length > 0 && <ContentIndicator />}
{clientCertConfig.length > 0 && <StatusDot />}
</div>
</div>
<section className="mt-4 h-full">{getTabPanel(tab)}</section>
<section className="mt-4 h-full overflow-scroll">{getTabPanel(tab)}</section>
</StyledWrapper>
);
};

View File

@ -0,0 +1,163 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
height: 100%;
background: ${(props) => props.theme.console.contentBg};
overflow: hidden;
.debug-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
background: ${(props) => props.theme.console.headerBg};
border-bottom: 1px solid ${(props) => props.theme.console.border};
flex-shrink: 0;
}
.debug-title {
display: flex;
align-items: center;
gap: 8px;
color: ${(props) => props.theme.console.titleColor};
font-size: 13px;
font-weight: 500;
.error-count {
color: ${(props) => props.theme.console.countColor};
font-size: 12px;
font-weight: 400;
}
}
.debug-controls {
display: flex;
align-items: center;
gap: 8px;
}
.control-button {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: transparent;
border: 1px solid ${(props) => props.theme.console.border};
border-radius: 4px;
color: ${(props) => props.theme.console.buttonColor};
cursor: pointer;
transition: all 0.2s ease;
}
.debug-content {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 0;
}
.debug-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: ${(props) => props.theme.console.emptyColor};
text-align: center;
gap: 8px;
padding: 40px 20px;
p {
margin: 0;
font-size: 14px;
font-weight: 500;
}
span {
font-size: 12px;
opacity: 0.7;
}
}
.errors-container {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
min-height: 0;
}
.errors-header {
display: grid;
grid-template-columns: 1fr 200px 120px;
gap: 12px;
padding: 8px 16px;
background: ${(props) => props.theme.console.headerBg};
border-bottom: 1px solid ${(props) => props.theme.console.border};
font-size: 11px;
font-weight: 600;
color: ${(props) => props.theme.console.titleColor};
text-transform: uppercase;
letter-spacing: 0.5px;
flex-shrink: 0;
}
.errors-list {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
min-height: 0;
}
.error-row {
display: grid;
grid-template-columns: 1fr 200px 120px;
gap: 12px;
padding: 8px 16px;
border-bottom: 1px solid ${(props) => props.theme.console.border};
cursor: pointer;
transition: background-color 0.1s ease;
font-size: 12px;
align-items: center;
&:hover {
background: ${(props) => props.theme.console.logHoverBg};
}
&.selected {
background: ${(props) => props.theme.console.buttonHoverBg};
border-left: 3px solid ${(props) => props.theme.console.checkboxColor};
}
}
.error-message {
color: ${(props) => props.theme.console.messageColor};
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
}
.error-location {
color: ${(props) => props.theme.console.messageColor};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: 11px;
}
.error-time {
color: ${(props) => props.theme.console.timestampColor};
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: 11px;
text-align: right;
}
`;
export default StyledWrapper;

View File

@ -0,0 +1,106 @@
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { IconBug } from '@tabler/icons';
import {
setSelectedError,
clearDebugErrors
} from 'providers/ReduxStore/slices/logs';
import StyledWrapper from './StyledWrapper';
const ErrorRow = ({ error, isSelected, onClick }) => {
const formatTime = (timestamp) => {
const date = new Date(timestamp);
return date.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
fractionalSecondDigits: 3
});
};
const getShortMessage = (message, maxLength = 80) => {
if (!message) return 'Unknown error';
return message.length > maxLength ? message.substring(0, maxLength) + '...' : message;
};
const getLocation = (error) => {
if (error.filename) {
const filename = error.filename.split('/').pop(); // Get just the filename
if (error.lineno && error.colno) {
return `${filename}:${error.lineno}:${error.colno}`;
} else if (error.lineno) {
return `${filename}:${error.lineno}`;
}
return filename;
}
return '-';
};
return (
<div
className={`error-row ${isSelected ? 'selected' : ''}`}
onClick={onClick}
>
<div className="error-message" title={error.message}>
{getShortMessage(error.message)}
</div>
<div className="error-location" title={error.filename}>
{getLocation(error)}
</div>
<div className="error-time">
{formatTime(error.timestamp)}
</div>
</div>
);
};
const DebugTab = () => {
const dispatch = useDispatch();
const { debugErrors, selectedError } = useSelector(state => state.logs);
const handleErrorClick = (error) => {
dispatch(setSelectedError(error));
};
const handleClearErrors = () => {
dispatch(clearDebugErrors());
};
return (
<StyledWrapper>
<div className="debug-content">
{debugErrors.length === 0 ? (
<div className="debug-empty">
<IconBug size={48} strokeWidth={1} />
<p>No errors</p>
<span>console.error() calls will appear here</span>
</div>
) : (
<div className="errors-container">
<div className="errors-header">
<div>Message</div>
<div>Location</div>
<div className="text-right">Time</div>
</div>
<div className="errors-list">
{debugErrors.map((error, index) => (
<ErrorRow
key={error.id}
error={error}
isSelected={selectedError?.id === error.id}
onClick={() => handleErrorClick(error)}
/>
))}
</div>
</div>
)}
</div>
</StyledWrapper>
);
};
export default DebugTab;

View File

@ -0,0 +1,228 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
height: 100%;
background: ${(props) => props.theme.console.contentBg};
border-left: 1px solid ${(props) => props.theme.console.border};
min-width: 400px;
max-width: 600px;
width: 40%;
overflow: hidden;
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
background: ${(props) => props.theme.console.headerBg};
border-bottom: 1px solid ${(props) => props.theme.console.border};
flex-shrink: 0;
}
.panel-title {
display: flex;
align-items: center;
gap: 8px;
color: ${(props) => props.theme.console.titleColor};
font-size: 13px;
font-weight: 500;
.error-time {
color: ${(props) => props.theme.console.countColor};
font-size: 11px;
font-weight: 400;
}
}
.close-button {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: transparent;
border: none;
border-radius: 4px;
color: ${(props) => props.theme.console.buttonColor};
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: ${(props) => props.theme.console.buttonHoverBg};
color: ${(props) => props.theme.console.buttonHoverColor};
}
}
.panel-tabs {
display: flex;
background: ${(props) => props.theme.console.headerBg};
border-bottom: 1px solid ${(props) => props.theme.console.border};
flex-shrink: 0;
}
.tab-button {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: ${(props) => props.theme.console.buttonColor};
cursor: pointer;
transition: all 0.2s ease;
font-size: 12px;
font-weight: 500;
&:hover {
background: ${(props) => props.theme.console.buttonHoverBg};
color: ${(props) => props.theme.console.buttonHoverColor};
}
&.active {
color: ${(props) => props.theme.console.checkboxColor};
border-bottom-color: ${(props) => props.theme.console.checkboxColor};
background: ${(props) => props.theme.console.contentBg};
}
}
.panel-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
background: ${(props) => props.theme.console.contentBg};
min-height: 0;
}
.tab-content {
padding: 16px;
height: 100%;
overflow-y: auto;
}
.section {
margin-bottom: 24px;
&:last-child {
margin-bottom: 0;
}
h4 {
margin: 0 0 12px 0;
font-size: 13px;
font-weight: 600;
color: ${(props) => props.theme.console.titleColor};
text-transform: uppercase;
letter-spacing: 0.5px;
}
}
.info-grid {
display: flex;
flex-direction: column;
gap: 12px;
}
.info-item {
display: flex;
flex-direction: column;
gap: 4px;
label {
font-size: 11px;
font-weight: 600;
color: ${(props) => props.theme.console.titleColor};
text-transform: uppercase;
letter-spacing: 0.5px;
}
span {
font-size: 12px;
color: ${(props) => props.theme.console.messageColor};
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
word-break: break-all;
line-height: 1.4;
}
}
.error-message-full {
color: ${(props) => props.theme.console.messageColor} !important;
background: ${(props) => props.theme.console.headerBg};
padding: 8px 12px;
border-radius: 4px;
border: 1px solid ${(props) => props.theme.console.border};
}
.file-path {
color: ${(props) => props.theme.console.checkboxColor} !important;
font-weight: 500 !important;
}
.report-section {
display: flex;
flex-direction: column;
gap: 12px;
p {
margin: 0;
font-size: 12px;
color: ${(props) => props.theme.console.messageColor};
line-height: 1.4;
}
}
.report-button {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: ${(props) => props.theme.console.buttonHoverBg};
border: 1px solid ${(props) => props.theme.console.border};
border-radius: 6px;
color: ${(props) => props.theme.console.buttonColor};
cursor: pointer;
transition: all 0.2s ease;
font-size: 12px;
font-weight: 500;
text-decoration: none;
align-self: flex-start;
&:hover {
background: ${(props) => props.theme.console.checkboxColor};
color: white;
border-color: ${(props) => props.theme.console.checkboxColor};
}
span {
font-family: inherit;
}
}
.stack-trace-container,
.arguments-container {
background: ${(props) => props.theme.console.headerBg};
border: 1px solid ${(props) => props.theme.console.border};
border-radius: 6px;
overflow: hidden;
}
.stack-trace,
.arguments {
margin: 0;
padding: 16px;
font-size: 11px;
line-height: 1.5;
color: ${(props) => props.theme.console.messageColor};
background: transparent;
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
white-space: pre-wrap;
word-break: break-word;
overflow-x: auto;
max-height: 400px;
overflow-y: auto;
}
`;
export default StyledWrapper;

View File

@ -0,0 +1,268 @@
import React, { useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import {
IconX,
IconBug,
IconFileText,
IconCode,
IconStack,
IconBrandGithub
} from '@tabler/icons';
import { clearSelectedError } from 'providers/ReduxStore/slices/logs';
import { useApp } from 'providers/App';
import platformLib from 'platform';
import StyledWrapper from './StyledWrapper';
const ErrorInfoTab = ({ error }) => {
const { version } = useApp();
const formatTimestamp = (timestamp) => {
const date = new Date(timestamp);
return date.toLocaleString();
};
const generateGitHubIssueUrl = () => {
const title = `Bug: ${error.message.substring(0, 50)}${error.message.length > 50 ? '...' : ''}`;
const body = `## Bug Report
### Error Details
- **Message**: ${error.message}
- **File**: ${error.filename || 'Unknown'}
- **Line**: ${error.lineno || 'Unknown'}:${error.colno || 'Unknown'}
- **Timestamp**: ${formatTimestamp(error.timestamp)}
### Environment
- **Bruno Version**: ${version}
- **OS**: ${platformLib.os.family} ${platformLib.os.version || ''}
- **Browser**: ${platformLib.name} ${platformLib.version || ''}
### Stack Trace
\`\`\`
${error.stack || 'No stack trace available'}
\`\`\`
### Arguments
\`\`\`
${error.args ? error.args.map((arg, index) => {
if (arg && typeof arg === 'object' && arg.__type === 'Error') {
return `[${index}]: Error: ${arg.message}`;
}
return `[${index}]: ${typeof arg === 'object' ? JSON.stringify(arg, null, 2) : String(arg)}`;
}).join('\n') : 'No arguments'}
\`\`\`
### Steps to Reproduce
1.
2.
3.
### Expected Behavior
### Additional Context
`;
const encodedTitle = encodeURIComponent(title);
const encodedBody = encodeURIComponent(body);
return `https://github.com/usebruno/bruno/issues/new?template=BLANK_ISSUE&title=${encodedTitle}&body=${encodedBody}`;
};
const handleReportIssue = () => {
const url = generateGitHubIssueUrl();
window.open(url, '_blank');
};
return (
<div className="tab-content">
<div className="section">
<h4>Error Information</h4>
<div className="info-grid">
<div className="info-item">
<label>Message:</label>
<span className="error-message-full">{error.message || 'No message available'}</span>
</div>
{error.filename && (
<div className="info-item">
<label>File:</label>
<span className="file-path">{error.filename}</span>
</div>
)}
{error.lineno && (
<div className="info-item">
<label>Line:</label>
<span>{error.lineno}{error.colno ? `:${error.colno}` : ''}</span>
</div>
)}
<div className="info-item">
<label>Timestamp:</label>
<span>{formatTimestamp(error.timestamp)}</span>
</div>
</div>
</div>
<div className="section">
<h4>Report Issue</h4>
<div className="report-section">
<p>Found a bug? Help us improve Bruno by reporting this error on GitHub.</p>
<button
className="report-button"
onClick={handleReportIssue}
title="Report this error on GitHub"
>
<IconBrandGithub size={16} strokeWidth={1.5} />
<span>Report Issue on GitHub</span>
</button>
</div>
</div>
</div>
);
};
const StackTraceTab = ({ error }) => {
const formatStackTrace = (stack) => {
if (!stack) return 'Stack trace not available';
return stack
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0)
.join('\n');
};
return (
<div className="tab-content">
<div className="section">
<h4>Stack Trace</h4>
<div className="stack-trace-container">
<pre className="stack-trace">
{formatStackTrace(error.stack)}
</pre>
</div>
</div>
</div>
);
};
const ArgumentsTab = ({ error }) => {
const formatArguments = (args) => {
if (!args || args.length === 0) return 'No arguments available';
try {
return args.map((arg, index) => {
// Handle special Error object format
if (arg && typeof arg === 'object' && arg.__type === 'Error') {
return `[${index}]: Error: ${arg.message}\n Name: ${arg.name}\n Stack: ${arg.stack || 'No stack trace'}`;
}
if (typeof arg === 'object' && arg !== null) {
return `[${index}]: ${JSON.stringify(arg, null, 2)}`;
}
return `[${index}]: ${String(arg)}`;
}).join('\n\n');
} catch (e) {
return 'Arguments could not be formatted';
}
};
return (
<div className="tab-content">
<div className="section">
<h4>Arguments</h4>
<div className="arguments-container">
<pre className="arguments">
{formatArguments(error.args)}
</pre>
</div>
</div>
</div>
);
};
const ErrorDetailsPanel = () => {
const dispatch = useDispatch();
const { selectedError } = useSelector(state => state.logs);
const [activeTab, setActiveTab] = useState('info');
if (!selectedError) return null;
const handleClose = () => {
dispatch(clearSelectedError());
};
const formatTime = (timestamp) => {
const date = new Date(timestamp);
return date.toLocaleString();
};
const getTabContent = () => {
switch (activeTab) {
case 'info':
return <ErrorInfoTab error={selectedError} />;
case 'stack':
return <StackTraceTab error={selectedError} />;
case 'args':
return <ArgumentsTab error={selectedError} />;
default:
return <ErrorInfoTab error={selectedError} />;
}
};
return (
<StyledWrapper>
<div className="panel-header">
<div className="panel-title">
<IconBug size={16} strokeWidth={1.5} />
<span>Error Details</span>
<span className="error-time">({formatTime(selectedError.timestamp)})</span>
</div>
<button
className="close-button"
onClick={handleClose}
title="Close details panel"
>
<IconX size={16} strokeWidth={1.5} />
</button>
</div>
<div className="panel-tabs">
<button
className={`tab-button ${activeTab === 'info' ? 'active' : ''}`}
onClick={() => setActiveTab('info')}
>
<IconFileText size={14} strokeWidth={1.5} />
Info
</button>
<button
className={`tab-button ${activeTab === 'stack' ? 'active' : ''}`}
onClick={() => setActiveTab('stack')}
>
<IconStack size={14} strokeWidth={1.5} />
Stack
</button>
<button
className={`tab-button ${activeTab === 'args' ? 'active' : ''}`}
onClick={() => setActiveTab('args')}
>
<IconCode size={14} strokeWidth={1.5} />
Args
</button>
</div>
<div className="panel-content">
{getTabContent()}
</div>
</StyledWrapper>
);
};
export default ErrorDetailsPanel;

View File

@ -0,0 +1,293 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
height: 100%;
background: ${(props) => props.theme.console.contentBg};
overflow: hidden;
.network-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
background: ${(props) => props.theme.console.headerBg};
border-bottom: 1px solid ${(props) => props.theme.console.border};
flex-shrink: 0;
}
.network-title {
display: flex;
align-items: center;
gap: 8px;
color: ${(props) => props.theme.console.titleColor};
font-size: 13px;
font-weight: 500;
.request-count {
color: ${(props) => props.theme.console.countColor};
font-size: 12px;
font-weight: 400;
}
}
.network-controls {
display: flex;
align-items: center;
gap: 8px;
}
.network-content {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 0; /* Important for proper flex behavior */
}
.network-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: ${(props) => props.theme.console.emptyColor};
text-align: center;
gap: 8px;
padding: 40px 20px;
p {
margin: 0;
font-size: 14px;
font-weight: 500;
}
span {
font-size: 12px;
opacity: 0.7;
}
}
.requests-container {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
min-height: 0; /* Important for proper flex behavior */
}
.requests-header {
display: grid;
grid-template-columns: 80px 80px 150px 1fr 100px 80px 80px;
gap: 12px;
padding: 8px 16px;
background: ${(props) => props.theme.console.headerBg};
border-bottom: 1px solid ${(props) => props.theme.console.border};
font-size: 11px;
font-weight: 600;
color: ${(props) => props.theme.console.titleColor};
text-transform: uppercase;
letter-spacing: 0.5px;
flex-shrink: 0;
}
.requests-list {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
min-height: 0; /* Important for proper scrolling */
}
.request-row {
display: grid;
grid-template-columns: 80px 80px 150px 1fr 100px 80px 80px;
gap: 12px;
padding: 6px 16px;
border-bottom: 1px solid ${(props) => props.theme.console.border};
cursor: pointer;
transition: background-color 0.1s ease;
font-size: 12px;
align-items: center;
&:hover {
background: ${(props) => props.theme.console.logHoverBg};
}
&.selected {
background: ${(props) => props.theme.console.buttonHoverBg};
border-left: 3px solid ${(props) => props.theme.console.checkboxColor};
}
}
.method-badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
color: white;
text-transform: uppercase;
letter-spacing: 0.5px;
min-width: 45px;
}
.status-badge {
font-weight: 600;
font-size: 12px;
}
.request-domain {
color: ${(props) => props.theme.console.messageColor};
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.request-path {
color: ${(props) => props.theme.console.messageColor};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
}
.request-time {
color: ${(props) => props.theme.console.timestampColor};
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: 11px;
}
.request-duration {
color: ${(props) => props.theme.console.messageColor};
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: 11px;
text-align: right;
}
.request-size {
color: ${(props) => props.theme.console.messageColor};
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: 11px;
text-align: right;
}
.filter-dropdown {
position: relative;
}
.filter-dropdown-trigger {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
background: transparent;
border: 1px solid ${(props) => props.theme.console.border};
border-radius: 4px;
color: ${(props) => props.theme.console.buttonColor};
cursor: pointer;
transition: all 0.2s ease;
font-size: 12px;
&:hover {
background: ${(props) => props.theme.console.buttonHoverBg};
color: ${(props) => props.theme.console.buttonHoverColor};
}
.filter-summary {
font-weight: 500;
min-width: 24px;
text-align: center;
}
}
.filter-dropdown-menu {
position: absolute;
top: calc(100% + 4px);
right: 0;
min-width: 200px;
max-width: 250px;
background: ${(props) => props.theme.console.dropdownBg};
border: 1px solid ${(props) => props.theme.console.border};
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 1000;
overflow: hidden;
}
.filter-dropdown-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: ${(props) => props.theme.console.dropdownHeaderBg};
border-bottom: 1px solid ${(props) => props.theme.console.border};
font-size: 12px;
font-weight: 500;
color: ${(props) => props.theme.console.titleColor};
}
.filter-toggle-all {
background: transparent;
border: none;
color: ${(props) => props.theme.console.buttonColor};
cursor: pointer;
font-size: 11px;
font-weight: 500;
padding: 2px 4px;
border-radius: 2px;
transition: all 0.2s ease;
&:hover {
background: ${(props) => props.theme.console.buttonHoverBg};
}
}
.filter-dropdown-options {
padding: 4px 0;
}
.filter-option {
display: flex;
align-items: center;
padding: 6px 12px;
cursor: pointer;
transition: background-color 0.2s ease;
&:hover {
background: ${(props) => props.theme.console.optionHoverBg};
}
input[type="checkbox"] {
margin: 0 8px 0 0;
width: 14px;
height: 14px;
accent-color: ${(props) => props.theme.console.checkboxColor};
}
}
.filter-option-content {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.filter-option-label {
color: ${(props) => props.theme.console.optionLabelColor};
font-size: 12px;
font-weight: 400;
}
.filter-option-count {
color: ${(props) => props.theme.console.optionCountColor};
font-size: 11px;
font-weight: 400;
margin-left: auto;
}
`;
export default StyledWrapper;

View File

@ -0,0 +1,302 @@
import React, { useState, useRef, useEffect, useMemo } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import {
IconFilter,
IconChevronDown,
IconNetwork,
} from '@tabler/icons';
import {
updateNetworkFilter,
toggleAllNetworkFilters,
setSelectedRequest
} from 'providers/ReduxStore/slices/logs';
import StyledWrapper from './StyledWrapper';
const MethodBadge = ({ method }) => {
const getMethodColor = (method) => {
switch (method?.toUpperCase()) {
case 'GET': return '#10b981';
case 'POST': return '#8b5cf6';
case 'PUT': return '#f59e0b';
case 'DELETE': return '#ef4444';
case 'PATCH': return '#06b6d4';
case 'HEAD': return '#6b7280';
case 'OPTIONS': return '#84cc16';
default: return '#6b7280';
}
};
return (
<span
className="method-badge"
style={{ backgroundColor: getMethodColor(method) }}
>
{method?.toUpperCase() || 'GET'}
</span>
);
};
const StatusBadge = ({ status, statusCode }) => {
const getStatusColor = (code) => {
if (code >= 200 && code < 300) return '#10b981';
if (code >= 300 && code < 400) return '#f59e0b';
if (code >= 400 && code < 500) return '#ef4444';
if (code >= 500) return '#dc2626';
return '#6b7280';
};
const displayStatus = statusCode || status;
return (
<span
className="status-badge"
style={{ color: getStatusColor(statusCode) }}
>
{displayStatus}
</span>
);
};
const NetworkFilterDropdown = ({ filters, requestCounts, onFilterToggle, onToggleAll }) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef(null);
const allFiltersEnabled = Object.values(filters).every(f => f);
const activeFilters = Object.entries(filters).filter(([_, enabled]) => enabled);
useEffect(() => {
const handleClickOutside = (event) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
return (
<div className="filter-dropdown" ref={dropdownRef}>
<button
className="filter-dropdown-trigger"
onClick={() => setIsOpen(!isOpen)}
title="Filter requests by method"
>
<IconFilter size={16} strokeWidth={1.5} />
<span className="filter-summary">
{activeFilters.length === Object.keys(filters).length ? 'All' : `${activeFilters.length}/${Object.keys(filters).length}`}
</span>
<IconChevronDown size={14} strokeWidth={1.5} />
</button>
{isOpen && (
<div className={`filter-dropdown-menu right`}>
<div className="filter-dropdown-header">
<span>Filter by Method</span>
<button
className="filter-toggle-all"
onClick={() => onToggleAll(!allFiltersEnabled)}
>
{allFiltersEnabled ? 'Hide All' : 'Show All'}
</button>
</div>
<div className="filter-dropdown-options">
{Object.keys(filters).map(method => (
<label key={method} className="filter-option">
<input
type="checkbox"
checked={filters[method]}
onChange={(e) => onFilterToggle(method, e.target.checked)}
/>
<div className="filter-option-content">
<MethodBadge method={method} />
<span className="filter-option-label">{method}</span>
<span className="filter-option-count">({requestCounts[method] || 0})</span>
</div>
</label>
))}
</div>
</div>
)}
</div>
);
};
const RequestRow = ({ request, isSelected, onClick }) => {
const { data } = request;
const { request: req, response: res, timestamp } = data;
const formatTime = (timestamp) => {
const date = new Date(timestamp);
return date.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
fractionalSecondDigits: 3
});
};
const formatDuration = (duration) => {
if (!duration) return '-';
if (duration < 1000) return `${Math.round(duration)}ms`;
return `${(duration / 1000).toFixed(2)}s`;
};
const formatSize = (size) => {
if (!size) return '-';
if (size < 1024) return `${size}B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)}KB`;
return `${(size / (1024 * 1024)).toFixed(1)}MB`;
};
const getUrl = () => {
return req?.url || 'Unknown URL';
};
const getDomain = () => {
try {
const url = new URL(getUrl());
return url.hostname;
} catch {
return getUrl();
}
};
const getPath = () => {
try {
const url = new URL(getUrl());
return url.pathname + url.search;
} catch {
return getUrl();
}
};
return (
<div
className={`request-row ${isSelected ? 'selected' : ''}`}
onClick={onClick}
>
<div className="request-method">
<MethodBadge method={req?.method} />
</div>
<div className="request-status">
<StatusBadge status={res?.status} statusCode={res?.statusCode} />
</div>
<div className="request-domain" title={getDomain()}>
{getDomain()}
</div>
<div className="request-path" title={getPath()}>
{getPath()}
</div>
<div className="request-time">
{formatTime(timestamp)}
</div>
<div className="request-duration">
{formatDuration(res?.duration)}
</div>
<div className="request-size">
{formatSize(res?.size)}
</div>
</div>
);
};
const NetworkTab = () => {
const dispatch = useDispatch();
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
.filter(entry => entry.type === 'request')
.forEach(entry => {
requests.push({
...entry,
collectionName: collection.name,
collectionUid: collection.uid
});
});
}
});
return requests.sort((a, b) => a.timestamp - b.timestamp);
}, [collections]);
const filteredRequests = useMemo(() => {
return allRequests.filter(request => {
const method = request.data?.request?.method?.toUpperCase() || 'GET';
return networkFilters[method];
});
}, [allRequests, networkFilters]);
const requestCounts = useMemo(() => {
return allRequests.reduce((counts, request) => {
const method = request.data?.request?.method?.toUpperCase() || 'GET';
counts[method] = (counts[method] || 0) + 1;
return counts;
}, {});
}, [allRequests]);
const handleFilterToggle = (method, enabled) => {
dispatch(updateNetworkFilter({ method, enabled }));
};
const handleToggleAllFilters = (enabled) => {
dispatch(toggleAllNetworkFilters(enabled));
};
const handleRequestClick = (request) => {
dispatch(setSelectedRequest(request));
};
return (
<StyledWrapper>
<div className="network-content">
{filteredRequests.length === 0 ? (
<div className="network-empty">
<IconNetwork size={48} strokeWidth={1} />
<p>No network requests</p>
<span>Requests will appear here as you make API calls</span>
</div>
) : (
<div className="requests-container">
<div className="requests-header">
<div>Method</div>
<div>Status</div>
<div>Domain</div>
<div>Path</div>
<div>Time</div>
<div className="text-right">Duration</div>
<div className="text-right">Size</div>
</div>
<div className="requests-list">
{filteredRequests.map((request, index) => (
<RequestRow
key={`${request.collectionUid}-${request.itemUid}-${request.timestamp}-${index}`}
request={request}
isSelected={selectedRequest?.timestamp === request.timestamp && selectedRequest?.itemUid === request.itemUid}
onClick={() => handleRequestClick(request)}
/>
))}
</div>
</div>
)}
</div>
</StyledWrapper>
);
};
export default NetworkTab;

View File

@ -0,0 +1,347 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
height: 100%;
background: ${(props) => props.theme.console.contentBg};
border-left: 1px solid ${(props) => props.theme.console.border};
min-width: 400px;
max-width: 600px;
width: 40%;
overflow: hidden;
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
background: ${(props) => props.theme.console.headerBg};
border-bottom: 1px solid ${(props) => props.theme.console.border};
flex-shrink: 0;
}
.panel-title {
display: flex;
align-items: center;
gap: 8px;
color: ${(props) => props.theme.console.titleColor};
font-size: 13px;
font-weight: 500;
.request-time {
color: ${(props) => props.theme.console.countColor};
font-size: 11px;
font-weight: 400;
}
}
.close-button {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: transparent;
border: none;
border-radius: 4px;
color: ${(props) => props.theme.console.buttonColor};
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: ${(props) => props.theme.console.buttonHoverBg};
color: ${(props) => props.theme.console.buttonHoverColor};
}
}
.panel-tabs {
display: flex;
background: ${(props) => props.theme.console.headerBg};
border-bottom: 1px solid ${(props) => props.theme.console.border};
flex-shrink: 0;
}
.tab-button {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: ${(props) => props.theme.console.buttonColor};
cursor: pointer;
transition: all 0.2s ease;
font-size: 12px;
font-weight: 500;
&:hover {
background: ${(props) => props.theme.console.buttonHoverBg};
color: ${(props) => props.theme.console.buttonHoverColor};
}
&.active {
color: ${(props) => props.theme.console.checkboxColor};
border-bottom-color: ${(props) => props.theme.console.checkboxColor};
background: ${(props) => props.theme.console.contentBg};
}
}
.panel-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 16px;
min-height: 0;
height: 0;
}
.tab-content {
display: flex;
flex-direction: column;
gap: 20px;
min-height: min-content;
}
.section {
display: flex;
flex-direction: column;
gap: 8px;
h4 {
margin: 0;
font-size: 13px;
font-weight: 600;
color: ${(props) => props.theme.console.titleColor};
padding-bottom: 4px;
border-bottom: 1px solid ${(props) => props.theme.console.border};
}
}
.info-grid {
display: flex;
flex-direction: column;
gap: 8px;
}
.info-item {
display: flex;
flex-direction: column;
gap: 2px;
.label {
font-size: 11px;
font-weight: 600;
color: ${(props) => props.theme.console.countColor};
text-transform: uppercase;
letter-spacing: 0.5px;
}
.value {
font-size: 12px;
color: ${(props) => props.theme.console.messageColor};
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
word-break: break-all;
padding: 4px 8px;
background: ${(props) => props.theme.console.headerBg};
border-radius: 4px;
border: 1px solid ${(props) => props.theme.console.border};
}
}
.headers-table,
.timeline-table {
overflow: auto;
border-radius: 4px;
border: 1px solid ${(props) => props.theme.console.border};
max-height: 300px;
table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
background: ${(props) => props.theme.console.headerBg};
thead {
background: ${(props) => props.theme.console.dropdownHeaderBg};
position: sticky;
top: 0;
z-index: 10;
td {
padding: 8px 12px;
font-weight: 600;
color: ${(props) => props.theme.console.titleColor};
text-transform: uppercase;
font-size: 11px;
letter-spacing: 0.5px;
border-bottom: 1px solid ${(props) => props.theme.console.border};
}
}
tbody {
tr {
border-bottom: 1px solid ${(props) => props.theme.console.border};
&:last-child {
border-bottom: none;
}
&:nth-child(odd) {
background: ${(props) => props.theme.console.contentBg};
}
&:hover {
background: ${(props) => props.theme.console.logHoverBg};
}
}
td {
padding: 8px 12px;
vertical-align: top;
word-break: break-word;
}
}
}
}
.header-name,
.timeline-phase {
color: ${(props) => props.theme.console.countColor};
font-weight: 600;
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
min-width: 120px;
}
.header-value,
.timeline-message {
color: ${(props) => props.theme.console.messageColor};
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
word-break: break-all;
}
.timeline-duration {
color: ${(props) => props.theme.console.timestampColor};
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
text-align: right;
min-width: 80px;
}
.code-block {
background: ${(props) => props.theme.console.headerBg};
border: 1px solid ${(props) => props.theme.console.border};
border-radius: 4px;
padding: 12px;
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: 11px;
line-height: 1.4;
color: ${(props) => props.theme.console.messageColor};
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
max-height: 400px;
margin: 0;
}
.empty-state {
padding: 12px;
text-align: center;
color: ${(props) => props.theme.console.emptyColor};
font-style: italic;
font-size: 12px;
background: ${(props) => props.theme.console.headerBg};
border: 1px solid ${(props) => props.theme.console.border};
border-radius: 4px;
}
.response-body-container {
border: 1px solid ${(props) => props.theme.console.border};
border-radius: 4px;
overflow: hidden;
background: ${(props) => props.theme.console.headerBg};
height: 400px;
display: flex;
flex-direction: column;
.w-full.h-full.relative.flex {
height: 100% !important;
width: 100% !important;
background: ${(props) => props.theme.console.headerBg} !important;
display: flex !important;
flex-direction: column !important;
}
div[role="tablist"] {
background: ${(props) => props.theme.console.dropdownHeaderBg};
padding: 8px 12px;
border-bottom: 1px solid ${(props) => props.theme.console.border};
display: flex !important;
gap: 8px !important;
flex-wrap: wrap !important;
align-items: center !important;
min-height: 40px !important;
flex-shrink: 0 !important;
> div {
color: ${(props) => props.theme.console.buttonColor};
font-size: 12px !important;
padding: 6px 12px !important;
border-radius: 4px;
transition: all 0.2s ease;
cursor: pointer;
border: 1px solid ${(props) => props.theme.console.border};
background: ${(props) => props.theme.console.contentBg};
white-space: nowrap !important;
min-width: auto !important;
height: auto !important;
line-height: 1.2 !important;
font-weight: 500 !important;
&:hover {
background: ${(props) => props.theme.console.buttonHoverBg};
color: ${(props) => props.theme.console.buttonHoverColor};
border-color: ${(props) => props.theme.console.buttonHoverBg};
}
&.active {
background: ${(props) => props.theme.console.checkboxColor};
color: white;
border-color: ${(props) => props.theme.console.checkboxColor};
}
}
}
.response-filter {
position: absolute !important;
bottom: 8px !important;
right: 8px !important;
left: 8px !important;
z-index: 10 !important;
}
}
.network-logs-container {
border: 1px solid ${(props) => props.theme.console.border};
border-radius: 4px;
overflow: hidden;
background: ${(props) => props.theme.console.headerBg};
min-height: 200px;
max-height: 400px;
.network-logs {
background: ${(props) => props.theme.console.contentBg} !important;
color: ${(props) => props.theme.console.messageColor} !important;
height: 100% !important;
max-height: 400px !important;
pre {
color: ${(props) => props.theme.console.messageColor} !important;
font-size: 11px !important;
line-height: 1.4 !important;
padding: 12px !important;
}
}
}
`;
export default StyledWrapper;

View File

@ -0,0 +1,242 @@
import React, { useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import {
IconX,
IconFileText,
IconArrowRight,
IconNetwork
} from '@tabler/icons';
import { clearSelectedRequest } from 'providers/ReduxStore/slices/logs';
import QueryResult from 'components/ResponsePane/QueryResult';
import Network from 'components/ResponsePane/Timeline/TimelineItem/Network';
import StyledWrapper from './StyledWrapper';
import { uuid } from 'utils/common/index';
const RequestTab = ({ request, response }) => {
const formatHeaders = (headers) => {
if (!headers) return [];
if (Array.isArray(headers)) return headers;
return Object.entries(headers).map(([key, value]) => ({ name: key, value }));
};
const formatBody = (body) => {
if (!body) return 'No body';
if (typeof body === 'string') return body;
return JSON.stringify(body, null, 2);
};
return (
<div className="tab-content">
<div className="section">
<h4>General</h4>
<div className="info-grid">
<div className="info-item">
<span className="label">Request URL:</span>
<span className="value">{request?.url || 'N/A'}</span>
</div>
<div className="info-item">
<span className="label">Request Method:</span>
<span className="value">{request?.method || 'GET'}</span>
</div>
</div>
</div>
<div className="section">
<h4>Request Headers</h4>
{formatHeaders(request?.headers).length > 0 ? (
<div className="headers-table">
<table>
<thead>
<tr>
<td>Name</td>
<td>Value</td>
</tr>
</thead>
<tbody>
{formatHeaders(request.headers).map((header, index) => (
<tr key={index}>
<td className="header-name">{header.name}</td>
<td className="header-value">{header.value}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="empty-state">No headers</div>
)}
</div>
{request?.body && (
<div className="section">
<h4>Request Body</h4>
<pre className="code-block">{formatBody(request.body)}</pre>
</div>
)}
</div>
);
};
const ResponseTab = ({ response, request, collection }) => {
const formatHeaders = (headers) => {
if (!headers) return [];
if (Array.isArray(headers)) return headers;
return Object.entries(headers).map(([key, value]) => ({ name: key, value }));
};
return (
<div className="tab-content">
<div className="section">
<h4>Response Headers</h4>
{formatHeaders(response?.headers).length > 0 ? (
<div className="headers-table">
<table>
<thead>
<tr>
<td>Name</td>
<td>Value</td>
</tr>
</thead>
<tbody>
{formatHeaders(response.headers).map((header, index) => (
<tr key={index}>
<td className="header-name">{header.name}</td>
<td className="header-value">{header.value}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="empty-state">No headers</div>
)}
</div>
<div className="section">
<h4>Response Body</h4>
<div className="response-body-container">
{response?.data || response?.dataBuffer ? (
<QueryResult
item={{ uid: uuid()}}
collection={collection}
data={response.data}
dataBuffer={response.dataBuffer}
headers={response.headers}
error={response.error}
disableRunEventListener={true}
/>
) : (
<div className="empty-state">No response data</div>
)}
</div>
</div>
</div>
);
};
const NetworkTab = ({ response }) => {
const timeline = response?.timeline || [];
return (
<div className="tab-content">
<div className="section">
<h4>Network Logs</h4>
<div className="network-logs-container">
{timeline.length > 0 ? (
<Network logs={timeline} />
) : (
<div className="empty-state">No network logs available</div>
)}
</div>
</div>
</div>
);
};
const RequestDetailsPanel = () => {
const dispatch = useDispatch();
const { selectedRequest } = useSelector(state => state.logs);
const collections = useSelector(state => state.collections.collections);
const [activeTab, setActiveTab] = useState('request');
if (!selectedRequest) return null;
const { data } = selectedRequest;
const { request, response } = data;
const collection = collections.find(c => c.uid === selectedRequest.collectionUid);
const handleClose = () => {
dispatch(clearSelectedRequest());
};
const formatTime = (timestamp) => {
const date = new Date(timestamp);
return date.toLocaleString();
};
const getTabContent = () => {
switch (activeTab) {
case 'request':
return <RequestTab request={request} response={response} />;
case 'response':
return <ResponseTab response={response} request={request} collection={collection} />;
case 'network':
return <NetworkTab response={response} />;
default:
return <RequestTab request={request} response={response} />;
}
};
return (
<StyledWrapper>
<div className="panel-header">
<div className="panel-title">
<IconFileText size={16} strokeWidth={1.5} />
<span>Request Details</span>
<span className="request-time">({formatTime(selectedRequest.timestamp)})</span>
</div>
<button
className="close-button"
onClick={handleClose}
title="Close details panel"
>
<IconX size={16} strokeWidth={1.5} />
</button>
</div>
<div className="panel-tabs">
<button
className={`tab-button ${activeTab === 'request' ? 'active' : ''}`}
onClick={() => setActiveTab('request')}
>
<IconArrowRight size={14} strokeWidth={1.5} />
Request
</button>
<button
className={`tab-button ${activeTab === 'response' ? 'active' : ''}`}
onClick={() => setActiveTab('response')}
>
<IconFileText size={14} strokeWidth={1.5} />
Response
</button>
<button
className={`tab-button ${activeTab === 'network' ? 'active' : ''}`}
onClick={() => setActiveTab('network')}
>
<IconNetwork size={14} strokeWidth={1.5} />
Network
</button>
</div>
<div className="panel-content">
{getTabContent()}
</div>
</StyledWrapper>
);
};
export default RequestDetailsPanel;

View File

@ -0,0 +1,520 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
width: 100%;
height: 100%;
background: ${(props) => props.theme.console.bg};
border-top: 1px solid ${(props) => props.theme.console.border};
display: flex;
flex-direction: column;
overflow: hidden;
.console-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
background: ${(props) => props.theme.console.headerBg};
border-bottom: 1px solid ${(props) => props.theme.console.border};
flex-shrink: 0;
position: relative;
}
.console-tabs {
display: flex;
align-items: center;
gap: 2px;
}
.console-tab {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: ${(props) => props.theme.console.buttonColor};
cursor: pointer;
transition: all 0.2s ease;
font-size: 12px;
font-weight: 500;
border-radius: 4px 4px 0 0;
&:hover {
background: ${(props) => props.theme.console.buttonHoverBg};
color: ${(props) => props.theme.console.buttonHoverColor};
}
&.active {
color: ${(props) => props.theme.console.checkboxColor};
border-bottom-color: ${(props) => props.theme.console.checkboxColor};
background: ${(props) => props.theme.console.contentBg};
}
}
.console-controls {
display: flex;
align-items: center;
gap: 4px;
}
.console-content {
flex: 1;
overflow: hidden;
background: ${(props) => props.theme.console.contentBg};
min-height: 0;
display: flex;
flex-direction: column;
}
.tab-content {
display: flex;
flex-direction: column;
height: 100%;
}
.tab-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
background: ${(props) => props.theme.console.headerBg};
border-bottom: 1px solid ${(props) => props.theme.console.border};
flex-shrink: 0;
}
.tab-title {
display: flex;
align-items: center;
gap: 8px;
color: ${(props) => props.theme.console.titleColor};
font-size: 13px;
font-weight: 500;
.log-count {
color: ${(props) => props.theme.console.countColor};
font-size: 12px;
font-weight: 400;
}
}
.tab-controls {
display: flex;
align-items: center;
gap: 8px;
}
.tab-content-area {
flex: 1;
overflow-y: auto;
background: ${(props) => props.theme.console.contentBg};
min-height: 0;
}
.network-with-details {
display: flex;
height: 100%;
overflow: hidden;
}
.network-main {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
min-width: 0;
}
.debug-with-details {
display: flex;
height: 100%;
overflow: hidden;
}
.debug-main {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
min-width: 0;
}
.filter-controls {
display: flex;
align-items: center;
gap: 4px;
margin-right: 8px;
padding-right: 8px;
border-right: 1px solid ${(props) => props.theme.console.border};
}
.action-controls {
display: flex;
align-items: center;
gap: 4px;
}
.control-button {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: transparent;
border: none;
border-radius: 4px;
color: ${(props) => props.theme.console.buttonColor};
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background: ${(props) => props.theme.console.buttonHoverBg};
color: ${(props) => props.theme.console.buttonHoverColor};
}
&.close-button:hover {
background: #e81123;
color: white;
}
}
.filter-dropdown {
position: relative;
}
.filter-dropdown-trigger {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
background: transparent;
border: 1px solid ${(props) => props.theme.console.border};
border-radius: 4px;
color: ${(props) => props.theme.console.buttonColor};
cursor: pointer;
transition: all 0.2s ease;
font-size: 12px;
&:hover {
background: ${(props) => props.theme.console.buttonHoverBg};
color: ${(props) => props.theme.console.buttonHoverColor};
border-color: ${(props) => props.theme.console.border};
}
.filter-summary {
font-weight: 500;
min-width: 24px;
text-align: center;
}
}
.filter-dropdown-menu {
position: absolute;
top: calc(100% + 4px);
left: 0;
min-width: 200px;
max-width: 250px;
background: ${(props) => props.theme.console.dropdownBg};
border: 1px solid ${(props) => props.theme.console.border};
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 1000;
overflow: hidden;
&.right {
left: auto;
right: 0;
}
}
.filter-dropdown-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: ${(props) => props.theme.console.dropdownHeaderBg};
border-bottom: 1px solid ${(props) => props.theme.console.border};
font-size: 12px;
font-weight: 500;
color: ${(props) => props.theme.console.titleColor};
}
.filter-toggle-all {
background: transparent;
border: none;
color: ${(props) => props.theme.console.buttonColor};
cursor: pointer;
font-size: 11px;
font-weight: 500;
padding: 2px 4px;
border-radius: 2px;
transition: all 0.2s ease;
&:hover {
background: ${(props) => props.theme.console.buttonHoverBg};
}
}
.filter-dropdown-options {
padding: 4px 0;
}
.filter-option {
display: flex;
align-items: center;
padding: 6px 12px;
cursor: pointer;
transition: background-color 0.2s ease;
&:hover {
background: ${(props) => props.theme.console.optionHoverBg};
}
input[type="checkbox"] {
margin: 0 8px 0 0;
width: 14px;
height: 14px;
accent-color: ${(props) => props.theme.console.checkboxColor};
}
}
.filter-option-content {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
}
.filter-option-label {
color: ${(props) => props.theme.console.optionLabelColor};
font-size: 12px;
font-weight: 400;
}
.filter-option-count {
color: ${(props) => props.theme.console.optionCountColor};
font-size: 11px;
font-weight: 400;
margin-left: auto;
}
.console-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: ${(props) => props.theme.console.emptyColor};
text-align: center;
gap: 8px;
padding: 40px 20px;
p {
margin: 0;
font-size: 14px;
font-weight: 500;
}
span {
font-size: 12px;
opacity: 0.7;
}
}
.logs-container {
padding: 8px 0;
}
.method-badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
color: white;
text-transform: uppercase;
letter-spacing: 0.5px;
min-width: 45px;
}
.log-entry {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 4px 16px;
font-family: ui-monospace, 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: 12px;
line-height: 1.4;
border-left: 2px solid transparent;
transition: background-color 0.1s ease;
&:hover {
background: ${(props) => props.theme.console.logHoverBg};
}
&.error {
border-left-color: #f14c4c;
.log-level {
background: #f14c4c;
color: white;
}
.log-icon {
color: #f14c4c;
}
}
&.warn {
border-left-color: #ffcc02;
.log-level {
background: #ffcc02;
color: #000;
}
.log-icon {
color: #ffcc02;
}
}
&.info {
border-left-color: #0078d4;
.log-level {
background: #0078d4;
color: white;
}
.log-icon {
color: #0078d4;
}
}
&.debug {
border-left-color: #9b59b6;
.log-level {
background: #9b59b6;
color: white;
}
.log-icon {
color: #9b59b6;
}
}
&.log {
border-left-color: #6a6a6a;
.log-level {
background: #6a6a6a;
color: white;
}
.log-icon {
color: #6a6a6a;
}
}
}
.log-meta {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
min-width: 120px;
}
.log-timestamp {
color: ${(props) => props.theme.console.timestampColor};
font-size: 11px;
font-weight: 400;
}
.log-level {
font-size: 9px;
font-weight: 600;
padding: 2px 4px;
border-radius: 2px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.log-icon {
flex-shrink: 0;
}
.log-message {
color: ${(props) => props.theme.console.messageColor};
white-space: pre-wrap;
word-break: break-word;
flex: 1;
.log-object {
margin: 4px 0;
padding: 8px;
background: ${(props) => props.theme.console.headerBg};
border-radius: 4px;
border: 1px solid ${(props) => props.theme.console.border};
.react-json-view {
background: transparent !important;
.object-key-val {
font-size: 12px !important;
}
.object-key {
color: ${(props) => props.theme.console.messageColor} !important;
font-weight: 500 !important;
}
.object-value {
color: ${(props) => props.theme.console.messageColor} !important;
}
.string-value {
color: ${(props) => props.theme.colors?.text?.green || (props.theme.console.messageColor)} !important;
}
.number-value {
color: ${(props) => props.theme.colors?.text?.purple || (props.theme.console.messageColor)} !important;
}
.boolean-value {
color: ${(props) => props.theme.colors?.text?.yellow || (props.theme.console.messageColor)} !important;
}
.null-value {
color: ${(props) => props.theme.colors?.text?.danger || (props.theme.console.messageColor)} !important;
}
.object-size {
color: ${(props) => props.theme.console.timestampColor} !important;
}
.brace, .bracket {
color: ${(props) => props.theme.console.messageColor} !important;
}
.collapsed-icon, .expanded-icon {
color: ${(props) => props.theme.console.checkboxColor} !important;
}
.icon-container {
color: ${(props) => props.theme.console.checkboxColor} !important;
}
.click-to-expand, .click-to-collapse {
color: ${(props) => props.theme.console.checkboxColor} !important;
}
}
}
}
`;
export default StyledWrapper;

View File

@ -0,0 +1,531 @@
import React, { useEffect, useRef, useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import ReactJson from 'react-json-view';
import { useTheme } from 'providers/Theme';
import {
IconX,
IconTrash,
IconFilter,
IconAlertTriangle,
IconAlertCircle,
IconBug,
IconCode,
IconChevronDown,
IconTerminal2,
IconNetwork
} from '@tabler/icons';
import {
closeConsole,
clearLogs,
updateFilter,
toggleAllFilters,
setActiveTab,
clearDebugErrors,
updateNetworkFilter,
toggleAllNetworkFilters
} from 'providers/ReduxStore/slices/logs';
import NetworkTab from './NetworkTab';
import RequestDetailsPanel from './RequestDetailsPanel';
// import DebugTab from './DebugTab';
import ErrorDetailsPanel from './ErrorDetailsPanel';
import StyledWrapper from './StyledWrapper';
const LogIcon = ({ type }) => {
const iconProps = { size: 16, strokeWidth: 1.5 };
switch (type) {
case 'error':
return <IconAlertCircle className="log-icon error" {...iconProps} />;
case 'warn':
return <IconAlertTriangle className="log-icon warn" {...iconProps} />;
case 'info':
return <IconAlertTriangle className="log-icon info" {...iconProps} />;
// case 'debug':
// return <IconBug className="log-icon debug" {...iconProps} />;
default:
return <IconCode className="log-icon log" {...iconProps} />;
}
};
const LogTimestamp = ({ timestamp }) => {
const date = new Date(timestamp);
const time = date.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
fractionalSecondDigits: 3
});
return <span className="log-timestamp">{time}</span>;
};
const LogMessage = ({ message, args }) => {
const { displayedTheme } = useTheme();
const formatMessage = (msg, originalArgs) => {
if (originalArgs && originalArgs.length > 0) {
return originalArgs.map((arg, index) => {
if (typeof arg === 'object' && arg !== null) {
return (
<div key={index} className="log-object">
<ReactJson
src={arg}
theme={displayedTheme === 'light' ? 'rjv-default' : 'monokai'}
iconStyle="triangle"
indentWidth={2}
collapsed={1}
displayDataTypes={false}
displayObjectSize={false}
enableClipboard={false}
name={false}
style={{
backgroundColor: 'transparent',
fontSize: '12px',
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace'
}}
/>
</div>
);
}
return String(arg);
});
}
return msg;
};
const formattedMessage = formatMessage(message, args);
return (
<span className="log-message">
{Array.isArray(formattedMessage) ? formattedMessage.map((item, index) => (
<span key={index}>{item} </span>
)) : formattedMessage}
</span>
);
};
const FilterDropdown = ({ filters, logCounts, onFilterToggle, onToggleAll }) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef(null);
const allFiltersEnabled = Object.values(filters).every(f => f);
const activeFilters = Object.entries(filters).filter(([_, enabled]) => enabled);
useEffect(() => {
const handleClickOutside = (event) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
return (
<div className="filter-dropdown" ref={dropdownRef}>
<button
className="filter-dropdown-trigger"
onClick={() => setIsOpen(!isOpen)}
title="Filter logs by type"
>
<IconFilter size={16} strokeWidth={1.5} />
<span className="filter-summary">
{activeFilters.length === Object.keys(filters).length ? 'All' : `${activeFilters.length}/${Object.keys(filters).length}`}
</span>
<IconChevronDown size={14} strokeWidth={1.5} />
</button>
{isOpen && (
<div className={`filter-dropdown-menu right`}>
<div className="filter-dropdown-header">
<span>Filter by Type</span>
<button
className="filter-toggle-all"
onClick={() => onToggleAll(!allFiltersEnabled)}
>
{allFiltersEnabled ? 'Hide All' : 'Show All'}
</button>
</div>
<div className="filter-dropdown-options">
{Object.entries(filters).map(([filterType, enabled]) => (
<label key={filterType} className="filter-option">
<input
type="checkbox"
checked={enabled}
onChange={(e) => onFilterToggle(filterType, e.target.checked)}
/>
<div className="filter-option-content">
<LogIcon type={filterType} />
<span className="filter-option-label">{filterType}</span>
<span className="filter-option-count">({logCounts[filterType] || 0})</span>
</div>
</label>
))}
</div>
</div>
)}
</div>
);
};
const NetworkFilterDropdown = ({ filters, requestCounts, onFilterToggle, onToggleAll }) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef(null);
const allFiltersEnabled = Object.values(filters).every(f => f);
const activeFilters = Object.entries(filters).filter(([_, enabled]) => enabled);
const getMethodColor = (method) => {
switch (method?.toUpperCase()) {
case 'GET': return '#10b981';
case 'POST': return '#8b5cf6';
case 'PUT': return '#f59e0b';
case 'DELETE': return '#ef4444';
case 'PATCH': return '#06b6d4';
case 'HEAD': return '#6b7280';
case 'OPTIONS': return '#84cc16';
default: return '#6b7280';
}
};
useEffect(() => {
const handleClickOutside = (event) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
return (
<div className="filter-dropdown" ref={dropdownRef}>
<button
className="filter-dropdown-trigger"
onClick={() => setIsOpen(!isOpen)}
title="Filter requests by method"
>
<IconFilter size={16} strokeWidth={1.5} />
<span className="filter-summary">
{activeFilters.length === Object.keys(filters).length ? 'All' : `${activeFilters.length}/${Object.keys(filters).length}`}
</span>
<IconChevronDown size={14} strokeWidth={1.5} />
</button>
{isOpen && (
<div className={`filter-dropdown-menu right`}>
<div className="filter-dropdown-header">
<span>Filter by Method</span>
<button
className="filter-toggle-all"
onClick={() => onToggleAll(!allFiltersEnabled)}
>
{allFiltersEnabled ? 'Hide All' : 'Show All'}
</button>
</div>
<div className="filter-dropdown-options">
{Object.entries(filters).map(([method, enabled]) => (
<label key={method} className="filter-option">
<input
type="checkbox"
checked={enabled}
onChange={(e) => onFilterToggle(method, e.target.checked)}
/>
<div className="filter-option-content">
<span className="method-badge" style={{ backgroundColor: getMethodColor(method) }}>
{method}
</span>
<span className="filter-option-label">{method}</span>
<span className="filter-option-count">({requestCounts[method] || 0})</span>
</div>
</label>
))}
</div>
</div>
)}
</div>
);
};
const ConsoleTab = ({ logs, filters, logCounts, onFilterToggle, onToggleAll, onClearLogs }) => {
const logsEndRef = useRef(null);
const prevLogsCountRef = useRef(0);
useEffect(() => {
// Only scroll when new logs are added, not when switching tabs
if (logsEndRef.current && logs.length > prevLogsCountRef.current) {
logsEndRef.current.scrollIntoView({ behavior: 'auto' });
}
prevLogsCountRef.current = logs.length;
}, [logs]);
const filteredLogs = logs.filter(log => filters[log.type]);
return (
<div className="tab-content">
<div className="tab-content-area">
{filteredLogs.length === 0 ? (
<div className="console-empty">
<IconTerminal2 size={48} strokeWidth={1} />
<p>No logs to display</p>
<span>Logs will appear here as your application runs</span>
</div>
) : (
<div className="logs-container">
{filteredLogs.map((log) => (
<div key={log.id} className={`log-entry ${log.type}`}>
<div className="log-meta">
<LogTimestamp timestamp={log.timestamp} />
<LogIcon type={log.type} />
</div>
<LogMessage message={log.message} args={log.args} />
</div>
))}
<div ref={logsEndRef} />
</div>
)}
</div>
</div>
);
};
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 consoleRef = useRef(null);
const logCounts = logs.reduce((counts, log) => {
counts[log.type] = (counts[log.type] || 0) + 1;
return counts;
}, {});
const allRequests = React.useMemo(() => {
const requests = [];
collections.forEach(collection => {
if (collection.timeline) {
collection.timeline
.filter(entry => entry.type === 'request')
.forEach(entry => {
requests.push({
...entry,
collectionName: collection.name,
collectionUid: collection.uid
});
});
}
});
return requests.sort((a, b) => a.timestamp - b.timestamp);
}, [collections]);
const filteredLogs = logs.filter(log => filters[log.type]);
const filteredRequests = allRequests.filter(request => {
const method = request.data?.request?.method?.toUpperCase() || 'GET';
return networkFilters[method];
});
const requestCounts = allRequests.reduce((counts, request) => {
const method = request.data?.request?.method?.toUpperCase() || 'GET';
counts[method] = (counts[method] || 0) + 1;
return counts;
}, {});
const handleFilterToggle = (filterType, enabled) => {
dispatch(updateFilter({ filterType, enabled }));
};
const handleNetworkFilterToggle = (method, enabled) => {
dispatch(updateNetworkFilter({ method, enabled }));
};
const handleClearLogs = () => {
dispatch(clearLogs());
};
const handleClearDebugErrors = () => {
dispatch(clearDebugErrors());
};
const handlecloseConsole = () => {
dispatch(closeConsole());
};
const handleToggleAllFilters = (enabled) => {
dispatch(toggleAllFilters(enabled));
};
const handleToggleAllNetworkFilters = (enabled) => {
dispatch(toggleAllNetworkFilters(enabled));
};
const handleTabChange = (tab) => {
dispatch(setActiveTab(tab));
};
const renderTabContent = () => {
switch (activeTab) {
case 'console':
return (
<ConsoleTab
logs={logs}
filters={filters}
logCounts={logCounts}
onFilterToggle={handleFilterToggle}
onToggleAll={handleToggleAllFilters}
onClearLogs={handleClearLogs}
/>
);
case 'network':
return <NetworkTab />;
// case 'debug':
// return <DebugTab />;
default:
return (
<ConsoleTab
logs={logs}
filters={filters}
logCounts={logCounts}
onFilterToggle={handleFilterToggle}
onToggleAll={handleToggleAllFilters}
onClearLogs={handleClearLogs}
/>
);
}
};
const renderTabControls = () => {
switch (activeTab) {
case 'console':
return (
<div className="tab-controls">
<div className="filter-controls">
<FilterDropdown
filters={filters}
logCounts={logCounts}
onFilterToggle={handleFilterToggle}
onToggleAll={handleToggleAllFilters}
/>
</div>
<div className="action-controls">
<button
className="control-button"
onClick={handleClearLogs}
title="Clear all logs"
>
<IconTrash size={16} strokeWidth={1.5} />
</button>
</div>
</div>
);
case 'network':
return (
<div className="tab-controls">
<div className="filter-controls">
<NetworkFilterDropdown
filters={networkFilters}
requestCounts={requestCounts}
onFilterToggle={handleNetworkFilterToggle}
onToggleAll={handleToggleAllNetworkFilters}
/>
</div>
</div>
);
// case 'debug':
// return (
// <div className="tab-controls">
// <div className="action-controls">
// {debugErrors.length > 0 && (
// <button
// className="control-button"
// onClick={handleClearDebugErrors}
// title="Clear all errors"
// >
// <IconTrash size={16} strokeWidth={1.5} />
// </button>
// )}
// </div>
// </div>
// );
default:
return null;
}
};
return (
<StyledWrapper ref={consoleRef}>
<div
className="console-resize-handle"
/>
<div className="console-header">
<div className="console-tabs">
<button
className={`console-tab ${activeTab === 'console' ? 'active' : ''}`}
onClick={() => handleTabChange('console')}
>
<IconTerminal2 size={16} strokeWidth={1.5} />
<span>Console</span>
</button>
<button
className={`console-tab ${activeTab === 'network' ? 'active' : ''}`}
onClick={() => handleTabChange('network')}
>
<IconNetwork size={16} strokeWidth={1.5} />
<span>Network</span>
</button>
{/* <button
className={`console-tab ${activeTab === 'debug' ? 'active' : ''}`}
onClick={() => handleTabChange('debug')}
>
<IconBug size={16} strokeWidth={1.5} />
<span>Debug</span>
</button> */}
</div>
<div className="console-controls">
{renderTabControls()}
<button
className="control-button close-button"
onClick={handlecloseConsole}
title="Close console"
>
<IconX size={16} strokeWidth={1.5} />
</button>
</div>
</div>
<div className="console-content">
{activeTab === 'network' && selectedRequest ? (
<div className="network-with-details">
<div className="network-main">
{renderTabContent()}
</div>
<RequestDetailsPanel />
</div>
) : activeTab === 'debug' && selectedError ? (
<div className="debug-with-details">
<div className="debug-main">
{renderTabContent()}
</div>
<ErrorDetailsPanel />
</div>
) : (
renderTabContent()
)}
</div>
</StyledWrapper>
);
};
export default Console;

View File

@ -0,0 +1,88 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import Console from './Console';
const MIN_DEVTOOLS_HEIGHT = 150;
const MAX_DEVTOOLS_HEIGHT = window.innerHeight * 0.7;
const DEFAULT_DEVTOOLS_HEIGHT = 300;
const Devtools = ({ mainSectionRef }) => {
const isDevtoolsOpen = useSelector((state) => state.logs.isConsoleOpen);
const [devtoolsHeight, setDevtoolsHeight] = useState(DEFAULT_DEVTOOLS_HEIGHT);
const [isResizingDevtools, setIsResizingDevtools] = useState(false);
const handleDevtoolsResizeStart = useCallback((e) => {
e.preventDefault();
setIsResizingDevtools(true);
}, []);
const handleDevtoolsResize = useCallback((e) => {
if (!isResizingDevtools || !mainSectionRef.current) return;
const windowHeight = window.innerHeight;
const statusBarHeight = 22;
const mouseY = e.clientY;
// Calculate new devtools height - expanding upward from bottom
const newHeight = windowHeight - mouseY - statusBarHeight;
const clampedHeight = Math.min(MAX_DEVTOOLS_HEIGHT, Math.max(MIN_DEVTOOLS_HEIGHT, newHeight));
setDevtoolsHeight(clampedHeight);
// Update main section height
if (mainSectionRef.current) {
mainSectionRef.current.style.height = `calc(100vh - 22px - ${clampedHeight}px)`;
}
}, [isResizingDevtools, mainSectionRef]);
const handleDevtoolsResizeEnd = useCallback(() => {
setIsResizingDevtools(false);
}, []);
useEffect(() => {
if (isResizingDevtools) {
document.addEventListener('mousemove', handleDevtoolsResize);
document.addEventListener('mouseup', handleDevtoolsResizeEnd);
document.body.style.userSelect = 'none';
return () => {
document.removeEventListener('mousemove', handleDevtoolsResize);
document.removeEventListener('mouseup', handleDevtoolsResizeEnd);
document.body.style.userSelect = '';
};
}
}, [isResizingDevtools, handleDevtoolsResize, handleDevtoolsResizeEnd]);
// Set initial height
useEffect(() => {
if (mainSectionRef.current && isDevtoolsOpen) {
mainSectionRef.current.style.height = `calc(100vh - 22px - ${devtoolsHeight}px)`;
}
}, [isDevtoolsOpen, devtoolsHeight, mainSectionRef]);
if (!isDevtoolsOpen) {
return null;
}
return (
<>
<div
onMouseDown={handleDevtoolsResizeStart}
style={{
height: '4px',
cursor: 'row-resize',
backgroundColor: isResizingDevtools ? '#0078d4' : 'transparent',
transition: 'background-color 0.2s ease',
zIndex: 20,
position: 'relative'
}}
onMouseEnter={(e) => e.target.style.backgroundColor = '#0078d4'}
onMouseLeave={(e) => e.target.style.backgroundColor = isResizingDevtools ? '#0078d4' : 'transparent'}
/>
<div style={{ height: `${devtoolsHeight}px`, overflow: 'hidden', position: 'relative' }}>
<Console />
</div>
</>
);
};
export default Devtools;

View File

@ -9,7 +9,7 @@ const ManageSecrets = ({ onClose }) => {
<div>
<p>In any collection, there are secrets that need to be managed.</p>
<p className="mt-2">These secrets can be anything such as API keys, passwords, or tokens.</p>
<p className="mt-4">Bruno offers two approaches to manage secrets in collections.</p>
<p className="mt-4">Bruno offers three approaches to manage secrets in collections.</p>
<p className="mt-2">
Read more about it in our{' '}
<a

View File

@ -0,0 +1,147 @@
import React, { Component, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import { addDebugError } from 'providers/ReduxStore/slices/logs';
class ErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error) {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
if (this.props.onError) {
this.props.onError({
message: error.message,
stack: error.stack,
error: error,
timestamp: new Date().toISOString()
});
}
setTimeout(() => {
this.setState({ hasError: false });
}, 100);
}
render() {
return this.props.children;
}
}
const serializeArgs = (args) => {
return args.map(arg => {
try {
if (arg === null) return 'null';
if (arg === undefined) return 'undefined';
if (typeof arg === 'string' || typeof arg === 'number' || typeof arg === 'boolean') {
return arg;
}
if (arg instanceof Error) {
return {
__type: 'Error',
name: arg.name,
message: arg.message,
stack: arg.stack
};
}
if (typeof arg === 'object') {
try {
return JSON.parse(JSON.stringify(arg));
} catch {
return String(arg);
}
}
return String(arg);
} catch (e) {
return '[Unserializable]';
}
});
};
// Helper function to extract file and line info from stack trace
const extractFileInfo = (stack) => {
if (!stack) return { filename: null, lineno: null, colno: null };
try {
const lines = stack.split('\n');
for (let line of lines) {
if (line.includes('ErrorCapture') || line.trim() === 'Error') continue;
const match = line.match(/(?:at\s+.*?\s+)?\(?([^)]+):(\d+):(\d+)\)?/);
if (match) {
return {
filename: match[1],
lineno: parseInt(match[2]),
colno: parseInt(match[3])
};
}
}
} catch (e) {
// Ignore parsing errors
}
return { filename: null, lineno: null, colno: null };
};
const useGlobalErrorCapture = () => {
const dispatch = useDispatch();
useEffect(() => {
const originalConsoleError = console.error;
console.error = (...args) => {
const currentStack = new Error().stack;
originalConsoleError.apply(console, args);
if (currentStack && currentStack.includes('useIpcEvents.js')) {
return;
}
const errorMessage = args.join(' ');
if (errorMessage.includes('removeConsoleLogListener')) {
return;
}
const { filename, lineno, colno } = extractFileInfo(currentStack);
const serializedArgs = serializeArgs(args);
dispatch(addDebugError({
message: errorMessage,
stack: currentStack,
filename: filename,
lineno: lineno,
colno: colno,
args: serializedArgs,
timestamp: new Date().toISOString()
}));
};
return () => {
console.error = originalConsoleError;
};
}, [dispatch]);
};
const ErrorCapture = ({ children }) => {
const dispatch = useDispatch();
useGlobalErrorCapture();
const handleReactError = (errorData) => {
dispatch(addDebugError(errorData));
};
return (
<ErrorBoundary onError={handleReactError}>
{children}
</ErrorBoundary>
);
};
export default ErrorCapture;

View File

@ -11,6 +11,12 @@ const Wrapper = styled.div`
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
}
.inherit-mode-text {
color: ${(props) => props.theme.colors.text.yellow};
}
.auth-mode-label {
color: ${(props) => props.theme.colors.text.yellow};
}
`;
export default Wrapper;

View File

@ -7,8 +7,17 @@ import { updateFolderAuth } from 'providers/ReduxStore/slices/collections';
import { useDispatch } from 'react-redux';
import OAuth2PasswordCredentials from 'components/RequestPane/Auth/OAuth2/PasswordCredentials/index';
import OAuth2ClientCredentials from 'components/RequestPane/Auth/OAuth2/ClientCredentials/index';
import OAuth2Implicit from 'components/RequestPane/Auth/OAuth2/Implicit/index';
import GrantTypeSelector from 'components/RequestPane/Auth/OAuth2/GrantTypeSelector/index';
import AuthMode from '../AuthMode';
import BasicAuth from 'components/RequestPane/Auth/BasicAuth';
import BearerAuth from 'components/RequestPane/Auth/BearerAuth';
import DigestAuth from 'components/RequestPane/Auth/DigestAuth';
import NTLMAuth from 'components/RequestPane/Auth/NTLMAuth';
import WsseAuth from 'components/RequestPane/Auth/WsseAuth';
import ApiKeyAuth from 'components/RequestPane/Auth/ApiKeyAuth';
import AwsV4Auth from 'components/RequestPane/Auth/AwsV4Auth';
import { findItemInCollection, findParentItemInCollection, humanizeRequestAuthMode } from 'utils/collections/index';
const GrantTypeComponentMap = ({ collection, folder }) => {
const dispatch = useDispatch();
@ -27,6 +36,8 @@ const GrantTypeComponentMap = ({ collection, folder }) => {
return <OAuth2AuthorizationCode save={save} item={folder} request={request} updateAuth={updateFolderAuth} collection={collection} folder={folder} />;
case 'client_credentials':
return <OAuth2ClientCredentials save={save} item={folder} request={request} updateAuth={updateFolderAuth} collection={collection} folder={folder} />;
case 'implicit':
return <OAuth2Implicit save={save} item={folder} request={request} updateAuth={updateFolderAuth} collection={collection} folder={folder} />;
default:
return <div>TBD</div>;
}
@ -37,12 +48,132 @@ const Auth = ({ collection, folder }) => {
let request = get(folder, 'root.request', {});
const authMode = get(folder, 'root.request.auth.mode');
const getTreePathFromCollectionToFolder = (collection, _folder) => {
let path = [];
let item = findItemInCollection(collection, _folder?.uid);
while (item) {
path.unshift(item);
item = findParentItemInCollection(collection, item?.uid);
}
return path;
};
const getEffectiveAuthSource = () => {
if (authMode !== 'inherit') return null;
const collectionAuth = get(collection, 'root.request.auth');
let effectiveSource = {
type: 'collection',
name: 'Collection',
auth: collectionAuth
};
// Get path from collection to current folder
const folderTreePath = getTreePathFromCollectionToFolder(collection, folder);
// Check parent folders to find closest auth configuration
// Skip the last item which is the current folder
for (let i = 0; i < folderTreePath.length - 1; i++) {
const parentFolder = folderTreePath[i];
if (parentFolder.type === 'folder') {
const folderAuth = get(parentFolder, 'root.request.auth');
if (folderAuth && folderAuth.mode && folderAuth.mode !== 'inherit') {
effectiveSource = {
type: 'folder',
name: parentFolder.name,
auth: folderAuth
};
break;
}
}
}
return effectiveSource;
};
const handleSave = () => {
dispatch(saveFolderRoot(collection.uid, folder.uid));
};
const getAuthView = () => {
switch (authMode) {
case 'basic': {
return (
<BasicAuth
collection={collection}
item={folder}
updateAuth={updateFolderAuth}
request={request}
save={() => handleSave()}
/>
);
}
case 'bearer': {
return (
<BearerAuth
collection={collection}
item={folder}
updateAuth={updateFolderAuth}
request={request}
save={() => handleSave()}
/>
);
}
case 'digest': {
return (
<DigestAuth
collection={collection}
item={folder}
updateAuth={updateFolderAuth}
request={request}
save={() => handleSave()}
/>
);
}
case 'ntlm': {
return (
<NTLMAuth
collection={collection}
item={folder}
updateAuth={updateFolderAuth}
request={request}
save={() => handleSave()}
/>
);
}
case 'wsse': {
return (
<WsseAuth
collection={collection}
item={folder}
updateAuth={updateFolderAuth}
request={request}
save={() => handleSave()}
/>
);
}
case 'apikey': {
return (
<ApiKeyAuth
collection={collection}
item={folder}
updateAuth={updateFolderAuth}
request={request}
save={() => handleSave()}
/>
);
}
case 'awsv4': {
return (
<AwsV4Auth
collection={collection}
item={folder}
updateAuth={updateFolderAuth}
request={request}
save={() => handleSave()}
/>
);
}
case 'oauth2': {
return (
<>
@ -56,6 +187,17 @@ const Auth = ({ collection, folder }) => {
</>
);
}
case 'inherit': {
const source = getEffectiveAuthSource();
return (
<>
<div className="flex flex-row w-full mt-2 gap-2">
<div>Auth inherited from {source.name}: </div>
<div className="inherit-mode-text">{humanizeRequestAuthMode(source.auth?.mode)}</div>
</div>
</>
);
}
case 'none': {
return null;
}
@ -64,6 +206,7 @@ const Auth = ({ collection, folder }) => {
}
};
return (
<StyledWrapper className="w-full">
<div className="text-xs mb-4 text-muted">

View File

@ -35,6 +35,51 @@ const AuthMode = ({ collection, folder }) => {
<StyledWrapper>
<div className="inline-flex items-center cursor-pointer">
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('awsv4');
}}
>
AWS Sig v4
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('basic');
}}
>
Basic Auth
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('bearer');
}}
>
Bearer Token
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('digest');
}}
>
Digest Auth
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('ntlm');
}}
>
NTLM Auth
</div>
<div
className="dropdown-item"
onClick={() => {
@ -44,6 +89,33 @@ const AuthMode = ({ collection, folder }) => {
>
OAuth 2.0
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('wsse');
}}
>
WSSE Auth
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('apikey');
}}
>
API Key
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onModeChange('inherit');
}}
>
Inherit
</div>
<div
className="dropdown-item"
onClick={() => {

View File

@ -9,6 +9,7 @@ import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions'
import SingleLineEditor from 'components/SingleLineEditor';
import StyledWrapper from './StyledWrapper';
import { headers as StandardHTTPHeaders } from 'know-your-http-well';
import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
const Headers = ({ collection, folder }) => {
@ -117,6 +118,7 @@ const Headers = ({ collection, folder }) => {
}
collection={collection}
item={folder}
autocomplete={MimeTypes}
/>
</td>
<td>

View File

@ -55,6 +55,7 @@ const Script = ({ collection, folder }) => {
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'bru']}
/>
</div>
<div className="flex flex-col flex-1 mt-2 gap-y-2">
@ -68,6 +69,7 @@ const Script = ({ collection, folder }) => {
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
/>
</div>

View File

@ -38,6 +38,7 @@ const Tests = ({ collection, folder }) => {
onSave={handleSave}
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
showHintsFor={['req', 'res', 'bru']}
/>
<div className="mt-6">

View File

@ -9,17 +9,9 @@ import StyledWrapper from './StyledWrapper';
import Vars from './Vars';
import Documentation from './Documentation';
import Auth from './Auth';
import DotIcon from 'components/Icons/Dot';
import StatusDot from 'components/StatusDot';
import get from 'lodash/get';
const ContentIndicator = () => {
return (
<sup className="ml-[.125rem] opacity-80 font-medium">
<DotIcon width="10"></DotIcon>
</sup>
);
};
const FolderSettings = ({ collection, folder }) => {
const dispatch = useDispatch();
let tab = 'headers';
@ -82,7 +74,7 @@ const FolderSettings = ({ collection, folder }) => {
};
return (
<StyledWrapper className="flex flex-col h-full">
<StyledWrapper className="flex flex-col h-full overflow-scroll">
<div className="flex flex-col h-full relative px-4 py-4">
<div className="flex flex-wrap items-center tabs" role="tablist">
<div className={getTabClassname('headers')} role="tab" onClick={() => setTab('headers')}>
@ -91,11 +83,11 @@ const FolderSettings = ({ collection, folder }) => {
</div>
<div className={getTabClassname('script')} role="tab" onClick={() => setTab('script')}>
Script
{hasScripts && <ContentIndicator />}
{hasScripts && <StatusDot />}
</div>
<div className={getTabClassname('test')} role="tab" onClick={() => setTab('test')}>
Test
{hasTests && <ContentIndicator />}
{hasTests && <StatusDot />}
</div>
<div className={getTabClassname('vars')} role="tab" onClick={() => setTab('vars')}>
Vars
@ -103,13 +95,13 @@ const FolderSettings = ({ collection, folder }) => {
</div>
<div className={getTabClassname('auth')} role="tab" onClick={() => setTab('auth')}>
Auth
{hasAuth && <ContentIndicator />}
{hasAuth && <StatusDot />}
</div>
<div className={getTabClassname('docs')} role="tab" onClick={() => setTab('docs')}>
Docs
</div>
</div>
<section className={`flex mt-4 h-full`}>{getTabPanel(tab)}</section>
<section className={`flex mt-4 h-full overflow-scroll`}>{getTabPanel(tab)}</section>
</div>
</StyledWrapper>
);

View File

@ -2,14 +2,10 @@ import React, { Component } from 'react';
import isEqual from 'lodash/isEqual';
import { getAllVariables } from 'utils/collections';
import { defineCodeMirrorBrunoVariablesMode } from 'utils/common/codemirror';
import { setupAutoComplete } from 'utils/codemirror/autocomplete';
import StyledWrapper from './StyledWrapper';
let CodeMirror;
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
if (!SERVER_RENDERED) {
CodeMirror = require('codemirror');
}
const CodeMirror = require('codemirror');
class MultiLineEditor extends Component {
constructor(props) {
@ -78,14 +74,23 @@ class MultiLineEditor extends Component {
'Shift-Tab': false
}
});
if (this.props.autocomplete) {
this.editor.on('keyup', (cm, event) => {
if (!cm.state.completionActive /*Enables keyboard navigation in autocomplete list*/ && event.keyCode != 13) {
/*Enter - do not open autocomplete list just after item has been selected in it*/
CodeMirror.commands.autocomplete(cm, CodeMirror.hint.anyword, { autocomplete: this.props.autocomplete });
}
});
}
const getAllVariablesHandler = () => getAllVariables(this.props.collection, this.props.item);
const getAnywordAutocompleteHints = () => this.props.autocomplete || [];
// Setup AutoComplete Helper
const autoCompleteOptions = {
showHintsFor: ['variables'],
getAllVariables: getAllVariablesHandler,
getAnywordAutocompleteHints
};
this.brunoAutoCompleteCleanup = setupAutoComplete(
this.editor,
autoCompleteOptions
);
this.editor.setValue(String(this.props.value) || '');
this.editor.on('change', this._onEdit);
this.addOverlay(variables);
@ -125,12 +130,15 @@ class MultiLineEditor extends Component {
}
componentWillUnmount() {
if (this.brunoAutoCompleteCleanup) {
this.brunoAutoCompleteCleanup();
}
this.editor.getWrapperElement().remove();
}
addOverlay = (variables) => {
this.variables = variables;
defineCodeMirrorBrunoVariablesMode(variables, 'text/plain');
defineCodeMirrorBrunoVariablesMode(variables, 'text/plain', false, true);
this.editor.setOption('mode', 'brunovariables');
};

View File

@ -2,6 +2,7 @@ import { IconBell } from '@tabler/icons';
import { useState } from 'react';
import StyledWrapper from './StyleWrapper';
import Modal from 'components/Modal/index';
import Portal from 'components/Portal';
import { useEffect } from 'react';
import { useApp } from 'providers/App';
import {
@ -109,7 +110,7 @@ const Notifications = () => {
>
<ToolHint text="Notifications" toolhintId="Notifications" offset={8}>
<IconBell
size={18}
size={16}
aria-hidden
strokeWidth={1.5}
className={`mr-2 ${unreadNotifications?.length > 0 ? 'bell' : ''}`}
@ -121,6 +122,7 @@ const Notifications = () => {
</a>
{showNotificationsModal && (
<Portal>
<Modal
size="lg"
title="Notifications"
@ -199,10 +201,11 @@ const Notifications = () => {
</div>
</div>
) : (
<div className="opacity-50 italic text-xs p-12 flex justify-center">No Notifications</div>
<div className="opacity-50 italic text-xs p-12 flex justify-center">You are all caught up!</div>
)}
</div>
</Modal>
</Portal>
)}
</StyledWrapper>
);

View File

@ -3,6 +3,7 @@ import get from 'lodash/get';
import { useSelector, useDispatch } from 'react-redux';
import { savePreferences } from 'providers/ReduxStore/slices/app';
import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
const Font = ({ close }) => {
const dispatch = useDispatch();
@ -31,7 +32,10 @@ const Font = ({ close }) => {
}
})
).then(() => {
toast.success('Preferences saved successfully')
close();
}).catch(() => {
toast.error('Failed to save preferences')
});
};

View File

@ -80,9 +80,9 @@ const General = ({ close }) => {
storeCookies: newPreferences.storeCookies,
sendCookies: newPreferences.sendCookies
}
})
)
}))
.then(() => {
toast.success('Preferences saved successfully')
close();
})
.catch((err) => console.log(err) && toast.error('Failed to update preferences'));

View File

@ -84,7 +84,10 @@ const ProxySettings = ({ close }) => {
proxy: validatedProxy
})
).then(() => {
toast.success('Preferences saved successfully')
close();
}).catch(() => {
toast.error('Failed to save preferences')
});
})
.catch((error) => {

View File

@ -5,21 +5,23 @@ import { IconCaretDown } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import { useTheme } from 'providers/Theme';
import SingleLineEditor from 'components/SingleLineEditor';
import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { sendRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
import { humanizeRequestAPIKeyPlacement } from 'utils/collections';
const ApiKeyAuth = ({ item, collection }) => {
const ApiKeyAuth = ({ item, collection, updateAuth, request, save }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const apikeyAuth = item.draft ? get(item, 'draft.request.auth.apikey', {}) : get(item, 'request.auth.apikey', {});
const apikeyAuth = get(request, 'auth.apikey', {});
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleSave = () => {
save();
};
const Icon = forwardRef((props, ref) => {
return (
@ -90,7 +92,7 @@ const ApiKeyAuth = ({ item, collection }) => {
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
dropdownTippyRef?.current?.hide();
handleAuthChange('placement', 'header');
}}
>
@ -99,11 +101,11 @@ const ApiKeyAuth = ({ item, collection }) => {
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
dropdownTippyRef?.current?.hide();
handleAuthChange('placement', 'queryparams');
}}
>
Query Params
Query Param
</div>
</Dropdown>
</div>

View File

@ -8,14 +8,17 @@ import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collection
import StyledWrapper from './StyledWrapper';
import { update } from 'lodash';
const AwsV4Auth = ({ onTokenChange, item, collection }) => {
const AwsV4Auth = ({ item, collection, updateAuth, request, save }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const awsv4Auth = item.draft ? get(item, 'draft.request.auth.awsv4', {}) : get(item, 'request.auth.awsv4', {});
const awsv4Auth = get(request, 'auth.awsv4', {});
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleSave = () => {
save();
};
const handleAccessKeyIdChange = (accessKeyId) => {
dispatch(
@ -25,11 +28,11 @@ const AwsV4Auth = ({ onTokenChange, item, collection }) => {
itemUid: item.uid,
content: {
accessKeyId: accessKeyId,
secretAccessKey: awsv4Auth.secretAccessKey,
sessionToken: awsv4Auth.sessionToken,
service: awsv4Auth.service,
region: awsv4Auth.region,
profileName: awsv4Auth.profileName
secretAccessKey: awsv4Auth.secretAccessKey || '',
sessionToken: awsv4Auth.sessionToken || '',
service: awsv4Auth.service || '',
region: awsv4Auth.region || '',
profileName: awsv4Auth.profileName || ''
}
})
);
@ -42,12 +45,12 @@ const AwsV4Auth = ({ onTokenChange, item, collection }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
accessKeyId: awsv4Auth.accessKeyId,
secretAccessKey: secretAccessKey,
sessionToken: awsv4Auth.sessionToken,
service: awsv4Auth.service,
region: awsv4Auth.region,
profileName: awsv4Auth.profileName
accessKeyId: awsv4Auth.accessKeyId || '',
secretAccessKey: secretAccessKey || '',
sessionToken: awsv4Auth.sessionToken || '',
service: awsv4Auth.service || '',
region: awsv4Auth.region || '',
profileName: awsv4Auth.profileName || ''
}
})
);
@ -60,12 +63,12 @@ const AwsV4Auth = ({ onTokenChange, item, collection }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
accessKeyId: awsv4Auth.accessKeyId,
secretAccessKey: awsv4Auth.secretAccessKey,
sessionToken: sessionToken,
service: awsv4Auth.service,
region: awsv4Auth.region,
profileName: awsv4Auth.profileName
accessKeyId: awsv4Auth.accessKeyId || '',
secretAccessKey: awsv4Auth.secretAccessKey || '',
sessionToken: sessionToken || '',
service: awsv4Auth.service || '',
region: awsv4Auth.region || '',
profileName: awsv4Auth.profileName || ''
}
})
);
@ -78,12 +81,12 @@ const AwsV4Auth = ({ onTokenChange, item, collection }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
accessKeyId: awsv4Auth.accessKeyId,
secretAccessKey: awsv4Auth.secretAccessKey,
sessionToken: awsv4Auth.sessionToken,
service: service,
region: awsv4Auth.region,
profileName: awsv4Auth.profileName
accessKeyId: awsv4Auth.accessKeyId || '',
secretAccessKey: awsv4Auth.secretAccessKey || '',
sessionToken: awsv4Auth.sessionToken || '',
service: service || '',
region: awsv4Auth.region || '',
profileName: awsv4Auth.profileName || ''
}
})
);
@ -96,12 +99,12 @@ const AwsV4Auth = ({ onTokenChange, item, collection }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
accessKeyId: awsv4Auth.accessKeyId,
secretAccessKey: awsv4Auth.secretAccessKey,
sessionToken: awsv4Auth.sessionToken,
service: awsv4Auth.service,
region: region,
profileName: awsv4Auth.profileName
accessKeyId: awsv4Auth.accessKeyId || '',
secretAccessKey: awsv4Auth.secretAccessKey || '',
sessionToken: awsv4Auth.sessionToken || '',
service: awsv4Auth.service || '',
region: region || '',
profileName: awsv4Auth.profileName || ''
}
})
);
@ -114,12 +117,12 @@ const AwsV4Auth = ({ onTokenChange, item, collection }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
accessKeyId: awsv4Auth.accessKeyId,
secretAccessKey: awsv4Auth.secretAccessKey,
sessionToken: awsv4Auth.sessionToken,
service: awsv4Auth.service,
region: awsv4Auth.region,
profileName: profileName
accessKeyId: awsv4Auth.accessKeyId || '',
secretAccessKey: awsv4Auth.secretAccessKey || '',
sessionToken: awsv4Auth.sessionToken || '',
service: awsv4Auth.service || '',
region: awsv4Auth.region || '',
profileName: profileName || ''
}
})
);

View File

@ -7,14 +7,17 @@ import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
const BasicAuth = ({ item, collection }) => {
const BasicAuth = ({ item, collection, updateAuth, request, save }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const basicAuth = item.draft ? get(item, 'draft.request.auth.basic', {}) : get(item, 'request.auth.basic', {});
const basicAuth = get(request, 'auth.basic', {});
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleSave = () => {
save();
};
const handleUsernameChange = (username) => {
dispatch(
@ -23,8 +26,8 @@ const BasicAuth = ({ item, collection }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
username: username,
password: basicAuth.password
username: username || '',
password: basicAuth.password || ''
}
})
);
@ -37,8 +40,8 @@ const BasicAuth = ({ item, collection }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
username: basicAuth.username,
password: password
username: basicAuth.username || '',
password: password || ''
}
})
);

View File

@ -7,16 +7,18 @@ import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
const BearerAuth = ({ item, collection }) => {
const BearerAuth = ({ item, collection, updateAuth, request, save }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const bearerToken = item.draft
? get(item, 'draft.request.auth.bearer.token', '')
: get(item, 'request.auth.bearer.token', '');
// Use the request prop directly like OAuth2ClientCredentials does
const bearerToken = get(request, 'auth.bearer.token', '');
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleSave = () => {
save();
};
const handleTokenChange = (token) => {
dispatch(

View File

@ -3,18 +3,20 @@ import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import SingleLineEditor from 'components/SingleLineEditor';
import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
const DigestAuth = ({ item, collection }) => {
const DigestAuth = ({ item, collection, updateAuth, request, save }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const digestAuth = item.draft ? get(item, 'draft.request.auth.digest', {}) : get(item, 'request.auth.digest', {});
const digestAuth = get(request, 'auth.digest', {});
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleSave = () => {
save();
};
const handleUsernameChange = (username) => {
dispatch(
@ -23,8 +25,8 @@ const DigestAuth = ({ item, collection }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
username: username,
password: digestAuth.password
username: username || '',
password: digestAuth.password || ''
}
})
);
@ -37,8 +39,8 @@ const DigestAuth = ({ item, collection }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
username: digestAuth.username,
password: password
username: digestAuth.username || '',
password: password || ''
}
})
);

View File

@ -7,14 +7,17 @@ import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
const NTLMAuth = ({ item, collection }) => {
const NTLMAuth = ({ item, collection, request, save, updateAuth }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const ntlmAuth = item.draft ? get(item, 'draft.request.auth.ntlm', {}) : get(item, 'request.auth.ntlm', {});
const ntlmAuth = get(request, 'auth.ntlm', {});
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleSave = () => {
save();
};
const handleUsernameChange = (username) => {
dispatch(
@ -23,10 +26,9 @@ const NTLMAuth = ({ item, collection }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
username: username,
password: ntlmAuth.password,
domain: ntlmAuth.domain
username: username || '',
password: ntlmAuth.password || '',
domain: ntlmAuth.domain || ''
}
})
);
@ -39,9 +41,9 @@ const NTLMAuth = ({ item, collection }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
username: ntlmAuth.username,
password: password,
domain: ntlmAuth.domain
username: ntlmAuth.username || '',
password: password || '',
domain: ntlmAuth.domain || ''
}
})
);
@ -54,9 +56,9 @@ const NTLMAuth = ({ item, collection }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
username: ntlmAuth.username,
password: ntlmAuth.password,
domain: domain
username: ntlmAuth.username || '',
password: ntlmAuth.password || '',
domain: domain || ''
}
})
);

View File

@ -101,6 +101,15 @@ const GrantTypeSelector = ({ item = {}, request, updateAuth, collection }) => {
>
Authorization Code
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onGrantTypeChange('implicit');
}}
>
Implicit
</div>
<div
className="dropdown-item"
onClick={() => {

View File

@ -0,0 +1,61 @@
import styled from 'styled-components';
const Wrapper = styled.div`
label {
font-size: 0.8125rem;
}
.oauth2-input-wrapper {
max-width: 400px;
padding: 0.15rem 0.4rem;
border-radius: 3px;
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
}
.token-placement-selector {
padding: 0.5rem 0px;
border-radius: 3px;
border: solid 1px ${(props) => props.theme.input.border};
background-color: ${(props) => props.theme.input.bg};
min-width: 100px;
.dropdown {
width: fit-content;
min-width: 100px;
div[data-tippy-root] {
width: fit-content;
min-width: 100px;
}
.tippy-box {
width: fit-content;
max-width: none !important;
min-width: 100px;
.tippy-content {
width: fit-content;
max-width: none !important;
min-width: 100px;
}
}
}
.token-placement-label {
width: fit-content;
justify-content: space-between;
padding: 0 0.5rem;
min-width: 100px;
}
.dropdown-item {
padding: 0.2rem 0.6rem !important;
}
}
.checkbox-label {
color: ${(props) => props.theme.colors.text.primary};
user-select: none;
}
`;
export default Wrapper;

View File

@ -0,0 +1,281 @@
import React, { useRef, forwardRef, useState, useMemo } from 'react';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch } from 'react-redux';
import { IconCaretDown, IconLoader2, IconSettings, IconKey, IconHelp, IconAdjustmentsHorizontal } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import SingleLineEditor from 'components/SingleLineEditor';
import { clearOauth2Cache, fetchOauth2Credentials } from 'providers/ReduxStore/slices/collections/actions';
import Wrapper from './StyledWrapper';
import { inputsConfig } from './inputsConfig';
import toast from 'react-hot-toast';
import Oauth2TokenViewer from '../Oauth2TokenViewer/index';
import { cloneDeep } from 'lodash';
import { getAllVariables } from 'utils/collections/index';
import { interpolate } from '@usebruno/common';
const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, collection, folder }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const [fetchingToken, toggleFetchingToken] = useState(false);
const oAuth = get(request, 'auth.oauth2', {});
const {
callbackUrl,
authorizationUrl,
clientId,
scope,
state,
credentialsId,
tokenPlacement,
tokenHeaderPrefix,
tokenQueryKey,
autoFetchToken
} = oAuth;
const interpolatedAuthUrl = useMemo(() => {
const variables = getAllVariables(collection, item);
return interpolate(authorizationUrl, variables);
}, [collection, item, authorizationUrl]);
const TokenPlacementIcon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-end token-placement-label select-none">
{tokenPlacement == 'url' ? 'URL' : 'Headers'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
});
const handleFetchOauth2Credentials = async () => {
let requestCopy = cloneDeep(request);
requestCopy.oauth2 = requestCopy?.auth.oauth2;
requestCopy.headers = {};
toggleFetchingToken(true);
try {
const result = await dispatch(fetchOauth2Credentials({
itemUid: item.uid,
request: requestCopy,
collection,
folderUid: folder?.uid || null,
forceGetToken: true
}));
toggleFetchingToken(false);
// Check if the result contains error or if access_token is missing
if (result?.error || !result?.access_token) {
const errorMessage = result?.error || 'No access token received from authorization server';
toast.error(errorMessage);
return;
}
toast.success('Token fetched successfully!');
}
catch (error) {
console.error(error);
toggleFetchingToken(false);
toast.error(error?.message || 'An error occurred while fetching token!');
}
}
const handleSave = () => { save(); };
const handleChange = (key, value) => {
dispatch(
updateAuth({
mode: 'oauth2',
collectionUid: collection.uid,
itemUid: item.uid,
content: {
grantType: 'implicit',
callbackUrl,
authorizationUrl,
clientId,
state,
scope,
credentialsId,
tokenPlacement,
tokenHeaderPrefix,
tokenQueryKey,
autoFetchToken,
[key]: value,
}
})
);
};
const handleAutoFetchTokenToggle = (e) => {
handleChange('autoFetchToken', e.target.checked);
};
const handleClearCache = (e) => {
dispatch(clearOauth2Cache({ collectionUid: collection?.uid, url: interpolatedAuthUrl, credentialsId }))
.then(() => {
toast.success('Cleared cache successfully');
})
.catch((err) => {
toast.error(err.message);
});
};
return (
<Wrapper className="mt-2 flex w-full gap-4 flex-col">
<Oauth2TokenViewer handleRun={handleRun} collection={collection} item={item} url={authorizationUrl} credentialsId={credentialsId} />
<div className="flex items-center gap-2.5 mt-2">
<div className="flex items-center px-2.5 py-1.5 bg-indigo-50/50 dark:bg-indigo-500/10 rounded-md">
<IconSettings size={14} className="text-indigo-500 dark:text-indigo-400" />
</div>
<span className="text-sm font-medium">
Configuration
</span>
</div>
{inputsConfig.map((input) => {
const { key, label, isSecret } = input;
return (
<div className="flex items-center gap-4 w-full" key={`input-${key}`}>
<label className="block min-w-[140px]">{label}</label>
<div className="oauth2-input-wrapper flex-1">
<SingleLineEditor
value={oAuth[key] || ''}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange(key, val)}
onRun={handleRun}
collection={collection}
item={item}
isSecret={isSecret}
/>
</div>
</div>
);
})}
<div className="flex items-center gap-2.5 mt-2">
<div className="flex items-center px-2.5 py-1.5 bg-indigo-50/50 dark:bg-indigo-500/10 rounded-md">
<IconKey size={14} className="text-indigo-500 dark:text-indigo-400" />
</div>
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">
Token
</span>
</div>
<div className="flex items-center gap-4 w-full" key={`input-token-name`}>
<label className="block min-w-[140px]">Token ID</label>
<div className="oauth2-input-wrapper flex-1">
<SingleLineEditor
value={oAuth['credentialsId'] || 'credentials'}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('credentialsId', val)}
onRun={handleRun}
collection={collection}
item={item}
/>
</div>
</div>
<div className="flex items-center gap-4 w-full" key={`input-token-placement`}>
<label className="block min-w-[140px]">Add Token to</label>
<div className="inline-flex items-center cursor-pointer token-placement-selector">
<Dropdown onCreate={onDropdownCreate} icon={<TokenPlacementIcon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('tokenPlacement', 'header');
}}
>
Headers
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('tokenPlacement', 'url');
}}
>
URL
</div>
</Dropdown>
</div>
</div>
{tokenPlacement == 'header' ? (
<div className="flex items-center gap-4 w-full" key={`input-token-header-prefix`}>
<label className="block min-w-[140px]">Header Prefix</label>
<div className="oauth2-input-wrapper flex-1">
<SingleLineEditor
value={oAuth.tokenHeaderPrefix || 'Bearer'}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('tokenHeaderPrefix', val)}
onRun={handleRun}
collection={collection}
item={item}
/>
</div>
</div>
) : (
<div className="flex items-center gap-4 w-full" key={`input-token-query-key`}>
<label className="block min-w-[140px]">URL Query Key</label>
<div className="oauth2-input-wrapper flex-1">
<SingleLineEditor
value={oAuth.tokenQueryKey || 'access_token'}
theme={storedTheme}
onSave={handleSave}
onChange={(val) => handleChange('tokenQueryKey', val)}
onRun={handleRun}
collection={collection}
item={item}
/>
</div>
</div>
)}
<div className="flex items-center gap-2.5 mt-2">
<div className="flex items-center px-2.5 py-1.5 bg-indigo-50/50 dark:bg-indigo-500/10 rounded-md">
<IconAdjustmentsHorizontal size={14} className="text-indigo-500 dark:text-indigo-400" />
</div>
<span className="text-sm font-medium">
Advanced Options
</span>
</div>
<div className="flex items-center gap-4 w-full">
<input
type="checkbox"
checked={oAuth.autoFetchToken !== false}
onChange={handleAutoFetchTokenToggle}
className="cursor-pointer ml-1"
/>
<label className="block min-w-[140px]">Auto fetch token</label>
<div className="flex items-center gap-2">
<div className="relative group cursor-pointer">
<IconHelp size={16} className="text-gray-500" />
<span className="group-hover:opacity-100 pointer-events-none opacity-0 max-w-60 absolute left-0 bottom-full mb-1 w-max p-2 bg-gray-700 text-white text-xs rounded-md transition-opacity duration-200">
Automatically fetch a new token when the current one expires.
</span>
</div>
</div>
</div>
<div className="flex flex-row gap-4 mt-4">
<button
onClick={handleFetchOauth2Credentials}
className={`submit btn btn-sm btn-secondary w-fit flex flex-row`}
disabled={fetchingToken}
>
Get Access Token{fetchingToken ? <IconLoader2 className="animate-spin ml-2" size={18} strokeWidth={1.5} /> : ""}
</button>
<button onClick={handleClearCache} className="submit btn btn-sm btn-secondary w-fit">
Clear Cache
</button>
</div>
</Wrapper>
);
};
export default OAuth2Implicit;

View File

@ -0,0 +1,24 @@
const inputsConfig = [
{
key: 'callbackUrl',
label: 'Callback URL'
},
{
key: 'authorizationUrl',
label: 'Authorization URL'
},
{
key: 'clientId',
label: 'Client ID'
},
{
key: 'scope',
label: 'Scope'
},
{
key: 'state',
label: 'State'
}
];
export { inputsConfig };

View File

@ -28,20 +28,30 @@ const Oauth2ActionButtons = ({ item, request, collection, url: accessTokenUrl, c
requestCopy.headers = {};
toggleFetchingToken(true);
try {
const credentials = await dispatch(fetchOauth2Credentials({ itemUid: item.uid, request: requestCopy, collection }));
const result = await dispatch(fetchOauth2Credentials({
itemUid: item.uid,
request: requestCopy,
collection,
forceGetToken: true
}));
toggleFetchingToken(false);
if (credentials?.access_token) {
toast.success('token fetched successfully!');
}
else {
toast.error('An error occurred while fetching token!');
// Check if the result contains error or if access_token is missing
if (!result || !result.access_token) {
const errorMessage = result?.error || 'No access token received from authorization server';
console.error(errorMessage);
toast.error(errorMessage);
return;
}
toast.success('Token fetched successfully!');
}
catch (error) {
console.error('could not fetch the token!');
console.error(error);
toggleFetchingToken(false);
toast.error('An error occurred while fetching token!');
toast.error(error?.message || 'An error occurred while fetching token!');
}
}
@ -51,26 +61,36 @@ const Oauth2ActionButtons = ({ item, request, collection, url: accessTokenUrl, c
requestCopy.headers = {};
toggleRefreshingToken(true);
try {
const credentials = await dispatch(refreshOauth2Credentials({ itemUid: item.uid, request: requestCopy, collection }));
const result = await dispatch(refreshOauth2Credentials({
itemUid: item.uid,
request: requestCopy,
collection,
forceGetToken: true
}));
toggleRefreshingToken(false);
if (credentials?.access_token) {
toast.success('token refreshed successfully!');
}
else {
toast.error('An error occurred while refreshing token!');
// Check if the result contains error or if access_token is missing
if (!result || !result.access_token) {
const errorMessage = result?.error || 'No access token received from authorization server';
console.error(errorMessage);
toast.error(errorMessage);
return;
}
toast.success('Token refreshed successfully!');
}
catch(error) {
console.error(error);
toggleRefreshingToken(false);
toast.error('An error occurred while refreshing token!');
toast.error(error?.message || 'An error occurred while refreshing token!');
}
};
const handleClearCache = (e) => {
dispatch(clearOauth2Cache({ collectionUid: collection?.uid, url: interpolatedAccessTokenUrl, credentialsId }))
.then(() => {
toast.success('cleared cache successfully');
toast.success('Cleared cache successfully');
})
.catch((err) => {
toast.error(err.message);

View File

@ -4,6 +4,7 @@ import StyledWrapper from './StyledWrapper';
import GrantTypeSelector from './GrantTypeSelector/index';
import OAuth2PasswordCredentials from './PasswordCredentials/index';
import OAuth2AuthorizationCode from './AuthorizationCode/index';
import OAuth2Implicit from './Implicit/index';
import OAuth2ClientCredentials from './ClientCredentials/index';
import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
@ -31,6 +32,9 @@ const GrantTypeComponentMap = ({ item, collection }) => {
case 'authorization_code':
return <OAuth2AuthorizationCode item={item} save={save} request={request} handleRun={handleRun} updateAuth={updateAuth} collection={collection} />;
break;
case 'implicit':
return <OAuth2Implicit item={item} save={save} request={request} handleRun={handleRun} updateAuth={updateAuth} collection={collection} />;
break;
case 'client_credentials':
return <OAuth2ClientCredentials item={item} save={save} request={request} handleRun={handleRun} updateAuth={updateAuth} collection={collection} />;
break;

View File

@ -7,14 +7,17 @@ import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import StyledWrapper from './StyledWrapper';
const WsseAuth = ({ item, collection }) => {
const WsseAuth = ({ item, collection, updateAuth, request, save }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const wsseAuth = item.draft ? get(item, 'draft.request.auth.wsse', {}) : get(item, 'request.auth.wsse', {});
const wsseAuth = get(request, 'auth.wsse', {});
const handleRun = () => dispatch(sendRequest(item, collection.uid));
const handleSave = () => dispatch(saveRequest(item.uid, collection.uid));
const handleSave = () => {
save();
};
const handleUserChange = (username) => {
dispatch(
@ -23,8 +26,8 @@ const WsseAuth = ({ item, collection }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
username,
password: wsseAuth.password
username: username || '',
password: wsseAuth.password || ''
}
})
);
@ -37,8 +40,8 @@ const WsseAuth = ({ item, collection }) => {
collectionUid: collection.uid,
itemUid: item.uid,
content: {
username: wsseAuth.username,
password
username: wsseAuth.username || '',
password: password || ''
}
})
);
@ -55,6 +58,7 @@ const WsseAuth = ({ item, collection }) => {
onChange={(val) => handleUserChange(val)}
onRun={handleRun}
collection={collection}
item={item}
/>
</div>
@ -67,6 +71,8 @@ const WsseAuth = ({ item, collection }) => {
onChange={(val) => handlePasswordChange(val)}
onRun={handleRun}
collection={collection}
item={item}
isSecret={true}
/>
</div>
</StyledWrapper>

View File

@ -7,6 +7,8 @@ import BasicAuth from './BasicAuth';
import DigestAuth from './DigestAuth';
import WsseAuth from './WsseAuth';
import NTLMAuth from './NTLMAuth';
import { updateAuth } from 'providers/ReduxStore/slices/collections';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import ApiKeyAuth from './ApiKeyAuth';
import StyledWrapper from './StyledWrapper';
@ -28,6 +30,16 @@ const Auth = ({ item, collection }) => {
const authMode = item.draft ? get(item, 'draft.request.auth.mode') : get(item, 'request.auth.mode');
const requestTreePath = getTreePathFromCollectionToItem(collection, item);
// Create a request object to pass to the auth components
const request = item.draft
? get(item, 'draft.request', {})
: get(item, 'request', {});
// Save function for request level
const save = () => {
return saveRequest(item.uid, collection.uid);
};
const getEffectiveAuthSource = () => {
if (authMode !== 'inherit') return null;
@ -42,7 +54,7 @@ const Auth = ({ item, collection }) => {
for (let i of [...requestTreePath].reverse()) {
if (i.type === 'folder') {
const folderAuth = get(i, 'root.request.auth');
if (folderAuth && folderAuth.mode && folderAuth.mode !== 'none' && folderAuth.mode !== 'inherit') {
if (folderAuth && folderAuth.mode && folderAuth.mode !== 'inherit') {
effectiveSource = {
type: 'folder',
name: i.name,
@ -59,28 +71,28 @@ const Auth = ({ item, collection }) => {
const getAuthView = () => {
switch (authMode) {
case 'awsv4': {
return <AwsV4Auth collection={collection} item={item} />;
return <AwsV4Auth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}
case 'basic': {
return <BasicAuth collection={collection} item={item} />;
return <BasicAuth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}
case 'bearer': {
return <BearerAuth collection={collection} item={item} />;
return <BearerAuth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}
case 'digest': {
return <DigestAuth collection={collection} item={item} />;
return <DigestAuth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}
case 'ntlm': {
return <NTLMAuth collection={collection} item={item} />;
return <NTLMAuth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}
case 'oauth2': {
return <OAuth2 collection={collection} item={item} />;
return <OAuth2 collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}
case 'wsse': {
return <WsseAuth collection={collection} item={item} />;
return <WsseAuth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}
case 'apikey': {
return <ApiKeyAuth collection={collection} item={item} />;
return <ApiKeyAuth collection={collection} item={item} request={request} save={save} updateAuth={updateAuth} />;
}
case 'inherit': {
const source = getEffectiveAuthSource();

View File

@ -18,8 +18,10 @@ import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collection
import StyledWrapper from './StyledWrapper';
import Documentation from 'components/Documentation/index';
import GraphQLSchemaActions from '../GraphQLSchemaActions/index';
import HeightBoundContainer from 'ui/HeightBoundContainer';
import Settings from 'components/RequestPane/Settings';
const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, toggleDocs, handleGqlClickReference }) => {
const GraphQLRequestPane = ({ item, collection, onSchemaLoad, toggleDocs, handleGqlClickReference }) => {
const dispatch = useDispatch();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
@ -66,7 +68,6 @@ const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, tog
collection={collection}
theme={displayedTheme}
schema={schema}
width={leftPaneWidth}
onSave={onSave}
value={query}
onRun={onRun}
@ -101,6 +102,9 @@ const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, tog
case 'docs': {
return <Documentation item={item} collection={collection} />;
}
case 'settings': {
return <Settings item={item} collection={collection} />;
}
default: {
return <div className="mt-4">404 | Not found</div>;
}
@ -152,9 +156,14 @@ const GraphQLRequestPane = ({ item, collection, leftPaneWidth, onSchemaLoad, tog
<div className={getTabClassname('docs')} role="tab" onClick={() => selectTab('docs')}>
Docs
</div>
<div className={getTabClassname('settings')} role="tab" onClick={() => selectTab('settings')}>
Settings
</div>
<GraphQLSchemaActions item={item} collection={collection} onSchemaLoad={setSchema} toggleDocs={toggleDocs} />
</div>
<section className="flex w-full mt-5 flex-1 relative">{getTabPanel(focusedTab.requestPaneTab)}</section>
<section className="flex w-full mt-5 flex-1 relative">
<HeightBoundContainer>{getTabPanel(focusedTab.requestPaneTab)}</HeightBoundContainer>
</section>
</StyledWrapper>
);
};

View File

@ -7,8 +7,10 @@ import Dropdown from '../../Dropdown';
const GraphQLSchemaActions = ({ item, collection, onSchemaLoad, toggleDocs }) => {
const url = item.draft ? get(item, 'draft.request.url', '') : get(item, 'request.url', '');
const pathname = item.draft ? get(item, 'draft.pathname', '') : get(item, 'pathname', '');
const uid = item.draft ? get(item, 'draft.uid', '') : get(item, 'uid', '');
const environment = findEnvironmentInCollection(collection, collection.activeEnvironmentUid);
const request = item.draft ? item.draft.request : item.request;
const request = item.draft ? { ...item.draft.request, pathname, uid } : { ...item.request, pathname, uid };
let {
schema,

View File

@ -64,9 +64,11 @@ const GraphQLVariables = ({ variables, item, collection }) => {
font={get(preferences, 'font.codeFont', 'default')}
fontSize={get(preferences, 'font.codeFontSize')}
onEdit={onEdit}
mode="javascript"
mode="application/json"
onRun={onRun}
onSave={onSave}
enableVariableHighlighting={true}
showHintsFor={['variables']}
/>
</>
);

View File

@ -7,7 +7,6 @@ import RequestHeaders from 'components/RequestPane/RequestHeaders';
import RequestBody from 'components/RequestPane/RequestBody';
import RequestBodyMode from 'components/RequestPane/RequestBody/RequestBodyMode';
import Auth from 'components/RequestPane/Auth';
import DotIcon from 'components/Icons/Dot';
import Vars from 'components/RequestPane/Vars';
import Assertions from 'components/RequestPane/Assertions';
import Script from 'components/RequestPane/Script';
@ -15,25 +14,12 @@ import Tests from 'components/RequestPane/Tests';
import StyledWrapper from './StyledWrapper';
import { find, get } from 'lodash';
import Documentation from 'components/Documentation/index';
import HeightBoundContainer from 'ui/HeightBoundContainer';
import { useEffect } from 'react';
import StatusDot from 'components/StatusDot';
import Settings from 'components/RequestPane/Settings';
const ContentIndicator = () => {
return (
<sup className="ml-[.125rem] opacity-80 font-medium">
<DotIcon width="10"></DotIcon>
</sup>
);
};
const ErrorIndicator = () => {
return (
<sup className="ml-[.125rem] opacity-80 font-medium text-red-500">
<DotIcon width="10" ></DotIcon>
</sup>
);
};
const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
const HttpRequestPane = ({ item, collection }) => {
const dispatch = useDispatch();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
@ -76,6 +62,9 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
case 'docs': {
return <Documentation item={item} collection={collection} />;
}
case 'settings': {
return <Settings item={item} collection={collection} />;
}
default: {
return <div className="mt-4">404 | Not found</div>;
}
@ -112,6 +101,7 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
const requestVars = getPropertyFromDraftOrRequest('request.vars.req');
const responseVars = getPropertyFromDraftOrRequest('request.vars.res');
const auth = getPropertyFromDraftOrRequest('request.auth');
const tags = getPropertyFromDraftOrRequest('tags');
const activeParamsLength = params.filter((param) => param.enabled).length;
const activeHeadersLength = headers.filter((header) => header.enabled).length;
@ -135,7 +125,7 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
</div>
<div className={getTabClassname('body')} role="tab" onClick={() => selectTab('body')}>
Body
{body.mode !== 'none' && <ContentIndicator />}
{body.mode !== 'none' && <StatusDot />}
</div>
<div className={getTabClassname('headers')} role="tab" onClick={() => selectTab('headers')}>
Headers
@ -143,7 +133,7 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
</div>
<div className={getTabClassname('auth')} role="tab" onClick={() => selectTab('auth')}>
Auth
{auth.mode !== 'none' && <ContentIndicator />}
{auth.mode !== 'none' && <StatusDot />}
</div>
<div className={getTabClassname('vars')} role="tab" onClick={() => selectTab('vars')}>
Vars
@ -153,8 +143,8 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
Script
{(script.req || script.res) && (
item.preRequestScriptErrorMessage || item.postResponseScriptErrorMessage ?
<ErrorIndicator /> :
<ContentIndicator />
<StatusDot type="error" /> :
<StatusDot />
)}
</div>
<div className={getTabClassname('assert')} role="tab" onClick={() => selectTab('assert')}>
@ -163,11 +153,19 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
</div>
<div className={getTabClassname('tests')} role="tab" onClick={() => selectTab('tests')}>
Tests
{tests && tests.length > 0 && <ContentIndicator />}
{tests && tests.length > 0 && (
item.testScriptErrorMessage ?
<StatusDot type="error" /> :
<StatusDot />
)}
</div>
<div className={getTabClassname('docs')} role="tab" onClick={() => selectTab('docs')}>
Docs
{docs && docs.length > 0 && <ContentIndicator />}
{docs && docs.length > 0 && <StatusDot />}
</div>
<div className={getTabClassname('settings')} role="tab" onClick={() => selectTab('settings')}>
Settings
{tags && tags.length > 0 && <StatusDot />}
</div>
{focusedTab.requestPaneTab === 'body' ? (
<div className="flex flex-grow justify-end items-center">
@ -180,7 +178,9 @@ const HttpRequestPane = ({ item, collection, leftPaneWidth }) => {
'mt-5': !isMultipleContentTab
})}
>
{getTabPanel(focusedTab.requestPaneTab)}
<HeightBoundContainer>
{getTabPanel(focusedTab.requestPaneTab)}
</HeightBoundContainer>
</section>
</StyledWrapper>
);

View File

@ -18,12 +18,7 @@ import { IconWand } from '@tabler/icons';
import onHasCompletion from './onHasCompletion';
let CodeMirror;
const SERVER_RENDERED = typeof window === 'undefined' || global['PREVENT_CODEMIRROR_RENDER'] === true;
if (!SERVER_RENDERED) {
CodeMirror = require('codemirror');
}
const CodeMirror = require('codemirror');
const md = new MD();
const AUTO_COMPLETE_AFTER_KEY = /^[a-zA-Z0-9_@(]$/;

View File

@ -31,7 +31,7 @@ const Wrapper = styled.div`
}
}
.btn-add-param {
.btn-action {
font-size: 0.8125rem;
&:hover span {
text-decoration: underline;

View File

@ -1,16 +1,17 @@
import React from 'react';
import React, { useState } from 'react';
import get from 'lodash/get';
import cloneDeep from 'lodash/cloneDeep';
import InfoTip from 'components/InfoTip';
import { IconTrash } from '@tabler/icons';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { useTheme } from 'providers/Theme';
import {
addQueryParam,
updateQueryParam,
deleteQueryParam,
moveQueryParam,
updatePathParam
updatePathParam,
setQueryParams
} from 'providers/ReduxStore/slices/collections';
import SingleLineEditor from 'components/SingleLineEditor';
import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
@ -18,6 +19,7 @@ import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collection
import StyledWrapper from './StyledWrapper';
import Table from 'components/Table/index';
import ReorderTable from 'components/ReorderTable';
import BulkEditor from '../../BulkEditor';
const QueryParams = ({ item, collection }) => {
const dispatch = useDispatch();
@ -26,6 +28,8 @@ const QueryParams = ({ item, collection }) => {
const queryParams = params.filter((param) => param.type === 'query');
const pathParams = params.filter((param) => param.type === 'path');
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
const handleAddQueryParam = () => {
dispatch(
addQueryParam({
@ -113,8 +117,31 @@ const QueryParams = ({ item, collection }) => {
);
};
const toggleBulkEditMode = () => {
setIsBulkEditMode(!isBulkEditMode);
};
const handleBulkParamsChange = (newParams) => {
const paramsWithType = newParams.map((item) => ({ ...item, type: 'query' }));
dispatch(setQueryParams({ collectionUid: collection.uid, itemUid: item.uid, params: paramsWithType }));
};
if (isBulkEditMode) {
return (
<StyledWrapper className="w-full mt-3">
<BulkEditor
params={queryParams}
onChange={handleBulkParamsChange}
onToggle={toggleBulkEditMode}
onSave={onSave}
onRun={handleRun}
/>
</StyledWrapper>
);
}
return (
<StyledWrapper className="w-full flex flex-col absolute">
<StyledWrapper className="w-full flex flex-col">
<div className="flex-1 mt-2">
<div className="mb-1 title text-xs">Query</div>
<Table
@ -171,9 +198,14 @@ const QueryParams = ({ item, collection }) => {
</ReorderTable>
</Table>
<button className="btn-add-param text-link pr-2 py-3 mt-2 select-none" onClick={handleAddQueryParam}>
+&nbsp;<span>Add Param</span>
</button>
<div className="flex justify-between mt-2">
<button className="btn-action text-link pr-2 py-3 select-none" onClick={handleAddQueryParam}>
+&nbsp;<span>Add Param</span>
</button>
<button className="btn-action text-link select-none" onClick={toggleBulkEditMode}>
Bulk Edit
</button>
</div>
<div className="mb-2 title text-xs flex items-stretch">
<span>Path</span>
<InfoTip infotipId="path-param-InfoTip">

View File

@ -83,6 +83,7 @@ const QueryUrl = ({ item, collection, handleRun }) => {
<HttpMethodSelector method={method} onMethodSelect={onMethodSelect} />
</div>
<div
id="request-url"
className="flex items-center flex-grow input-container h-full"
style={{
color: 'yellow',

View File

@ -21,7 +21,7 @@ const RequestBodyMode = ({ item, collection }) => {
const Icon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-center pl-3 py-1 select-none selected-body-mode">
{humanizeRequestBodyMode(bodyMode)} <IconCaretDown className="caret ml-2 mr-2" size={14} strokeWidth={2} />
{humanizeRequestBodyMode(bodyMode)} <IconCaretDown className="caret ml-2" size={14} strokeWidth={2} />
</div>
);
});
@ -149,7 +149,7 @@ const RequestBodyMode = ({ item, collection }) => {
</Dropdown>
</div>
{(bodyMode === 'json' || bodyMode === 'xml') && (
<button className="ml-1" onClick={onPrettify}>
<button className="ml-2" onClick={onPrettify}>
Prettify
</button>
)}

View File

@ -58,13 +58,15 @@ const RequestBody = ({ item, collection }) => {
onRun={onRun}
onSave={onSave}
mode={codeMirrorMode[bodyMode]}
enableVariableHighlighting={true}
showHintsFor={['variables']}
/>
</StyledWrapper>
);
}
if (bodyMode === 'file') {
return <FileBody item={item} collection={collection}/>
return <FileBody item={item} collection={collection} />;
}
if (bodyMode === 'formUrlEncoded') {

View File

@ -22,8 +22,11 @@ const Wrapper = styled.div`
}
}
.btn-add-header {
.btn-action {
font-size: 0.8125rem;
&:hover span {
text-decoration: underline;
}
}
input[type='text'] {

View File

@ -1,10 +1,10 @@
import React from 'react';
import React, { useState } from 'react';
import get from 'lodash/get';
import cloneDeep from 'lodash/cloneDeep';
import { IconTrash } from '@tabler/icons';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { useTheme } from 'providers/Theme';
import { addRequestHeader, updateRequestHeader, deleteRequestHeader, moveRequestHeader } from 'providers/ReduxStore/slices/collections';
import { addRequestHeader, updateRequestHeader, deleteRequestHeader, moveRequestHeader, setRequestHeaders } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import SingleLineEditor from 'components/SingleLineEditor';
import StyledWrapper from './StyledWrapper';
@ -12,6 +12,8 @@ import { headers as StandardHTTPHeaders } from 'know-your-http-well';
import { MimeTypes } from 'utils/codemirror/autocompleteConstants';
import Table from 'components/Table/index';
import ReorderTable from 'components/ReorderTable/index';
import BulkEditor from '../../BulkEditor';
const headerAutoCompleteList = StandardHTTPHeaders.map((e) => e.header);
const RequestHeaders = ({ item, collection }) => {
@ -19,6 +21,8 @@ const RequestHeaders = ({ item, collection }) => {
const { storedTheme } = useTheme();
const headers = item.draft ? get(item, 'draft.request.headers') : get(item, 'request.headers');
const [isBulkEditMode, setIsBulkEditMode] = useState(false);
const addHeader = () => {
dispatch(
addRequestHeader({
@ -75,6 +79,28 @@ const RequestHeaders = ({ item, collection }) => {
);
};
const toggleBulkEditMode = () => {
setIsBulkEditMode(!isBulkEditMode);
};
const handleBulkHeadersChange = (newHeaders) => {
dispatch(setRequestHeaders({ collectionUid: collection.uid, itemUid: item.uid, headers: newHeaders }));
};
if (isBulkEditMode) {
return (
<StyledWrapper className="w-full mt-3">
<BulkEditor
params={headers}
onChange={handleBulkHeadersChange}
onToggle={toggleBulkEditMode}
onSave={onSave}
onRun={handleRun}
/>
</StyledWrapper>
);
}
return (
<StyledWrapper className="w-full">
<Table
@ -153,9 +179,14 @@ const RequestHeaders = ({ item, collection }) => {
: null}
</ReorderTable>
</Table>
<button className="btn-add-header text-link pr-2 py-3 mt-2 select-none" onClick={addHeader}>
+ Add Header
</button>
<div className="flex justify-between mt-2">
<button className="btn-action text-link pr-2 py-3 select-none" onClick={addHeader}>
+ Add Header
</button>
<button className="btn-action text-link select-none" onClick={toggleBulkEditMode}>
Bulk Edit
</button>
</div>
</StyledWrapper>
);
};

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