Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot]
51312a3148 chore(deps): bump actions/github-script from 7 to 8
Bumps [actions/github-script](https://github.com/actions/github-script) from 7 to 8.
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7...v8)

---
updated-dependencies:
- dependency-name: actions/github-script
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-02 19:06:25 +00:00
578 changed files with 5918 additions and 32535 deletions

View File

@@ -23,19 +23,6 @@ reviews:
drafts: false
base_branches: ['main', 'release/*']
path_instructions:
- path: '**/*'
instructions: |
Bruno is a cross-platform Electron desktop app that runs on macOS, Windows, and Linux. Ensure that all code is OS-agnostic:
- File paths must use `path.join()` or `path.resolve()` instead of hardcoded `/` or `\\` separators
- Never assume case-sensitive or case-insensitive filesystems
- Use `os.homedir()`, `app.getPath()`, or environment-appropriate APIs instead of hardcoded paths like `/home/`, `C:\\Users\\`, or `~/`
- Line endings should be handled consistently (be aware of CRLF vs LF issues)
- Use `path.sep` or `path.posix`/`path.win32` when platform-specific separators are needed
- Shell commands or child_process calls must account for platform differences (e.g., `which` vs `where`, `/bin/sh` vs `cmd.exe`)
- File permissions (e.g., `fs.chmod`, `fs.access`) should account for Windows not supporting Unix-style permission bits
- Avoid relying on Unix-only signals (e.g., `SIGKILL`) without Windows fallbacks
- Use `os.tmpdir()` instead of hardcoding `/tmp`
- Environment variable access should handle platform differences (e.g., `HOME` vs `USERPROFILE`)
- path: 'tests/**/**.*'
instructions: |
Review the following e2e test code written using the Playwright test library. Ensure that:

View File

@@ -1,10 +1,5 @@
name: 'Setup Node Dependencies'
description: 'Install Node.js and npm dependencies'
inputs:
skip-build:
description: 'Skip building libraries'
required: false
default: 'false'
runs:
using: 'composite'
steps:
@@ -14,13 +9,12 @@ runs:
node-version: v22.17.0
cache: 'npm'
cache-dependency-path: './package-lock.json'
- name: Install node dependencies
shell: bash
run: npm ci --legacy-peer-deps
- name: Build libraries
if: inputs.skip-build != 'true'
shell: bash
run: |
npm run build:graphql-docs

View File

@@ -1,20 +0,0 @@
name: 'Run CLI Tests'
description: 'Setup dependencies, start local testbench and run CLI tests'
runs:
using: 'composite'
steps:
- name: Run Local Testbench
shell: bash
run: |
npm start --workspace=packages/bruno-tests &
sleep 5
- name: Install Test Collection Dependencies
shell: bash
run: npm ci --prefix packages/bruno-tests/collection
- name: Run CLI Tests
shell: bash
run: |
cd packages/bruno-tests/collection
node ../../bruno-cli/bin/bru.js run --env Prod --output junit.xml --format junit --sandbox developer

View File

@@ -1,22 +0,0 @@
name: 'Run E2E Tests'
description: 'Setup dependencies, configure environment, and run Playwright E2E tests'
inputs:
os:
description: 'Operating system (ubuntu, macos, windows)'
default: 'ubuntu'
runs:
using: 'composite'
steps:
- name: Install Test Collection Dependencies
shell: bash
run: npm ci --prefix packages/bruno-tests/collection
- name: Run Playwright Tests (Ubuntu)
if: inputs.os == 'ubuntu'
shell: bash
run: xvfb-run npm run test:e2e
- name: Run Playwright Tests
if: inputs.os != 'ubuntu'
shell: bash
run: npm run test:e2e

View File

@@ -1,48 +0,0 @@
name: 'Run Unit Tests'
description: 'Setup dependencies and run unit tests for all packages'
runs:
using: 'composite'
steps:
- name: Test Package bruno-js
shell: bash
run: npm run test --workspace=packages/bruno-js
- name: Test Package bruno-cli
shell: bash
run: npm run test --workspace=packages/bruno-cli
- name: Test Package bruno-query
shell: bash
run: npm run test --workspace=packages/bruno-query
- name: Test Package bruno-lang
shell: bash
run: npm run test --workspace=packages/bruno-lang
- name: Test Package bruno-schema
shell: bash
run: npm run test --workspace=packages/bruno-schema
- name: Test Package bruno-app
shell: bash
run: npm run test --workspace=packages/bruno-app
- name: Test Package bruno-common
shell: bash
run: npm run test --workspace=packages/bruno-common
- name: Test Package bruno-converters
shell: bash
run: npm run test --workspace=packages/bruno-converters
- name: Test Package bruno-electron
shell: bash
run: npm run test --workspace=packages/bruno-electron
- name: Test Package bruno-requests
shell: bash
run: npm run test --workspace=packages/bruno-requests
- name: Test Package bruno-filestore
shell: bash
run: npm run test --workspace=packages/bruno-filestore

View File

@@ -9,7 +9,6 @@ on:
permissions:
contents: read
pull-requests: write
issues: write
checks: write
jobs:
@@ -73,7 +72,7 @@ jobs:
- name: Post PR comment
if: hashFiles('pr-comment.md') != ''
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
script: |
const fs = require('fs');

View File

@@ -1,26 +0,0 @@
name: Lint Checks
on:
workflow_dispatch:
push:
branches: [main, 'release/v*']
pull_request:
branches: [main, 'release/v*']
jobs:
lint:
name: Lint Check
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
with:
skip-build: 'true'
- name: Lint Check
run: npm run lint
env:
ESLINT_PLUGIN_DIFF_COMMIT: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.ref || 'main' }}

View File

@@ -1,10 +1,9 @@
name: Tests
on:
workflow_dispatch:
push:
branches: [main, 'release/v*']
branches: [main]
pull_request:
branches: [main, 'release/v*']
branches: [main]
jobs:
unit-test:
@@ -15,12 +14,52 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v5
with:
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: './package-lock.json'
- name: Install dependencies
run: npm ci --legacy-peer-deps
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
# build libraries
- name: Build libraries
run: |
npm run build --workspace=packages/bruno-common
npm run build --workspace=packages/bruno-query
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
npm run build --workspace=packages/bruno-converters
npm run build --workspace=packages/bruno-requests
npm run build --workspace=packages/bruno-schema-types
npm run build --workspace=packages/bruno-filestore
- name: Run Unit Tests
uses: ./.github/actions/tests/run-unit-tests
- name: Lint Check
run: npm run lint
env:
ESLINT_PLUGIN_DIFF_COMMIT: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.ref || 'main' }}
# tests
- name: Test Package bruno-js
run: npm run test --workspace=packages/bruno-js
- name: Test Package bruno-cli
run: npm run test --workspace=packages/bruno-cli
- name: Test Package bruno-query
run: npm run test --workspace=packages/bruno-query
- name: Test Package bruno-lang
run: npm run test --workspace=packages/bruno-lang
- name: Test Package bruno-schema
run: npm run test --workspace=packages/bruno-schema
- name: Test Package bruno-app
run: npm run test --workspace=packages/bruno-app
- name: Test Package bruno-common
run: npm run test --workspace=packages/bruno-common
- name: Test Package bruno-converters
run: npm run test --workspace=packages/bruno-converters
- name: Test Package bruno-electron
run: npm run test --workspace=packages/bruno-electron
- name: Test Package bruno-requests
run: npm run test --workspace=packages/bruno-requests
cli-test:
name: CLI Tests
@@ -31,12 +70,35 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v5
with:
node-version-file: '.nvmrc'
cache: 'npm'
cache-dependency-path: './package-lock.json'
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
- name: Install dependencies
run: npm ci --legacy-peer-deps
- name: Run CLI Tests
uses: ./.github/actions/tests/run-cli-tests
- name: Build Libraries
run: |
npm run build --workspace=packages/bruno-query
npm run build --workspace=packages/bruno-common
npm run sandbox:bundle-libraries --workspace=packages/bruno-js
npm run build --workspace=packages/bruno-converters
npm run build --workspace=packages/bruno-requests
npm run build --workspace=packages/bruno-schema-types
npm run build --workspace=packages/bruno-filestore
- name: Run Local Testbench
run: |
npm start --workspace=packages/bruno-tests &
sleep 5
- name: Run tests
run: |
cd packages/bruno-tests/collection
npm install
node ../../bruno-cli/bin/bru.js run --env Prod --output junit.xml --format junit --sandbox developer
- name: Publish Test Report
uses: EnricoMi/publish-unit-test-result-action@v2
@@ -45,38 +107,46 @@ jobs:
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@v6
- uses: actions/checkout@v6
- uses: actions/setup-node@v5
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 System Dependencies (Ubuntu)
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
- name: Install dependencies for test collection environment
run: |
npm ci --prefix packages/bruno-tests/collection
- name: Setup Node Dependencies
uses: ./.github/actions/common/setup-node-deps
- 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
npm run build:schema-types
npm run build:bruno-filestore
- name: Configure Chrome Sandbox
run: |
sudo chown root node_modules/electron/dist/chrome-sandbox
sudo chmod 4755 node_modules/electron/dist/chrome-sandbox
- name: Run playwright Tests
uses: ./.github/actions/tests/run-e2e-tests
with:
os: ubuntu
- name: Upload Playwright Report
uses: actions/upload-artifact@v6
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30
- name: Run Playwright tests
run: |
xvfb-run npm run test:e2e
- uses: actions/upload-artifact@v6
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30

1
.gitignore vendored
View File

@@ -49,7 +49,6 @@ bruno.iml
.idea
.vscode
.cursor
.claude
# Playwright
/blob-report/

1293
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -23,7 +23,7 @@
"@eslint/compat": "^1.3.2",
"@faker-js/faker": "^7.6.0",
"@jest/globals": "^29.2.0",
"@opencollection/types": "~0.8.0",
"@opencollection/types": "~0.7.0",
"@playwright/test": "^1.51.1",
"@rollup/plugin-json": "^6.1.0",
"@storybook/addon-webpack5-compiler-babel": "^4.0.0",
@@ -100,7 +100,6 @@
}
},
"dependencies": {
"ajv": "^8.17.1",
"git-url-parse": "^14.1.0"
"ajv": "^8.17.1"
}
}

View File

@@ -1,8 +1,7 @@
module.exports = {
rootDir: '.',
transform: {
'^.+\\.[jt]sx?$': '<rootDir>/jest/transformers/babel-with-esm-replacements.cjs'
// '^.+\\.[jt]sx?$': [require("./jest/transformers/with-replacements.cjs"),'babel-jest']
'^.+\\.[jt]sx?$': 'babel-jest'
},
transformIgnorePatterns: [
'/node_modules/(?!strip-json-comments|nanoid|xml-formatter)/'

View File

@@ -1,8 +0,0 @@
const babelJest = require('babel-jest')
module.exports = {
process(sourceText, sourcePath, options) {
const transformer = babelJest.createTransformer();
return transformer.process(sourceText.replace(`import.meta.env.MODE`, 'test'), sourcePath, options)
}
};

View File

@@ -88,7 +88,7 @@
"shell-quote": "^1.8.3",
"strip-json-comments": "^5.0.1",
"styled-components": "^5.3.3",
"swagger-ui-react": "^5.31.0",
"swagger-ui-react": "5.17.12",
"system": "^2.0.1",
"url": "^0.11.3",
"xml-formatter": "^3.5.0",
@@ -130,4 +130,4 @@
"form-data": "4.0.4"
}
}
}
}

View File

@@ -4,12 +4,10 @@ import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react';
import toast from 'react-hot-toast';
import { useDispatch, useSelector } from 'react-redux';
import { savePreferences, showManageWorkspacePage, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
import { savePreferences, showHomePage, showManageWorkspacePage, toggleSidebarCollapse } from 'providers/ReduxStore/slices/app';
import { closeConsole, openConsole } from 'providers/ReduxStore/slices/logs';
import { createWorkspaceWithUniqueName, openWorkspaceDialog, switchWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
import { openWorkspaceDialog, switchWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
import { sortWorkspaces, toggleWorkspacePin } from 'utils/workspaces';
import { focusTab } from 'providers/ReduxStore/slices/tabs';
import get from 'lodash/get';
import Bruno from 'components/Bruno';
import MenuDropdown from 'ui/MenuDropdown';
@@ -131,10 +129,7 @@ const AppTitleBar = () => {
});
const handleHomeClick = () => {
const scratchCollectionUid = activeWorkspace?.scratchCollectionUid;
if (scratchCollectionUid) {
dispatch(focusTab({ uid: `${scratchCollectionUid}-overview` }));
}
dispatch(showHomePage());
};
const handleWorkspaceSwitch = (workspaceUid) => {
@@ -151,20 +146,9 @@ const AppTitleBar = () => {
}
};
const handleCreateWorkspace = useCallback(async () => {
const defaultLocation = get(preferences, 'general.defaultLocation', '');
if (!defaultLocation) {
setCreateWorkspaceModalOpen(true);
return;
}
try {
await dispatch(createWorkspaceWithUniqueName(defaultLocation));
toast.success('Workspace created!');
} catch (error) {
toast.error(error?.message || 'Failed to create workspace');
}
}, [preferences, dispatch]);
const handleCreateWorkspace = () => {
setCreateWorkspaceModalOpen(true);
};
const handleManageWorkspaces = () => {
dispatch(showManageWorkspacePage());
@@ -252,7 +236,7 @@ const AppTitleBar = () => {
);
return items;
}, [sortedWorkspaces, activeWorkspaceUid, preferences, handlePinWorkspace, handleCreateWorkspace]);
}, [sortedWorkspaces, activeWorkspaceUid, preferences, handlePinWorkspace]);
return (
<StyledWrapper className={`app-titlebar ${osClass} ${isFullScreen ? 'fullscreen' : ''}`}>

View File

@@ -16,7 +16,6 @@ import stripJsonComments from 'strip-json-comments';
import { getAllVariables } from 'utils/collections';
import { setupLinkAware } from 'utils/codemirror/linkAware';
import { setupLintErrorTooltip } from 'utils/codemirror/lint-errors';
import { setupShortcuts } from 'utils/codemirror/shortcuts';
import CodeMirrorSearch from 'components/CodeMirrorSearch/index';
const CodeMirror = require('codemirror');
@@ -47,9 +46,6 @@ export default class CodeEditor extends React.Component {
this.state = {
searchBarVisible: false
};
// Shortcuts cleanup function
this._shortcutsCleanup = null;
}
componentDidMount() {
@@ -221,9 +217,6 @@ export default class CodeEditor extends React.Component {
// Setup lint error tooltip on line number hover
this.cleanupLintErrorTooltip = setupLintErrorTooltip(editor);
// Setup keyboard shortcuts
this._shortcutsCleanup = setupShortcuts(editor, this);
}
}
@@ -240,18 +233,10 @@ export default class CodeEditor extends React.Component {
CodeMirror.signal(this.editor, 'change', this.editor);
}
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
// TODO: temporary fix for keeping cursor state when auto save and new line insertion collide PR#7098
const nextValue = this.props.value ?? '';
const currentValue = this.editor.getValue();
// Skip updating only when focused and editable; read-only editors (e.g. response viewer) must always show new value
if (this.editor.hasFocus?.() && currentValue !== nextValue && !this.props.readOnly) {
this.cachedValue = currentValue;
} else {
const cursor = this.editor.getCursor();
this.cachedValue = nextValue;
this.editor.setValue(nextValue);
this.editor.setCursor(cursor);
}
const cursor = this.editor.getCursor();
this.cachedValue = this.props.value;
this.editor.setValue(this.props.value);
this.editor.setCursor(cursor);
}
if (this.editor) {
@@ -295,12 +280,6 @@ export default class CodeEditor extends React.Component {
}
componentWillUnmount() {
// Cleanup shortcuts (keymap and store subscription)
if (this._shortcutsCleanup) {
this._shortcutsCleanup();
this._shortcutsCleanup = null;
}
if (this.editor) {
if (this.props.onScroll) {
this.props.onScroll(this.editor);

View File

@@ -8,44 +8,6 @@ function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');
}
const MAX_MATCHES = 99_999;
function findSearchMatches(editor, searchText, regex, caseSensitive, wholeWord) {
try {
let query, options = {};
if (regex) {
try {
query = new RegExp(searchText, caseSensitive ? 'g' : 'gi');
} catch (error) {
console.warn('Invalid regex provided in search!', error);
return [];
}
} else if (wholeWord) {
const escaped = escapeRegExp(searchText);
query = new RegExp(`\\b${escaped}\\b`, caseSensitive ? 'g' : 'gi');
} else {
query = searchText;
options = { caseFold: !caseSensitive };
}
const cursor = editor.getSearchCursor(query, { line: 0, ch: 0 }, options);
const out = [];
while (cursor.findNext()) {
out.push({ from: cursor.from(), to: cursor.to() });
if (out.length >= MAX_MATCHES) {
break;
}
}
return out;
} catch (e) {
console.error('Search error:', e);
return [];
}
}
function createCacheKey(editor, searchText, regex, caseSensitive, wholeWord) {
return `${editor.getValue().length}${searchText}${regex}${caseSensitive}${wholeWord}`;
}
const CodeMirrorSearch = forwardRef(({ visible, editor, onClose }, ref) => {
const [searchText, setSearchText] = useState('');
const [regex, setRegex] = useState(false);
@@ -57,15 +19,49 @@ const CodeMirrorSearch = forwardRef(({ visible, editor, onClose }, ref) => {
const searchMarks = useRef([]);
const searchLineHighlight = useRef(null);
const searchMatches = useRef([]);
const searchCacheKey = useRef('');
const inputRef = useRef(null);
const debouncedSearchText = useDebounce(searchText, 250);
const doSearch = useCallback((newIndex = 0) => {
if (!editor || !visible) {
return;
}
const debouncedSearchText = useDebounce(searchText, 150);
const memoizedMatches = useMemo(() => {
if (!editor || !visible) return [];
if (!debouncedSearchText) return [];
try {
let query, options = {};
if (regex) {
try {
query = new RegExp(debouncedSearchText, caseSensitive ? 'g' : 'gi');
} catch {
return [];
}
} else if (wholeWord) {
const escaped = escapeRegExp(debouncedSearchText);
query = new RegExp(`\\b${escaped}\\b`, caseSensitive ? 'g' : 'gi');
} else {
query = debouncedSearchText;
options = { caseFold: !caseSensitive };
}
const cursor = editor.getSearchCursor(query, { line: 0, ch: 0 }, options);
const out = [];
while (cursor.findNext()) {
out.push({ from: cursor.from(), to: cursor.to() });
}
return out;
} catch (e) {
console.error('Search error:', e);
return [];
}
}, [editor, visible, debouncedSearchText, regex, caseSensitive, wholeWord]);
const doSearch = useCallback((newIndex = 0) => {
if (!editor) return;
// Clear previous marks
searchMarks.current.forEach((mark) => mark.clear());
searchMarks.current = [];
// Clear previous line highlight
if (searchLineHighlight.current !== null) {
editor.removeLineClass(searchLineHighlight.current, 'wrap', 'cm-search-line-highlight');
searchLineHighlight.current = null;
@@ -75,89 +71,41 @@ const CodeMirrorSearch = forwardRef(({ visible, editor, onClose }, ref) => {
setMatchCount(0);
setMatchIndex(0);
searchMatches.current = [];
searchMarks.current.forEach((mark) => mark.clear());
searchMarks.current = [];
return;
}
try {
const newCacheKey = createCacheKey(editor, debouncedSearchText, regex, caseSensitive, wholeWord);
const isCacheHit = newCacheKey === searchCacheKey.current;
let matches = searchMatches.current;
if (!isCacheHit) {
matches = findSearchMatches(editor, debouncedSearchText, regex, caseSensitive, wholeWord);
searchMatches.current = matches;
searchCacheKey.current = newCacheKey;
setMatchCount(matches.length);
}
if (!matches.length) {
setMatchIndex(0);
// Clear previous marks
searchMarks.current.forEach((mark) => mark.clear());
searchMarks.current = [];
return;
}
const matchIndex = Math.max(0, Math.min(newIndex, matches.length - 1));
setMatchIndex(matchIndex);
if (isCacheHit) {
// Clear only old current mark
const oldIndex = searchMarks.current.findIndex((mark) => mark.className?.includes('cm-search-current'));
if (oldIndex !== -1) {
searchMarks.current[oldIndex].clear();
searchMarks.current.splice(oldIndex, 1);
}
// Add mark to the new current and remark the previous and next
const toMark = [
// Previous
matchIndex > 0 ? matchIndex - 1 : null,
// Current
matchIndex,
// Next
matchIndex < matches.length - 1 ? matchIndex + 1 : null
].filter((i) => i !== null);
toMark.forEach((i) => {
const mark = editor.markText(matches[i].from, matches[i].to, {
className: i === matchIndex ? 'cm-search-current' : 'cm-search-match',
clearOnEnter: true
});
searchMarks.current.push(mark);
const matches = memoizedMatches;
let matchIndex = matches.length ? Math.max(0, Math.min(newIndex, matches.length - 1)) : 0;
matches.forEach((m, i) => {
const mark = editor.markText(m.from, m.to, {
className: i === matchIndex ? 'cm-search-current' : 'cm-search-match',
clearOnEnter: true
});
searchMarks.current.push(mark);
});
if (matches.length) {
const currentLine = matches[matchIndex].from.line;
editor.addLineClass(currentLine, 'wrap', 'cm-search-line-highlight');
searchLineHighlight.current = currentLine;
editor.scrollIntoView(matches[matchIndex].from, 100);
editor.setSelection(matches[matchIndex].from, matches[matchIndex].to);
} else {
// Clear previous marks
searchMarks.current.forEach((mark) => mark.clear());
searchMarks.current = [];
// Mark all on new search
matches.forEach((m, i) => {
const mark = editor.markText(m.from, m.to, {
className: i === matchIndex ? 'cm-search-current' : 'cm-search-match',
clearOnEnter: true
});
searchMarks.current.push(mark);
});
searchLineHighlight.current = null;
}
const currentLine = matches[matchIndex].from.line;
editor.addLineClass(currentLine, 'wrap', 'cm-search-line-highlight');
searchLineHighlight.current = currentLine;
editor.scrollIntoView(matches[matchIndex].from, 100);
editor.setSelection(matches[matchIndex].from, matches[matchIndex].to);
setMatchCount(matches.length);
setMatchIndex(matchIndex);
searchMatches.current = matches;
} catch (e) {
console.error('Search error:', e);
setMatchCount(0);
setMatchIndex(0);
searchMatches.current = [];
searchCacheKey.current = '';
}
}, [debouncedSearchText, regex, caseSensitive, wholeWord, editor, visible]);
}, [debouncedSearchText, regex, caseSensitive, wholeWord, editor, memoizedMatches]);
useImperativeHandle(ref, () => ({
focus: () => {
@@ -168,7 +116,7 @@ const CodeMirrorSearch = forwardRef(({ visible, editor, onClose }, ref) => {
}));
useEffect(() => {
doSearch(0);
doSearch(0, debouncedSearchText);
}, [debouncedSearchText, doSearch]);
const handleSearchBarClose = useCallback(() => {
@@ -179,7 +127,6 @@ const CodeMirrorSearch = forwardRef(({ visible, editor, onClose }, ref) => {
searchLineHighlight.current = null;
}
searchMatches.current = [];
searchCacheKey.current = '';
if (onClose) onClose();
// Focus the editor after closing the search bar
if (editor) {
@@ -195,27 +142,32 @@ const CodeMirrorSearch = forwardRef(({ visible, editor, onClose }, ref) => {
const handleToggleRegex = () => {
setRegex((prev) => !prev);
setMatchIndex(0);
doSearch(0);
};
const handleToggleCase = () => {
setCaseSensitive((prev) => !prev);
setMatchIndex(0);
doSearch(0);
};
const handleToggleWholeWord = () => {
setWholeWord((prev) => !prev);
setMatchIndex(0);
doSearch(0);
};
const handleNext = () => {
if (!searchMatches.current || !searchMatches.current.length) return;
const next = (matchIndex + 1) % searchMatches.current.length;
let next = (matchIndex + 1) % searchMatches.current.length;
setMatchIndex(next);
doSearch(next);
};
const handlePrev = () => {
if (!searchMatches.current || !searchMatches.current.length) return;
const prev = (matchIndex - 1 + searchMatches.current.length) % searchMatches.current.length;
let prev = (matchIndex - 1 + searchMatches.current.length) % searchMatches.current.length;
setMatchIndex(prev);
doSearch(prev);
};

View File

@@ -7,7 +7,6 @@ import { saveCollectionSettings } from 'providers/ReduxStore/slices/collections/
import { useTheme } from 'providers/Theme';
import { Tabs, TabsList, TabsTrigger, TabsContent } from 'components/Tabs';
import StatusDot from 'components/StatusDot';
import { flattenItems, isItemARequest } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
import Button from 'ui/Button';
@@ -74,10 +73,6 @@ const Script = ({ collection }) => {
dispatch(saveCollectionSettings(collection.uid));
};
const items = flattenItems(collection.items || []);
const hasPreRequestScriptError = items.some((i) => isItemARequest(i) && i.preRequestScriptErrorMessage);
const hasPostResponseScriptError = items.some((i) => isItemARequest(i) && i.postResponseScriptErrorMessage);
return (
<StyledWrapper className="w-full flex flex-col h-full">
<div className="text-xs mb-4 text-muted">
@@ -88,15 +83,11 @@ const Script = ({ collection }) => {
<TabsList>
<TabsTrigger value="pre-request">
Pre Request
{requestScript && requestScript.trim().length > 0 && (
<StatusDot type={hasPreRequestScriptError ? 'error' : 'default'} />
)}
{requestScript && requestScript.trim().length > 0 && <StatusDot />}
</TabsTrigger>
<TabsTrigger value="post-response">
Post Response
{responseScript && responseScript.trim().length > 0 && (
<StatusDot type={hasPostResponseScriptError ? 'error' : 'default'} />
)}
{responseScript && responseScript.trim().length > 0 && <StatusDot />}
</TabsTrigger>
</TabsList>

View File

@@ -1,17 +1,21 @@
import React from 'react';
import { useTheme } from 'providers/Theme';
const ColorBadge = ({ color, size = 10 }) => {
const ColorBadge = ({ color, size = 10, showEmptyBorder = true }) => {
const sizeValue = typeof size === 'string' ? size : `${size}px`;
const { theme } = useTheme();
const showBorder = !color && showEmptyBorder;
return (
<div
className="flex-shrink-0 rounded-full"
style={{
width: sizeValue,
height: sizeValue,
backgroundColor: color || 'transparent'
backgroundColor: color || 'transparent',
border: showBorder ? '1px solid' : 'none',
borderColor: showBorder ? theme.background.surface1 : 'transparent'
}}
/>
);

View File

@@ -134,15 +134,15 @@ const ColorPicker = ({ color, onChange, icon }) => {
))}
</div>
<div className="flex items-center gap-2 mt-2 pt-0.5">
<div className="flex items-center gap-2 mt-2 pt-2">
<div
className="w-5 h-5 rounded-full flex-shrink-0 cursor-pointer"
className="w-4 h-4 rounded-full flex-shrink-0 cursor-pointer"
style={{ backgroundColor: customColor }}
onClick={() => handleColorSelect(customColor)}
title="Custom color"
/>
<ColorRangePicker
className="flex-1 flex"
className="flex-1"
value={sliderPosition}
onChange={handleSliderChange}
onMouseUp={handleSliderEnd}

View File

@@ -4,7 +4,6 @@ const StyledWrapper = styled.div`
.hue-slider {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 4px;
border-radius: 2px;
outline: none;

View File

@@ -2,14 +2,14 @@ import StyledWrapper from './StyledWrapper';
const ColorRangePicker = ({ selectedColor, className, value, onChange, colorRange, ...props }) => {
return (
<StyledWrapper color={selectedColor} className={className}>
<StyledWrapper color={selectedColor}>
<input
type="range"
min="0"
max="100"
value={value}
onChange={onChange}
className="hue-slider"
className={`hue-slider ${className}`}
style={{
background: `linear-gradient(to right, ${colorRange.join(',')})`
}}

View File

@@ -9,7 +9,6 @@ import { useDispatch, useSelector } from 'react-redux';
import { flattenItems, isItemARequest, isItemTransientRequest } from 'utils/collections';
import filter from 'lodash/filter';
import { get } from 'lodash';
import { formatIpcError } from 'utils/common/error';
const REQUEST_TYPE = {
HTTP: 'http',
@@ -58,7 +57,7 @@ const CreateTransientRequest = ({ collectionUid }) => {
const collection = useMemo(() => {
return collections?.find((c) => c.uid === collectionUid);
}, [collections, collectionUid]);
}, [collections]);
const collectionPresets = useMemo(() => {
return get(collection, collection?.draft?.brunoConfig ? 'draft.brunoConfig.presets' : 'brunoConfig.presets', {
@@ -104,7 +103,7 @@ const CreateTransientRequest = ({ collectionUid }) => {
itemUid: null,
isTransient: true
})
).catch((err) => toast.error(formatIpcError(err) || 'An error occurred while adding the request'));
).catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
}, [dispatch, collection, collectionPresets.requestUrl]);
const handleCreateGraphQLRequest = useCallback(() => {
@@ -131,7 +130,7 @@ const CreateTransientRequest = ({ collectionUid }) => {
}
}
})
).catch((err) => toast.error(formatIpcError(err) || 'An error occurred while adding the request'));
).catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
}, [dispatch, collection, collectionPresets.requestUrl]);
const handleCreateWebSocketRequest = useCallback(() => {
@@ -150,7 +149,7 @@ const CreateTransientRequest = ({ collectionUid }) => {
itemUid: null,
isTransient: true
})
).catch((err) => toast.error(formatIpcError(err) || 'An error occurred while adding the request'));
).catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
}, [dispatch, collection, collectionPresets.requestUrl]);
const handleCreateGrpcRequest = useCallback(() => {
@@ -168,7 +167,7 @@ const CreateTransientRequest = ({ collectionUid }) => {
itemUid: null,
isTransient: true
})
).catch((err) => toast.error(formatIpcError(err) || 'An error occurred while adding the request'));
).catch((err) => toast.error(err ? err.message : 'An error occurred while adding the request'));
}, [dispatch, collection, collectionPresets.requestUrl]);
const handleItemClick = (type) => {

View File

@@ -64,89 +64,6 @@ const LogTimestamp = ({ timestamp }) => {
return <span className="log-timestamp">{time}</span>;
};
// Helper function to check if an object is a plain object (not a class instance)
const isPlainObject = (obj) => {
if (typeof obj !== 'object' || obj === null) return false;
const proto = Object.getPrototypeOf(obj);
return proto === null || proto === Object.prototype;
};
// Helper function to transform Bruno special types back to readable format
// Extracted outside component to avoid recreation on every render
const transformBrunoTypes = (obj, seen = new WeakSet()) => {
if (typeof obj !== 'object' || obj === null) {
return obj;
}
// Guard against circular references
if (seen.has(obj)) {
return '[Circular]';
}
seen.add(obj);
// Handle Bruno special types
if (obj.__brunoType) {
switch (obj.__brunoType) {
case 'Set':
// Transform Set to display values at top level with numeric indices
if (Array.isArray(obj.__brunoValue)) {
return Object.fromEntries(
obj.__brunoValue.map((value, index) => [index, transformBrunoTypes(value, seen)])
);
}
return {};
case 'Map':
// Transform Map to display entries at top level with => notation
if (Array.isArray(obj.__brunoValue)) {
const mapEntries = {};
for (const entry of obj.__brunoValue) {
// Defensive check: ensure entry is a valid [key, value] pair
if (Array.isArray(entry) && entry.length >= 2) {
const [key, value] = entry;
mapEntries[`${String(key)} =>`] = transformBrunoTypes(value, seen);
}
}
return mapEntries;
}
return {};
case 'Function':
return `[Function: ${obj.__brunoValue?.split?.('\n')?.[0]?.substring(0, 50) ?? 'anonymous'}...]`;
case 'undefined':
return 'undefined';
default:
return obj;
}
}
// Handle arrays - recurse into elements
if (Array.isArray(obj)) {
return obj.map((item) => transformBrunoTypes(item, seen));
}
// Preserve non-plain objects (Date, Error, RegExp, class instances, etc.)
if (!isPlainObject(obj)) {
return obj;
}
// Only deep-clone plain objects
const transformed = {};
for (const [key, value] of Object.entries(obj)) {
transformed[key] = transformBrunoTypes(value, seen);
}
return transformed;
};
// Helper to get metadata about Bruno types for display purposes
const getBrunoTypeMetadata = (obj) => {
if (typeof obj !== 'object' || obj === null) {
return {};
}
if (obj.__brunoType === 'Set' || obj.__brunoType === 'Map') {
return { type: obj.__brunoType };
}
return {};
};
const LogMessage = ({ message, args }) => {
const { displayedTheme } = useTheme();
@@ -154,30 +71,18 @@ const LogMessage = ({ message, args }) => {
if (originalArgs && originalArgs.length > 0) {
return originalArgs.map((arg, index) => {
if (typeof arg === 'object' && arg !== null) {
const metadata = getBrunoTypeMetadata(arg);
const transformedArg = transformBrunoTypes(arg);
// Determine the name to display based on the type
let displayName = false;
let shouldCollapse = 1; // Default: collapse at depth 1 for regular objects
if (metadata.type === 'Map' || metadata.type === 'Set') {
displayName = metadata.type;
shouldCollapse = true; // Fully collapse Maps/Sets by default
}
return (
<div key={index} className="log-object">
<ReactJson
src={transformedArg}
src={arg}
theme={displayedTheme === 'light' ? 'rjv-default' : 'monokai'}
iconStyle="triangle"
indentWidth={2}
collapsed={shouldCollapse}
collapsed={1}
displayDataTypes={false}
displayObjectSize={false}
enableClipboard={false}
name={displayName}
name={false}
style={{
backgroundColor: 'transparent',
fontSize: '${(props) => props.theme.font.size.sm}',

View File

@@ -85,17 +85,6 @@ const Wrapper = styled.div`
justify-content: center;
}
.dropdown-tab-count {
margin-left: auto;
font-size: 11px;
font-weight: 500;
padding: 1px 6px;
border-radius: 10px;
background: ${(props) => props.theme.dropdown.hoverBg};
min-width: 18px;
text-align: center;
}
&:hover:not(:disabled):not(.disabled) {
background-color: ${(props) => props.theme.dropdown.hoverBg};
}

View File

@@ -129,7 +129,6 @@ const StyledWrapper = styled.div`
text-align: center;
vertical-align: middle;
line-height: 1;
text-overflow: clip;
input[type='checkbox'] {
vertical-align: baseline;
@@ -139,9 +138,6 @@ const StyledWrapper = styled.div`
.tooltip-mod {
max-width: 200px !important;
word-wrap: break-word !important;
overflow-wrap: break-word !important;
white-space: normal !important;
}
input[type='text'] {

View File

@@ -96,36 +96,6 @@ const Wrapper = styled.div`
max-width: 200px !important;
}
.name-cell-wrapper {
position: relative;
width: 100%;
.name-highlight-overlay {
position: absolute;
inset: 0;
pointer-events: none;
white-space: pre;
overflow: hidden;
font-size: inherit;
line-height: inherit;
color: ${(props) => props.theme.text};
}
}
.search-highlight {
background: ${(props) => props.theme.colors.accent}55;
color: inherit;
border-radius: 2px;
padding: 0 1px;
}
.no-results {
padding: 24px;
text-align: center;
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.muted};
}
input[type='text'] {
width: 100%;
border: 1px solid transparent;

View File

@@ -13,7 +13,6 @@ import { variableNameRegex } from 'utils/common/regex';
import toast from 'react-hot-toast';
import { Tooltip } from 'react-tooltip';
import { getGlobalEnvironmentVariables } from 'utils/collections';
import { stripEnvVarUid } from 'utils/environments';
const MIN_H = 35 * 2;
const MIN_COLUMN_WIDTH = 80;
@@ -31,15 +30,6 @@ const TableRow = React.memo(
}
);
const highlightText = (text, query) => {
if (!query?.trim() || !text) return text;
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
const parts = text.split(regex);
return parts.map((part, i) =>
regex.test(part) ? <mark key={i} className="search-highlight">{part}</mark> : part
);
};
const EnvironmentVariablesTable = ({
environment,
collection,
@@ -51,8 +41,7 @@ const EnvironmentVariablesTable = ({
renderExtraValueContent,
searchQuery = ''
}) => {
const { storedTheme, theme } = useTheme();
const valueMatchBg = theme?.colors?.accent ? `${theme.colors.accent}1a` : undefined;
const { storedTheme } = useTheme();
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
const hasDraftForThisEnv = draft?.environmentUid === environment.uid;
@@ -60,7 +49,6 @@ const EnvironmentVariablesTable = ({
const [tableHeight, setTableHeight] = useState(MIN_H);
const [columnWidths, setColumnWidths] = useState({ name: '30%', value: 'auto' });
const [resizing, setResizing] = useState(null);
const [focusedNameIndex, setFocusedNameIndex] = useState(null);
const handleResizeStart = useCallback((e, columnKey) => {
e.preventDefault();
@@ -104,7 +92,6 @@ const EnvironmentVariablesTable = ({
}, []);
const prevEnvUidRef = useRef(null);
const prevEnvVariablesRef = useRef(environment.variables);
const mountedRef = useRef(false);
let _collection = collection ? cloneDeep(collection) : {};
@@ -180,13 +167,11 @@ const EnvironmentVariablesTable = ({
useEffect(() => {
const isMount = !mountedRef.current;
const envChanged = prevEnvUidRef.current !== null && prevEnvUidRef.current !== environment.uid;
const variablesReloaded = !isMount && !envChanged && prevEnvVariablesRef.current !== environment.variables;
prevEnvUidRef.current = environment.uid;
prevEnvVariablesRef.current = environment.variables;
mountedRef.current = true;
if ((isMount || envChanged || variablesReloaded) && hasDraftForThisEnv && draft?.variables) {
if ((isMount || envChanged) && hasDraftForThisEnv && draft?.variables) {
formik.setValues([
...draft.variables,
{
@@ -199,16 +184,16 @@ const EnvironmentVariablesTable = ({
}
]);
}
}, [environment.uid, environment.variables, hasDraftForThisEnv, draft?.variables]);
}, [environment.uid, hasDraftForThisEnv, draft?.variables]);
const savedValuesJson = useMemo(() => {
return JSON.stringify((environment.variables || []).map(stripEnvVarUid));
return JSON.stringify(environment.variables || []);
}, [environment.variables]);
// Sync modified state
useEffect(() => {
const currentValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
const currentValuesJson = JSON.stringify(currentValues.map(stripEnvVarUid));
const currentValuesJson = JSON.stringify(currentValues);
const hasActualChanges = currentValuesJson !== savedValuesJson;
setIsModified(hasActualChanges);
}, [formik.values, savedValuesJson, setIsModified]);
@@ -217,11 +202,11 @@ const EnvironmentVariablesTable = ({
useEffect(() => {
const timeoutId = setTimeout(() => {
const currentValues = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
const currentValuesJson = JSON.stringify(currentValues.map(stripEnvVarUid));
const currentValuesJson = JSON.stringify(currentValues);
const hasActualChanges = currentValuesJson !== savedValuesJson;
const existingDraftVariables = hasDraftForThisEnv ? draft?.variables : null;
const existingDraftJson = existingDraftVariables ? JSON.stringify(existingDraftVariables.map(stripEnvVarUid)) : null;
const existingDraftJson = existingDraftVariables ? JSON.stringify(existingDraftVariables) : null;
if (hasActualChanges) {
if (currentValuesJson !== existingDraftJson) {
@@ -333,8 +318,7 @@ const EnvironmentVariablesTable = ({
const variablesToSave = formik.values.filter((variable) => variable.name && variable.name.trim() !== '');
const savedValues = environment.variables || [];
// Compare without UIDs since they can be different but the actual data is the same
const hasChanges = JSON.stringify(variablesToSave.map(stripEnvVarUid)) !== JSON.stringify(savedValues.map(stripEnvVarUid));
const hasChanges = JSON.stringify(variablesToSave) !== JSON.stringify(savedValues);
if (!hasChanges) {
toast.error('No changes to save');
return;
@@ -418,160 +402,132 @@ const EnvironmentVariablesTable = ({
const query = searchQuery.toLowerCase().trim();
return allVariables.filter(({ variable }) => {
return allVariables.filter(({ variable, index }) => {
const isLastRow = index === formik.values.length - 1;
const isEmptyRow = !variable.name || variable.name.trim() === '';
if (isLastRow && isEmptyRow) {
return true;
}
const nameMatch = variable.name ? variable.name.toLowerCase().includes(query) : false;
const valueMatch = typeof variable.value === 'string' ? variable.value.toLowerCase().includes(query) : false;
return !!(nameMatch || valueMatch);
});
}, [formik.values, searchQuery]);
const isSearchActive = !!searchQuery?.trim();
return (
<StyledWrapper className={resizing ? 'is-resizing' : ''}>
{isSearchActive && filteredVariables.length === 0 ? (
<div className="no-results">No results found for &ldquo;{searchQuery.trim()}&rdquo;</div>
) : (
<TableVirtuoso
className="table-container"
style={{ height: tableHeight }}
components={{ TableRow }}
data={filteredVariables}
totalListHeightChanged={handleTotalHeightChanged}
fixedHeaderContent={() => (
<tr>
<td className="text-center"></td>
<td style={{ width: columnWidths.name }}>
Name
<div
className={`resize-handle ${resizing === 'name' ? 'resizing' : ''}`}
style={{ height: tableHeight > 0 ? `${tableHeight}px` : undefined }}
onMouseDown={(e) => handleResizeStart(e, 'name')}
/>
</td>
<td style={{ width: columnWidths.value }}>Value</td>
<td className="text-center">Secret</td>
<td></td>
</tr>
)}
fixedItemHeight={35}
computeItemKey={(virtualIndex, item) => `${environment.uid}-${item.index}`}
itemContent={(virtualIndex, { variable, index: actualIndex }) => {
const isLastRow = actualIndex === formik.values.length - 1;
const isEmptyRow = !variable.name || variable.name.trim() === '';
const isLastEmptyRow = isLastRow && isEmptyRow;
const activeQuery = searchQuery?.trim().toLowerCase();
const valueMatchesOnly = activeQuery
&& !(variable.name?.toLowerCase().includes(activeQuery))
&& typeof variable.value === 'string'
&& variable.value.toLowerCase().includes(activeQuery);
<TableVirtuoso
className="table-container"
style={{ height: tableHeight }}
components={{ TableRow }}
data={filteredVariables}
totalListHeightChanged={handleTotalHeightChanged}
fixedHeaderContent={() => (
<tr>
<td className="text-center"></td>
<td style={{ width: columnWidths.name }}>
Name
<div
className={`resize-handle ${resizing === 'name' ? 'resizing' : ''}`}
style={{ height: tableHeight > 0 ? `${tableHeight}px` : undefined }}
onMouseDown={(e) => handleResizeStart(e, 'name')}
/>
</td>
<td style={{ width: columnWidths.value }}>Value</td>
<td className="text-center">Secret</td>
<td></td>
</tr>
)}
fixedItemHeight={35}
computeItemKey={(index, item) => item.variable.uid}
itemContent={(index, { variable, index: actualIndex }) => {
const isLastRow = actualIndex === formik.values.length - 1;
const isEmptyRow = !variable.name || variable.name.trim() === '';
const isLastEmptyRow = isLastRow && isEmptyRow;
return (
<>
<td className="text-center">
{!isLastEmptyRow && (
<input
type="checkbox"
className="mousetrap"
name={`${actualIndex}.enabled`}
checked={variable.enabled}
onChange={isSearchActive ? undefined : formik.handleChange}
disabled={isSearchActive}
return (
<>
<td className="text-center">
{!isLastEmptyRow && (
<input
type="checkbox"
className="mousetrap"
name={`${actualIndex}.enabled`}
checked={variable.enabled}
onChange={formik.handleChange}
/>
)}
</td>
<td style={{ width: columnWidths.name }}>
<div className="flex items-center">
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
className="mousetrap"
id={`${actualIndex}.name`}
name={`${actualIndex}.name`}
value={variable.name}
placeholder={!variable.value || (typeof variable.value === 'string' && variable.value.trim() === '') ? 'Value' : ''}
onChange={(e) => handleNameChange(actualIndex, e)}
onBlur={() => handleNameBlur(actualIndex)}
onKeyDown={(e) => handleNameKeyDown(actualIndex, e)}
/>
<ErrorMessage name={`${actualIndex}.name`} index={actualIndex} />
</div>
</td>
<td className="flex flex-row flex-nowrap items-center" style={{ width: columnWidths.value }}>
<div className="overflow-hidden grow w-full relative">
<MultiLineEditor
theme={storedTheme}
collection={_collection}
name={`${actualIndex}.value`}
value={variable.value}
placeholder={isLastEmptyRow ? 'Value' : ''}
isSecret={variable.secret}
readOnly={typeof variable.value !== 'string'}
onChange={(newValue) => formik.setFieldValue(`${actualIndex}.value`, newValue, true)}
onSave={handleSave}
/>
</div>
{typeof variable.value !== 'string' && (
<span className="ml-2 flex items-center">
<IconInfoCircle id={`${variable.uid}-disabled-info-icon`} className="text-muted" size={16} />
<Tooltip
anchorId={`${variable.uid}-disabled-info-icon`}
content="Non-string values set via scripts are read-only and can only be updated through scripts."
place="top"
/>
)}
</td>
<td style={{ width: columnWidths.name }}>
<div className="flex items-center">
<div className="name-cell-wrapper">
<input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
className="mousetrap"
id={`${actualIndex}.name`}
name={`${actualIndex}.name`}
value={variable.name}
placeholder={!variable.value || (typeof variable.value === 'string' && variable.value.trim() === '') ? 'Name' : ''}
readOnly={isSearchActive}
onChange={isSearchActive ? undefined : (e) => handleNameChange(actualIndex, e)}
onFocus={() => !isSearchActive && setFocusedNameIndex(actualIndex)}
onBlur={() => {
setFocusedNameIndex(null); if (!isSearchActive) handleNameBlur(actualIndex);
}}
onKeyDown={isSearchActive ? undefined : (e) => handleNameKeyDown(actualIndex, e)}
style={searchQuery?.trim() && focusedNameIndex !== actualIndex ? { color: 'transparent' } : undefined}
/>
{searchQuery?.trim() && focusedNameIndex !== actualIndex && (
<div className="name-highlight-overlay">
{highlightText(variable.name || '', searchQuery)}
</div>
)}
</div>
<ErrorMessage name={`${actualIndex}.name`} index={actualIndex} />
</div>
</td>
<td
className="flex flex-row flex-nowrap items-center"
style={{ width: columnWidths.value, ...(valueMatchesOnly && valueMatchBg ? { background: valueMatchBg } : {}) }}
>
<div className="overflow-hidden grow w-full relative">
<MultiLineEditor
theme={storedTheme}
collection={_collection}
name={`${actualIndex}.value`}
value={variable.value}
placeholder={isLastEmptyRow ? 'Value' : ''}
isSecret={variable.secret}
readOnly={isSearchActive || typeof variable.value !== 'string'}
onChange={(newValue) => {
formik.setFieldValue(`${actualIndex}.value`, newValue, true);
// Clear ephemeral metadata when user manually edits the value
if (variable.ephemeral) {
formik.setFieldValue(`${actualIndex}.ephemeral`, undefined, false);
formik.setFieldValue(`${actualIndex}.persistedValue`, undefined, false);
}
}}
onSave={handleSave}
/>
</div>
{typeof variable.value !== 'string' && (
<span className="ml-2 flex items-center">
<IconInfoCircle id={`${variable.uid}-disabled-info-icon`} className="text-muted" size={16} />
<Tooltip
anchorId={`${variable.uid}-disabled-info-icon`}
content="Non-string values set via scripts are read-only and can only be updated through scripts."
place="top"
/>
</span>
)}
{renderExtraValueContent && renderExtraValueContent(variable)}
</td>
<td className="text-center">
{!isLastEmptyRow && (
<input
type="checkbox"
className="mousetrap"
name={`${actualIndex}.secret`}
checked={variable.secret}
onChange={isSearchActive ? undefined : formik.handleChange}
disabled={isSearchActive}
/>
)}
</td>
<td>
{!isLastEmptyRow && (
<button onClick={isSearchActive ? undefined : () => handleRemoveVar(variable.uid)} disabled={isSearchActive}>
<IconTrash strokeWidth={1.5} size={18} />
</button>
)}
</td>
</>
);
}}
/>
)}
</span>
)}
{renderExtraValueContent && renderExtraValueContent(variable)}
</td>
<td className="text-center">
{!isLastEmptyRow && (
<input
type="checkbox"
className="mousetrap"
name={`${actualIndex}.secret`}
checked={variable.secret}
onChange={formik.handleChange}
/>
)}
</td>
<td>
{!isLastEmptyRow && (
<button onClick={() => handleRemoveVar(variable.uid)}>
<IconTrash strokeWidth={1.5} size={18} />
</button>
)}
</td>
</>
);
}}
/>
<div className="button-container">
<div className="flex items-center">

View File

@@ -55,7 +55,6 @@ const StyledWrapper = styled.div`
}
.section-title {
padding-right: 4px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;

View File

@@ -46,8 +46,8 @@ const ImportEnvironmentModal = ({ type = 'collection', collection, onClose, onEn
let importedCount = 0;
for (const environment of validEnvironments) {
const action = isGlobal
? addGlobalEnvironment({ name: environment.name, variables: environment.variables, color: environment.color })
: importEnvironment({ name: environment.name, variables: environment.variables, color: environment.color, collectionUid: collection?.uid });
? addGlobalEnvironment({ name: environment.name, variables: environment.variables })
: importEnvironment({ name: environment.name, variables: environment.variables, collectionUid: collection?.uid });
await dispatch(action);
importedCount++;

View File

@@ -4,14 +4,7 @@ import Modal from 'components/Modal';
import Portal from 'components/Portal';
import Button from 'ui/Button';
const ConfirmCloseEnvironment = ({ onCancel, onCloseWithoutSave, onSaveAndClose, isGlobal, isDotEnv }) => {
let settingsLabel = 'collection environment settings';
if (isDotEnv) {
settingsLabel = '.env file';
} else if (isGlobal) {
settingsLabel = 'global environment settings';
}
const ConfirmCloseEnvironment = ({ onCancel, onCloseWithoutSave, onSaveAndClose, isGlobal }) => {
return (
<Portal>
<Modal
@@ -28,7 +21,7 @@ const ConfirmCloseEnvironment = ({ onCancel, onCloseWithoutSave, onSaveAndClose,
<h1 className="ml-2 text-lg font-medium">Hold on...</h1>
</div>
<div className="font-normal mt-4">
You have unsaved changes in {settingsLabel}.
You have unsaved changes in {isGlobal ? 'global' : 'collection'} environment settings.
</div>
<div className="flex justify-between mt-6">

View File

@@ -217,12 +217,10 @@ const DotEnvFileEditor = ({
];
formik.resetForm({ values: newValues });
setIsModified(false);
window.dispatchEvent(new Event('dotenv-save-complete'));
})
.catch((error) => {
console.error(error);
toast.error('An error occurred while saving the changes');
window.dispatchEvent(new Event('dotenv-save-failed'));
})
.finally(() => {
setIsSaving(false);
@@ -242,12 +240,10 @@ const DotEnvFileEditor = ({
.then(() => {
toast.success('Changes saved successfully');
setIsModified(false);
window.dispatchEvent(new Event('dotenv-save-complete'));
})
.catch((error) => {
console.error(error);
toast.error('An error occurred while saving the changes');
window.dispatchEvent(new Event('dotenv-save-failed'));
})
.finally(() => {
setIsSaving(false);

View File

@@ -39,7 +39,7 @@ const EnvironmentListContent = ({
data-tooltip-content={env.name}
data-tooltip-hidden={env.name?.length < 90}
>
<ColorBadge color={env.color} size={8} />
<ColorBadge color={env.color} size={8} showEmptyBorder={false} />
<span className="max-w-100% truncate no-wrap">{env.name}</span>
</div>
))}

View File

@@ -1,6 +1,7 @@
import { IconCopy, IconEdit, IconTrash, IconCheck, IconX, IconSearch } from '@tabler/icons';
import { useState, useRef } from 'react';
import { useDispatch } from 'react-redux';
import useDebounce from 'hooks/useDebounce';
import { renameEnvironment, updateEnvironmentColor } from 'providers/ReduxStore/slices/collections/actions';
import { validateName, validateNameError } from 'utils/common/regex';
import toast from 'react-hot-toast';
@@ -10,7 +11,7 @@ import EnvironmentVariables from './EnvironmentVariables';
import ColorPicker from 'components/ColorPicker';
import StyledWrapper from './StyledWrapper';
const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuery, setSearchQuery, isSearchExpanded, setIsSearchExpanded, debouncedSearchQuery, searchInputRef }) => {
const EnvironmentDetails = ({ environment, setIsModified, collection }) => {
const dispatch = useDispatch();
const environments = collection?.environments || [];
@@ -19,7 +20,11 @@ const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuer
const [isRenaming, setIsRenaming] = useState(false);
const [newName, setNewName] = useState('');
const [nameError, setNameError] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [isSearchExpanded, setIsSearchExpanded] = useState(false);
const debouncedSearchQuery = useDebounce(searchQuery, 300);
const inputRef = useRef(null);
const searchInputRef = useRef(null);
const validateEnvironmentName = (name) => {
if (!name || name.trim() === '') {
@@ -130,7 +135,13 @@ const EnvironmentDetails = ({ environment, setIsModified, collection, searchQuer
};
const handleColorChange = (color) => {
dispatch(updateEnvironmentColor(environment.uid, color, collection.uid));
dispatch(updateEnvironmentColor(environment.uid, color, collection.uid))
.then(() => {
toast.success('Environment color updated!');
})
.catch(() => {
toast.error('An error occurred while updating the environment color');
});
};
return (

View File

@@ -32,6 +32,19 @@ const StyledWrapper = styled.div`
flex-direction: column;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 16px 12px 16px;
.title {
font-size: ${(props) => props.theme.font.size.base};
font-weight: 500;
color: ${(props) => props.theme.text};
margin: 0;
}
.btn-action {
display: flex;
align-items: center;
@@ -53,54 +66,35 @@ const StyledWrapper = styled.div`
}
}
.env-list-search {
.search-container {
position: relative;
display: flex;
align-items: center;
margin: 0 4px 6px 4px;
.env-list-search-icon {
padding: 0 12px 12px 12px;
.search-icon {
position: absolute;
left: 8px;
left: 20px;
top: 50%;
transform: translateY(-100%);
color: ${(props) => props.theme.colors.text.muted};
pointer-events: none;
}
.env-list-search-input {
.search-input {
width: 100%;
padding: 5px 24px 5px 26px;
padding: 6px 8px 6px 28px;
font-size: 12px;
background: transparent;
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: 5px;
color: ${(props) => props.theme.text};
transition: border-color 0.15s ease;
transition: all 0.15s ease;
&::placeholder {
color: ${(props) => props.theme.colors.text.muted};
}
&:focus {
outline: none;
border-color: ${(props) => props.theme.colors.accent};
}
}
.env-list-search-clear {
position: absolute;
right: 4px;
display: flex;
align-items: center;
justify-content: center;
padding: 2px;
background: transparent;
border: none;
cursor: pointer;
color: ${(props) => props.theme.colors.text.muted};
border-radius: 3px;
&:hover {
color: ${(props) => props.theme.text};
}
}
}
@@ -136,10 +130,6 @@ const StyledWrapper = styled.div`
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
color: ${(props) => props.theme.text};
}
&.active {
color: ${(props) => props.theme.colors.accent};
}
}
.environment-item {

View File

@@ -1,7 +1,6 @@
import React, { useEffect, useState, useRef, useCallback } from 'react';
import usePrevious from 'hooks/usePrevious';
import useOnClickOutside from 'hooks/useOnClickOutside';
import useDebounce from 'hooks/useDebounce';
import EnvironmentDetails from './EnvironmentDetails';
import { IconDownload, IconUpload, IconSearch, IconPlus, IconCheck, IconX, IconFileAlert } from '@tabler/icons';
import Button from 'ui/Button';
@@ -23,8 +22,6 @@ import {
createDotEnvFile,
deleteDotEnvFile
} from 'providers/ReduxStore/slices/collections/actions';
import { setEnvironmentsDraft, clearEnvironmentsDraft } from 'providers/ReduxStore/slices/collections';
import { setEnvVarSearchQuery, setEnvVarSearchExpanded } from 'providers/ReduxStore/slices/app';
import { validateName, validateNameError } from 'utils/common/regex';
import toast from 'react-hot-toast';
import classnames from 'classnames';
@@ -42,15 +39,9 @@ const EnvironmentList = ({
setShowExportModal
}) => {
const dispatch = useDispatch();
const envSearchQuery = useSelector((state) => state.app.envVarSearch?.collection?.query ?? '');
const isEnvSearchExpanded = useSelector((state) => state.app.envVarSearch?.collection?.expanded ?? false);
const setEnvSearchQuery = (q) => dispatch(setEnvVarSearchQuery({ context: 'collection', query: q }));
const setIsEnvSearchExpanded = (v) => dispatch(setEnvVarSearchExpanded({ context: 'collection', expanded: v }));
const [openImportModal, setOpenImportModal] = useState(false);
const [searchText, setSearchText] = useState('');
const [isEnvListSearchExpanded, setIsEnvListSearchExpanded] = useState(false);
const envListSearchInputRef = useRef(null);
const [isCreatingInline, setIsCreatingInline] = useState(false);
const [renamingEnvUid, setRenamingEnvUid] = useState(null);
const [newEnvName, setNewEnvName] = useState('');
@@ -73,9 +64,6 @@ const EnvironmentList = ({
const dotEnvInputRef = useRef(null);
const dotEnvCreateContainerRef = useRef(null);
const debouncedEnvSearchQuery = useDebounce(envSearchQuery, 300);
const envSearchInputRef = useRef(null);
const dotEnvFiles = useSelector((state) => {
const coll = state.collections.collections.find((c) => c.uid === collection?.uid);
return coll?.dotEnvFiles || EMPTY_ARRAY;
@@ -84,24 +72,11 @@ const EnvironmentList = ({
const envUids = environments ? environments.map((env) => env.uid) : [];
const prevEnvUids = usePrevious(envUids);
const handleDotEnvModifiedChange = useCallback((modified) => {
setIsDotEnvModified(modified);
if (modified) {
dispatch(setEnvironmentsDraft({
collectionUid: collection.uid,
environmentUid: `dotenv:${selectedDotEnvFile}`,
variables: []
}));
} else {
dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid }));
}
}, [dispatch, collection.uid, selectedDotEnvFile]);
useEffect(() => {
if (dotEnvFiles.length === 0) {
setSelectedDotEnvFile(null);
setActiveView('environment');
handleDotEnvModifiedChange(false);
setIsDotEnvModified(false);
return;
}
@@ -449,7 +424,7 @@ const EnvironmentList = ({
dispatch(deleteDotEnvFile(collection.uid, filename))
.then(() => {
toast.success(`${filename} file deleted!`);
handleDotEnvModifiedChange(false);
setIsDotEnvModified(false);
if (selectedDotEnvFile === filename) {
const remainingFiles = dotEnvFiles.filter((f) => f.filename !== filename);
if (remainingFiles.length > 0) {
@@ -492,7 +467,7 @@ const EnvironmentList = ({
onSave={handleSaveDotEnv}
onSaveRaw={handleSaveDotEnvRaw}
isModified={isDotEnvModified}
setIsModified={handleDotEnvModifiedChange}
setIsModified={setIsDotEnvModified}
dotEnvExists={selectedDotEnvData?.exists}
viewMode={dotEnvViewMode}
collection={collection}
@@ -508,12 +483,6 @@ const EnvironmentList = ({
setIsModified={setIsModified}
originalEnvironmentVariables={originalEnvironmentVariables}
collection={collection}
searchQuery={envSearchQuery}
setSearchQuery={setEnvSearchQuery}
isSearchExpanded={isEnvSearchExpanded}
setIsSearchExpanded={setIsEnvSearchExpanded}
debouncedSearchQuery={debouncedEnvSearchQuery}
searchInputRef={envSearchInputRef}
/>
);
}
@@ -548,6 +517,20 @@ const EnvironmentList = ({
)}
<div className="sidebar">
<div className="sidebar-header">
<h2 className="title">Variables</h2>
</div>
<div className="search-container">
<IconSearch size={14} strokeWidth={1.5} className="search-icon" />
<input
type="text"
placeholder="Search..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="search-input"
/>
</div>
<div className="sections-container">
<CollapsibleSection
@@ -556,19 +539,6 @@ const EnvironmentList = ({
onToggle={() => setEnvironmentsExpanded(!environmentsExpanded)}
actions={(
<>
<button
type="button"
className={`btn-action ${isEnvListSearchExpanded ? 'active' : ''}`}
onClick={() => {
const next = !isEnvListSearchExpanded;
setIsEnvListSearchExpanded(next);
if (!next) setSearchText('');
else setTimeout(() => envListSearchInputRef.current?.focus(), 50);
}}
title="Search environments"
>
<IconSearch size={14} strokeWidth={1.5} />
</button>
<button type="button" className="btn-action" onClick={() => handleCreateEnvClick()} title="Create environment">
<IconPlus size={14} strokeWidth={1.5} />
</button>
@@ -581,28 +551,6 @@ const EnvironmentList = ({
</>
)}
>
{isEnvListSearchExpanded && (
<div className="env-list-search">
<IconSearch size={13} strokeWidth={1.5} className="env-list-search-icon" />
<input
ref={envListSearchInputRef}
type="text"
placeholder="Search environments..."
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="env-list-search-input"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
{searchText && (
<button className="env-list-search-clear" title="Clear search" onClick={() => setSearchText('')} onMouseDown={(e) => e.preventDefault()}>
<IconX size={12} strokeWidth={1.5} />
</button>
)}
</div>
)}
<div className="environments-list">
{filteredEnvironments.map((env) => (
<div

View File

@@ -34,36 +34,23 @@ class ErrorBoundary extends Component {
const serializeArgs = (args) => {
return args.map((arg) => {
const seen = new WeakSet();
const replacer = (key, value) => {
if (typeof value === 'object' && value !== null) {
if (seen.has(value)) {
return '[Circular Reference]';
}
seen.add(value);
if (value instanceof Error || Object.prototype.toString.call(value) === '[object Error]' || (typeof value.message === 'string' && typeof value.stack === 'string')) {
const error = {};
Object.getOwnPropertyNames(value).forEach((prop) => {
error[prop] = value[prop];
});
return error;
}
}
return value;
};
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, replacer));
return JSON.parse(JSON.stringify(arg));
} catch {
return String(arg);
}

View File

@@ -1,10 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
color: ${(props) => props.theme.colors.danger};
pre {
color: ${(props) => props.theme.colors.danger};
}
`;
export default StyledWrapper;

View File

@@ -1,34 +0,0 @@
import React from 'react';
import Portal from 'components/Portal';
import Modal from 'components/Modal';
import { useState } from 'react';
import StyledWrapper from './StyledWrapper';
const IpcErrorModal = ({ error }) => {
const [showModal, setShowModal] = useState(true);
return (
<>
{showModal ? (
<StyledWrapper>
<Portal>
<Modal
size="sm"
title="Error"
hideFooter={true}
hideCancel={true}
handleCancel={() => {
setShowModal(false);
}}
disableCloseOnOutsideClick={true}
disableEscapeKey={true}
>
<pre className="w-full flex flex-wrap whitespace-pre-wrap">{error}</pre>
</Modal>
</Portal>
</StyledWrapper>
) : null}
</>
);
};
export default IpcErrorModal;

View File

@@ -7,7 +7,6 @@ import { saveFolderRoot } from 'providers/ReduxStore/slices/collections/actions'
import { useTheme } from 'providers/Theme';
import { Tabs, TabsList, TabsTrigger, TabsContent } from 'components/Tabs';
import StatusDot from 'components/StatusDot';
import { flattenItems, isItemARequest } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
import Button from 'ui/Button';
@@ -76,10 +75,6 @@ const Script = ({ collection, folder }) => {
dispatch(saveFolderRoot(collection.uid, folder.uid));
};
const items = flattenItems(folder.items || []);
const hasPreRequestScriptError = items.some((i) => isItemARequest(i) && i.preRequestScriptErrorMessage);
const hasPostResponseScriptError = items.some((i) => isItemARequest(i) && i.postResponseScriptErrorMessage);
return (
<StyledWrapper className="w-full flex flex-col h-full">
<div className="text-xs mb-4 text-muted">
@@ -90,15 +85,11 @@ const Script = ({ collection, folder }) => {
<TabsList>
<TabsTrigger value="pre-request">
Pre Request
{requestScript && requestScript.trim().length > 0 && (
<StatusDot type={hasPreRequestScriptError ? 'error' : 'default'} />
)}
{requestScript && requestScript.trim().length > 0 && <StatusDot />}
</TabsTrigger>
<TabsTrigger value="post-response">
Post Response
{responseScript && responseScript.trim().length > 0 && (
<StatusDot type={hasPostResponseScriptError ? 'error' : 'default'} />
)}
{responseScript && responseScript.trim().length > 0 && <StatusDot />}
</TabsTrigger>
</TabsList>

View File

@@ -1,62 +0,0 @@
import React from 'react';
import Modal from 'components/Modal/index';
import Portal from 'components/Portal/index';
const getOSName = () => {
const platform = window.navigator.userAgentData?.platform || '';
if (platform.startsWith('Win')) {
return 'Windows';
} else if (platform.startsWith('Mac')) {
return 'macOS';
} else if (platform.startsWith('Linux')) {
return 'Linux';
} else {
return 'your OS';
}
};
const getDownloadUrl = (os) => {
switch (os) {
case 'Windows':
return 'https://git-scm.com/download/win';
case 'macOS':
return 'https://git-scm.com/download/mac';
case 'Linux':
return 'https://git-scm.com/download/linux';
default:
return 'https://git-scm.com/download';
}
};
const GitNotFoundModal = ({ onClose }) => {
const osName = getOSName();
const downloadUrl = getDownloadUrl(osName);
return (
<Portal>
<Modal
size="sm"
title="Git Not Found"
handleCancel={onClose}
hideFooter={true}
>
<div>
<p>Git was not detected on your system. You need to install Git to proceed.</p>
<p className="mt-2">
You can download Git for <strong>{osName}</strong> here:
</p>
<p>
<span
className="text-blue-600 cursor-pointer border-b border-blue-600"
onClick={() => window.open(downloadUrl, '_blank')}
>
Download Git for {osName}
</span>
</p>
</div>
</Modal>
</Portal>
);
};
export default GitNotFoundModal;

View File

@@ -8,37 +8,7 @@ import React, { useState } from 'react';
import HelpIcon from 'components/Icons/Help';
import StyledWrapper from './StyledWrapper';
const getPlacementStyles = (placement) => {
switch (placement) {
case 'top':
return {
bottom: 'calc(100% + 8px)',
left: '50%',
transform: 'translateX(-50%)'
};
case 'bottom':
return {
top: 'calc(100% + 8px)',
left: '50%',
transform: 'translateX(-50%)'
};
case 'left':
return {
top: '50%',
right: 'calc(100% + 8px)',
transform: 'translateY(-50%)'
};
case 'right':
default:
return {
top: '50%',
left: 'calc(100% + 8px)',
transform: 'translateY(-50%)'
};
}
};
const Help = ({ children, width = 200, placement = 'right' }) => {
const Help = ({ children, width = 200 }) => {
const [showTooltip, setShowTooltip] = useState(false);
return (
@@ -54,7 +24,9 @@ const Help = ({ children, width = 200, placement = 'right' }) => {
<StyledWrapper
className="absolute z-50 rounded-md p-3"
style={{
...getPlacementStyles(placement),
top: '50%',
left: 'calc(100% + 8px)',
transform: 'translateY(-50%)',
width: `${width}px`
}}
>

View File

@@ -3,9 +3,8 @@ import { useSelector, useDispatch } from 'react-redux';
import { IconArrowLeft, IconPlus, IconFolder, IconLock, IconDots, IconCategory, IconLogin } from '@tabler/icons';
import toast from 'react-hot-toast';
import get from 'lodash/get';
import { showHomePage } from 'providers/ReduxStore/slices/app';
import { createWorkspaceWithUniqueName, switchWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
import { switchWorkspace } from 'providers/ReduxStore/slices/workspaces/actions';
import { showInFolder } from 'providers/ReduxStore/slices/collections/actions';
import { sortWorkspaces } from 'utils/workspaces';
@@ -60,21 +59,6 @@ const ManageWorkspace = () => {
setDeleteWorkspaceModal({ open: true, workspace });
};
const handleCreateWorkspace = async () => {
const defaultLocation = get(preferences, 'general.defaultLocation', '');
if (!defaultLocation) {
setCreateWorkspaceModalOpen(true);
return;
}
try {
await dispatch(createWorkspaceWithUniqueName(defaultLocation));
toast.success('Workspace created!');
} catch (error) {
toast.error(error?.message || 'Failed to create workspace');
}
};
return (
<StyledWrapper>
{createWorkspaceModalOpen && (
@@ -102,7 +86,7 @@ const ManageWorkspace = () => {
</div>
<span className="header-title">Manage Workspace</span>
</div>
<Button size="sm" onClick={handleCreateWorkspace} icon={<IconPlus size={14} strokeWidth={2} />}>
<Button size="sm" onClick={() => setCreateWorkspaceModalOpen(true)} icon={<IconPlus size={14} strokeWidth={2} />}>
Create Workspace
</Button>
</div>

View File

@@ -15,20 +15,20 @@ const StyledMarkdownBodyWrapper = styled.div`
margin: 0.67em 0;
font-weight: var(--base-text-weight-semibold, 600);
padding-bottom: 0.3em;
font-size: 2.2em;
font-size: 1.4em;
border-bottom: 1px solid var(--color-border-muted);
}
h2 {
font-weight: var(--base-text-weight-semibold, 600);
padding-bottom: 0.3em;
font-size: 1.7em;
font-size: 1.3em;
border-bottom: 1px solid var(--color-border-muted);
}
h3 {
font-weight: var(--base-text-weight-semibold, 600);
font-size: 1.45em;
font-size: 1.2em;
}
h4 {
@@ -38,12 +38,12 @@ const StyledMarkdownBodyWrapper = styled.div`
h5 {
font-weight: var(--base-text-weight-semibold, 600);
font-size: 0.975em;
font-size: 1em;
}
h6 {
font-weight: var(--base-text-weight-semibold, 600);
font-size: 0.85em;
font-size: 0.9em;
color: var(--color-fg-muted);
}

View File

@@ -6,7 +6,6 @@ import { setupAutoComplete } from 'utils/codemirror/autocomplete';
import { MaskedEditor } from 'utils/common/masked-editor';
import StyledWrapper from './StyledWrapper';
import { setupLinkAware } from 'utils/codemirror/linkAware';
import { setupShortcuts } from 'utils/codemirror/shortcuts';
import { IconEye, IconEyeOff } from '@tabler/icons';
const CodeMirror = require('codemirror');
@@ -25,9 +24,6 @@ class MultiLineEditor extends Component {
this.state = {
maskInput: props.isSecret || false // Always mask the input by default (if it's a secret)
};
// Shortcuts cleanup function
this._shortcutsCleanup = null;
}
componentDidMount() {
@@ -49,16 +45,16 @@ class MultiLineEditor extends Component {
readOnly: this.props.readOnly,
tabindex: 0,
extraKeys: {
// 'Ctrl-Enter': () => {
// if (this.props.onRun) {
// this.props.onRun();
// }
// },
// 'Cmd-Enter': () => {
// if (this.props.onRun) {
// this.props.onRun();
// }
// },
'Ctrl-Enter': () => {
if (this.props.onRun) {
this.props.onRun();
}
},
'Cmd-Enter': () => {
if (this.props.onRun) {
this.props.onRun();
}
},
'Cmd-S': () => {
if (this.props.onSave) {
this.props.onSave();
@@ -94,9 +90,6 @@ class MultiLineEditor extends Component {
setupLinkAware(this.editor);
// Setup keyboard shortcuts
this._shortcutsCleanup = setupShortcuts(this.editor, this);
this.editor.setValue(String(this.props.value) || '');
this.editor.on('change', this._onEdit);
this.addOverlay(variables);
@@ -161,17 +154,10 @@ class MultiLineEditor extends Component {
this.editor.setOption('readOnly', this.props.readOnly);
}
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
// TODO: temporary fix for keeping cursor state when auto save and new line insertion collide PR#7098
const nextValue = String(this.props.value ?? '');
const currentValue = this.editor.getValue();
if (this.editor.hasFocus?.() && currentValue !== nextValue) {
this.cachedValue = currentValue;
} else {
const cursor = this.editor.getCursor();
this.cachedValue = nextValue;
this.editor.setValue(nextValue);
this.editor.setCursor(cursor);
}
const cursor = this.editor.getCursor();
this.cachedValue = String(this.props.value);
this.editor.setValue(String(this.props.value) || '');
this.editor.setCursor(cursor);
}
if (!isEqual(this.props.isSecret, prevProps.isSecret)) {
// If the secret flag has changed, update the editor to reflect the change
@@ -186,12 +172,6 @@ class MultiLineEditor extends Component {
}
componentWillUnmount() {
// Cleanup shortcuts (keymap and store subscription)
if (this._shortcutsCleanup) {
this._shortcutsCleanup();
this._shortcutsCleanup = null;
}
if (this.brunoAutoCompleteCleanup) {
this.brunoAutoCompleteCleanup();
}

View File

@@ -1,13 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
color: ${(props) => props.theme.text};
form.bruno-form {
label {
font-size: 0.8125rem;
}
}
`;
export default StyledWrapper;

View File

@@ -1,122 +0,0 @@
import React, { useEffect, useCallback, useRef } from 'react';
import { useFormik } from 'formik';
import { useSelector, useDispatch } from 'react-redux';
import {
savePreferences,
clearHttpHttpsAgentCache
} from 'providers/ReduxStore/slices/app';
import toast from 'react-hot-toast';
import StyledWrapper from './StyledWrapper';
import * as Yup from 'yup';
import debounce from 'lodash/debounce';
import get from 'lodash/get';
const cacheSchema = Yup.object().shape({
sslSession: Yup.object({
enabled: Yup.boolean()
})
});
const Cache = () => {
const preferences = useSelector((state) => state.app.preferences);
const dispatch = useDispatch();
const handleSave = useCallback(
(newCachePreferences) => {
dispatch(
savePreferences({
...preferences,
cache: newCachePreferences
})
).catch(() => toast.error('Failed to update cache preferences'));
},
[dispatch, preferences]
);
const handleSaveRef = useRef(handleSave);
handleSaveRef.current = handleSave;
const formik = useFormik({
initialValues: {
sslSession: {
enabled: get(preferences, 'cache.sslSession.enabled', false)
}
},
validationSchema: cacheSchema,
onSubmit: async (values) => {
try {
const newPreferences = await cacheSchema.validate(values, { abortEarly: true });
handleSave(newPreferences);
} catch (error) {
console.error('Cache preferences validation error:', error.message);
}
}
});
const debouncedSave = useCallback(
debounce((values) => {
cacheSchema
.validate(values, { abortEarly: true })
.then((validatedValues) => handleSaveRef.current(validatedValues))
.catch(() => {});
}, 500),
[]
);
useEffect(() => {
if (formik.dirty && formik.isValid) {
debouncedSave(formik.values);
}
return () => {
debouncedSave.cancel();
};
}, [formik.values, formik.dirty, formik.isValid, debouncedSave]);
const handleAgentCachingChange = (e) => {
formik.handleChange(e);
// Immediately evict all cached agents when caching is disabled
if (!e.target.checked) {
dispatch(clearHttpHttpsAgentCache()).catch(() => {});
}
};
const handleResetCache = () => {
dispatch(clearHttpHttpsAgentCache())
.then(() => toast.success('ssl session cache cleared'))
.catch(() => toast.error('Failed to clear ssl session cache'));
};
return (
<StyledWrapper className="w-full">
<form className="bruno-form" onSubmit={formik.handleSubmit}>
<div className="section-title mt-6 mb-3">Cache SSL Session</div>
<div className="flex items-center my-2">
<input
id="sslSession.enabled"
type="checkbox"
name="sslSession.enabled"
checked={formik.values.sslSession.enabled}
onChange={handleAgentCachingChange}
className="mousetrap mr-0"
/>
<label className="block ml-2 select-none" htmlFor="sslSession.enabled">
Enable SSL session caching
</label>
</div>
<div className="text-xs mt-1 ml-6 opacity-70">
Reuses TLS sessions and connections across requests for faster handshakes. Disable to create a fresh connection for every
request.
</div>
<div className="mt-6">
<button type="button" className="text-link cursor-pointer hover:underline" onClick={handleResetCache}>
Clear
</button>
</div>
</form>
</StyledWrapper>
);
};
export default Cache;

View File

@@ -1,127 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
color: ${(props) => props.theme.text};
.zoom-field {
width: 120px;
position: relative;
}
.zoom-field label {
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.5rem;
display: block;
}
.custom-select {
width: 80px;
height: 35.89px;
padding: 0 0.5rem;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
background-color: ${(props) => props.theme.input.background};
border: 1px solid ${(props) => props.theme.input.border};
border-radius: ${(props) => props.theme.border.radius.sm};
color: ${(props) => props.theme.text};
font-size: 0.875rem;
line-height: 1.5;
transition: all 0.15s ease;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.custom-select:hover {
border-color: ${(props) => props.theme.input.hoverBorder || props.theme.input.border};
}
.custom-select .selected-value {
flex: 1;
}
.custom-select .chevron-icon {
color: ${(props) => props.theme.input.border};
flex-shrink: 0;
transition: transform 0.15s ease;
margin-left: auto;
}
.dropdown-menu {
width: 80px;
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 0.25rem;
background-color: ${(props) => props.theme.input.background};
border: 1px solid ${(props) => props.theme.input.border};
border-radius: ${(props) => props.theme.border.radius.sm};
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
z-index: 50;
max-height: 200px;
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
.dropdown-menu::-webkit-scrollbar {
display: none;
}
.dropdown-option {
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
transition: background-color 0.1s ease;
}
.dropdown-option:hover {
background-color: ${(props) => props.theme.input.border};
}
.dropdown-option.selected {
background-color: ${(props) => props.theme.input.focusBorder || props.theme.input.border}22;
}
.dropdown-option .option-label {
flex: 1;
}
.dropdown-option .check-icon {
color: ${(props) => props.theme.textLink};
flex-shrink: 0;
}
.reset-btn {
padding: 0.45rem 1rem;
background: transparent;
border: 1px solid ${(props) => props.theme.input.border};
border-radius: ${(props) => props.theme.border.radius.sm};
color: ${(props) => props.theme.textLink};
font-size: 0.875rem;
line-height: 1.5;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
&:hover {
background: ${(props) => props.theme.input.border};
}
&:focus {
outline: none;
border-color: ${(props) => props.theme.input.focusBorder};
box-shadow: 0 0 0 2px ${(props) => props.theme.input.focusBorder}33;
}
}
`;
export default StyledWrapper;

View File

@@ -1,124 +0,0 @@
import React, { useState, useRef, useEffect } from 'react';
import get from 'lodash/get';
import { useSelector, useDispatch } from 'react-redux';
import { savePreferences } from 'providers/ReduxStore/slices/app';
import StyledWrapper from './StyledWrapper';
import { IconChevronDown, IconCheck } from '@tabler/icons';
const { percentageToZoomLevel } = require('@usebruno/common');
// Zoom options for dropdown (50% to 150%)
const ZOOM_OPTIONS = [
{ label: '50%', value: 50 },
{ label: '60%', value: 60 },
{ label: '70%', value: 70 },
{ label: '80%', value: 80 },
{ label: '90%', value: 90 },
{ label: '100%', value: 100 },
{ label: '110%', value: 110 },
{ label: '120%', value: 120 },
{ label: '130%', value: 130 },
{ label: '140%', value: 140 },
{ label: '150%', value: 150 }
];
const DEFAULT_ZOOM = 100;
const Zoom = () => {
const dispatch = useDispatch();
const preferences = useSelector((state) => state.app.preferences);
const dropdownRef = useRef(null);
const dropdownMenuRef = useRef(null);
const { ipcRenderer } = window;
// Get saved zoom percentage from Redux preferences (single source of truth)
const savedZoom = get(preferences, 'display.zoomPercentage', DEFAULT_ZOOM);
const [isOpen, setIsOpen] = useState(false);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Callback ref to scroll to selected option when dropdown renders
const setDropdownMenuRef = (node) => {
dropdownMenuRef.current = node;
if (node) {
const selectedOption = node.querySelector('.dropdown-option.selected');
if (selectedOption) {
selectedOption.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
}
};
const handleSelect = (zoom) => {
// Apply zoom level to Electron window immediately
if (ipcRenderer) {
const zoomLevel = percentageToZoomLevel(zoom);
ipcRenderer.invoke('renderer:set-zoom-level', zoomLevel);
}
// Save to preferences via Redux (same pattern as layout)
const updatedPreferences = {
...preferences,
display: {
...get(preferences, 'display', {}),
zoomPercentage: zoom
}
};
dispatch(savePreferences(updatedPreferences));
setIsOpen(false);
};
const handleResetToDefault = () => {
handleSelect(DEFAULT_ZOOM);
};
const selectedOption = ZOOM_OPTIONS.find((opt) => opt.value === savedZoom);
const isDefault = savedZoom === DEFAULT_ZOOM;
return (
<StyledWrapper>
<div className="flex flex-row gap-1 items-end">
<div className="zoom-field" ref={dropdownRef}>
<label className="block">Interface Zoom</label>
<div className="custom-select mt-2" onClick={() => setIsOpen(!isOpen)}>
<span className="selected-value">{selectedOption?.label}</span>
<IconChevronDown size={14} className="chevron-icon" />
</div>
{isOpen && (
<div className="dropdown-menu" ref={setDropdownMenuRef}>
{ZOOM_OPTIONS.map((option) => (
<div
key={option.value}
className={`dropdown-option ${option.value === savedZoom ? 'selected' : ''}`}
onClick={() => handleSelect(option.value)}
>
<span className="option-label">{option.label}</span>
{option.value === savedZoom && <IconCheck size={12} className="check-icon" />}
</div>
))}
</div>
)}
</div>
{!isDefault && (
<button
type="button"
className="reset-btn"
onClick={handleResetToDefault}
>
Reset
</button>
)}
</div>
</StyledWrapper>
);
};
export default Zoom;

View File

@@ -1,6 +1,5 @@
import React from 'react';
import Font from './Font/index';
import Zoom from './Zoom/index';
const Display = ({ close }) => {
return (
@@ -10,9 +9,6 @@ const Display = ({ close }) => {
<div className="w-fit flex flex-col gap-2">
<Font close={close} />
</div>
<div className="w-full flex flex-col gap-2">
<Zoom />
</div>
</div>
</div>
);

View File

@@ -24,7 +24,7 @@ const StyledWrapper = styled.div`
}
}
.default-location-input {
.default-collection-location-input {
max-width: 28rem;
}
`;

View File

@@ -47,8 +47,8 @@ const General = () => {
.test('isNumber', 'Save Delay must be a number', (value) => {
return value === undefined || !isNaN(value);
})
.test('isValidInterval', 'Save Delay must be at least 500ms', (value) => {
return value === undefined || Number(value) >= 500;
.test('isValidInterval', 'Save Delay must be at least 100ms', (value) => {
return value === undefined || Number(value) >= 100;
})
}).test('intervalRequired', 'Save Delay is required when Auto Save is enabled', (value) => {
// If autosave is enabled, interval must be provided
@@ -60,7 +60,7 @@ const General = () => {
oauth2: Yup.object({
useSystemBrowser: Yup.boolean()
}),
defaultLocation: Yup.string().max(1024)
defaultCollectionLocation: Yup.string().max(1024)
});
const formik = useFormik({
@@ -83,7 +83,7 @@ const General = () => {
oauth2: {
useSystemBrowser: get(preferences, 'request.oauth2.useSystemBrowser', false)
},
defaultLocation: get(preferences, 'general.defaultLocation', '')
defaultCollectionLocation: get(preferences, 'general.defaultCollectionLocation', '')
},
validationSchema: preferencesSchema,
onSubmit: async (values) => {
@@ -121,7 +121,7 @@ const General = () => {
interval: newPreferences.autoSave.interval
},
general: {
defaultLocation: newPreferences.defaultLocation
defaultCollectionLocation: newPreferences.defaultCollectionLocation
}
}))
.catch((err) => console.log(err) && toast.error('Failed to update preferences'));
@@ -163,11 +163,11 @@ const General = () => {
dispatch(browseDirectory())
.then((dirPath) => {
if (typeof dirPath === 'string') {
formik.setFieldValue('defaultLocation', dirPath);
formik.setFieldValue('defaultCollectionLocation', dirPath);
}
})
.catch((error) => {
formik.setFieldValue('defaultLocation', '');
formik.setFieldValue('defaultCollectionLocation', '');
console.error(error);
});
};
@@ -356,38 +356,35 @@ const General = () => {
<div className="text-red-500">{formik.errors.autoSave.interval}</div>
)}
<div className="flex flex-col mt-6">
<label className="block select-none default-location-label" htmlFor="defaultLocation">
Default Location
<label className="block select-none default-collection-location-label" htmlFor="defaultCollectionLocation">
Default Collection Location
</label>
<p className="text-muted mt-1 text-xs">
Used as the default location for new workspaces and collections
</p>
<input
type="text"
name="defaultLocation"
id="defaultLocation"
className="block textbox mt-2 w-full cursor-pointer default-location-input"
name="defaultCollectionLocation"
id="defaultCollectionLocation"
className="block textbox mt-2 w-full cursor-pointer default-collection-location-input"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
readOnly={true}
onChange={formik.handleChange}
value={formik.values.defaultLocation || ''}
value={formik.values.defaultCollectionLocation || ''}
onClick={browseDefaultLocation}
placeholder="Click to browse for default location"
/>
<div className="mt-1">
<span
className="text-link cursor-pointer hover:underline default-location-browse"
className="text-link cursor-pointer hover:underline default-collection-location-browse"
onClick={browseDefaultLocation}
>
Browse
</span>
</div>
</div>
{formik.touched.defaultLocation && formik.errors.defaultLocation ? (
<div className="text-red-500">{formik.errors.defaultLocation}</div>
{formik.touched.defaultCollectionLocation && formik.errors.defaultCollectionLocation ? (
<div className="text-red-500">{formik.errors.defaultCollectionLocation}</div>
) : null}
</form>
</StyledWrapper>

View File

@@ -1,198 +1,53 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
gap: 1rem;
width: 100%;
table {
width: 80%;
border-collapse: collapse;
&::-webkit-scrollbar {
display: none;
}
scrollbar-width: none;
-ms-overflow-style: none;
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 14px;
}
.reset-all-btn {
display: flex;
align-items: center;
background: transparent;
border: 1px solid ${(props) => props.theme.table.border};
border-radius: 6px;
padding: 4px 4px;
cursor: pointer;
color: ${(props) => props.theme.text};
font-size: 12px;
font-weight: 500;
transition: all 0.2s ease;
&:hover {
background: ${(props) => props.theme.button.secondary.hoverBg};
border-color: ${(props) => props.theme.button.secondary.hoverBorder};
}
}
.keybinding-row {
display: flex;
align-items: center;
gap: 10px;
}
.keybinding-row:hover .edit-btn {
opacity: 0.9;
}
.shortcut-wrap {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 260px;
flex: 1;
}
.shortcut-input {
width: 200px;
max-width: 200px;
flex-shrink: 0;
caret-color: ${(props) => props.theme.table.input.color};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border: none;
outline: none;
background: transparent;
font-family: monospace;
color: ${(props) => props.theme.table.input.color};
cursor: pointer;
&:hover {
opacity: 0.85;
thead,
td {
border: 1px solid ${(props) => props.theme.table.border};
}
&:focus {
opacity: 1;
thead {
color: ${(props) => props.theme.table.thead.color};
font-size: ${(props) => props.theme.font.size.base};
user-select: none;
}
&::placeholder {
opacity: 0.5;
td {
padding: 6px 10px;
font-size: ${(props) => props.theme.font.size.sm};
}
}
.edit-btn {
background: transparent;
border: none;
color: ${(props) => props.theme.table.thead.color};
padding: 0;
cursor: pointer;
opacity: 0.6;
&:hover {
opacity: 1;
thead th {
font-weight: 500;
padding: 10px;
text-align: left;
border: 1px solid ${(props) => props.theme.table.border};
}
}
.reset-btn {
background: transparent;
border: none;
color: ${(props) => props.theme.table.thead.color};
border-radius: 8px;
padding: 0px;
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.shortcut-input--error {
opacity: 1;
}
.kb-tooltip {
border-radius: 8px;
padding: 6px 8px;
font-size: 12px;
line-height: 1.2;
max-width: 320px;
white-space: normal;
}
.kb-tooltip--error {
color: ${(props) => props.theme.colors?.text?.red || '#ef4444'};
}
.table-container {
flex: 1 1 auto;
min-height: 0;
max-height: 650px;
overflow-y: auto;
border-radius: 8px;
border-top: 1px solid ${(props) => props.theme.table.border};
border-bottom: 1px solid ${(props) => props.theme.table.border};
&::-webkit-scrollbar {
width: 0;
height: 0;
}
scrollbar-width: none;
-ms-overflow-style: none;
}
table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
table-layout: fixed;
}
thead th:first-child,
tbody td:first-child {
width: 35%;
}
thead th:last-child,
tbody td:last-child {
width: 45%;
}
thead th {
position: sticky;
top: 0;
z-index: 5;
background: ${(props) => props.theme.background};
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
color: ${(props) => props.theme.table.thead.color};
font-size: ${(props) => props.theme.font.size.base};
user-select: none;
font-weight: 500;
padding: 10px;
text-align: left;
border-left: 1px solid ${(props) => props.theme.table.border};
border-right: 1px solid ${(props) => props.theme.table.border};
border-bottom: 1px solid ${(props) => props.theme.table.border};
box-shadow: 0 1px 0 ${(props) => props.theme.table.border};
}
td {
padding: 6px 10px;
font-size: ${(props) => props.theme.font.size.sm};
border-top: 1px solid ${(props) => props.theme.table.border};
border-left: 1px solid ${(props) => props.theme.table.border};
border-right: 1px solid ${(props) => props.theme.table.border};
.key-button {
display: inline-block;
color: ${(props) => props.theme.table.input.color};
opacity: 0.7;
border-radius: 4px;
padding: 1px 5px;
font-family: monospace;
margin-right: 8px;
border: 1px solid #ccc;
border-bottom: 1.44px solid ${(props) => props.theme.table.input.border};
}
`;

View File

@@ -1,524 +1,14 @@
import React, { useMemo, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import toast from 'react-hot-toast';
import StyledWrapper from './StyledWrapper';
import { IconRefresh, IconPencil } from '@tabler/icons';
import React from 'react';
import { getKeyBindingsForOS } from 'providers/Hotkeys/keyMappings';
import { isMacOS } from 'utils/common/platform';
import { savePreferences } from 'providers/ReduxStore/slices/app';
import { DEFAULT_KEY_BINDINGS } from 'providers/Hotkeys/keyMappings.js';
import { Tooltip } from 'react-tooltip';
const SEP = '+bind+';
const getOS = () => (isMacOS() ? 'mac' : 'windows');
// Stored tokens must match your preferences defaults (lowercase)
const MODIFIERS = new Set(['ctrl', 'command', 'alt', 'shift']);
const REQUIRED_MODIFIERS_BY_OS = {
mac: new Set(['command', 'alt', 'shift', 'ctrl']),
windows: new Set(['ctrl', 'alt', 'shift']) // command (Win key) should NOT count
};
const hasRequiredModifier = (os, arr) => arr.some((k) => REQUIRED_MODIFIERS_BY_OS[os]?.has(k));
const isOnlyModifiers = (arr) => arr.length > 0 && arr.every((k) => MODIFIERS.has(k));
const sortCombo = (arr) => {
const order = ['ctrl', 'command', 'alt', 'shift'];
const modifiers = [];
const nonModifiers = [];
// Separate modifiers from non-modifiers
arr.forEach((key) => {
if (order.includes(key)) {
modifiers.push(key);
} else {
nonModifiers.push(key);
}
});
// Sort modifiers by their order
modifiers.sort((a, b) => order.indexOf(a) - order.indexOf(b));
// Keep non-modifiers in the order they were pressed (don't sort them)
return [...modifiers, ...nonModifiers];
};
const uniqSorted = (arr) => {
// Remove duplicates while preserving order
const unique = [];
const seen = new Set();
arr.forEach((key) => {
if (!seen.has(key)) {
seen.add(key);
unique.push(key);
}
});
return sortCombo(unique);
};
const fromKeysString = (keysStr) => (keysStr ? keysStr.split(SEP).filter(Boolean) : []);
const toKeysString = (keysArr) => uniqSorted(keysArr).join(SEP);
// Signature MUST be stable: unique + sorted
const comboSignature = (arr) => toKeysString(arr);
// OS reserved shortcuts in stored-token format
const RESERVED_BY_OS = {
mac: new Set([
comboSignature(['command', 'q']),
comboSignature(['command', 'w']),
comboSignature(['command', 'h']),
comboSignature(['command', 'm']),
comboSignature(['command', 'tab']),
comboSignature(['command', 'space']),
comboSignature(['ctrl', 'command', 'q']),
comboSignature(['command', ',']),
comboSignature(['command', 'shift', '3']),
comboSignature(['command', 'shift', '4']),
comboSignature(['command', 'shift', '5']),
comboSignature(['command', 'alt', 'esc'])
]),
windows: new Set([
comboSignature(['alt', 'tab']),
comboSignature(['alt', 'f4']),
comboSignature(['ctrl', 'alt', 'delete']),
comboSignature(['command', 'l']),
comboSignature(['command', 'd']),
comboSignature(['command', 'e']),
comboSignature(['command', 'r']),
comboSignature(['command', 'tab']),
comboSignature(['ctrl', 'shift', 'esc'])
])
};
// normalize keyboard event -> stored tokens
const normalizeKey = (e) => {
const k = e.key;
// ignore lock keys
if (k === 'CapsLock' || k === 'NumLock' || k === 'ScrollLock') return null;
if (k === ' ') return 'space';
if (k === 'Escape') return 'esc';
if (k === 'Control') return 'ctrl';
if (k === 'Alt') return 'alt';
if (k === 'Shift') return 'shift';
if (k === 'Enter') return 'enter';
if (k === 'Backspace') return 'backspace';
if (k === 'Tab') return 'tab';
if (k === 'Delete') return 'delete';
// Meta -> command (matches your stored default format)
if (k === 'Meta') return 'command';
// single char (letters/punct) to lowercase
if (k.length === 1) return k.toLowerCase();
// ArrowUp -> arrowup, PageUp -> pageup, etc
return k.toLowerCase();
};
const ERROR = {
EMPTY: 'EMPTY',
ONLY_MODIFIERS: 'ONLY_MODIFIERS',
MISSING_REQUIRED_MOD: 'MISSING_REQUIRED_MOD',
RESERVED: 'RESERVED',
DUPLICATE: 'DUPLICATE',
CONFLICT: 'CONFLICT'
};
const Keybindings = () => {
const dispatch = useDispatch();
const preferences = useSelector((state) => state.app.preferences);
const os = getOS();
// Source of truth: merge defaults with user preferences
const keyBindings = useMemo(() => {
const merged = {};
// Start with defaults
for (const [action, binding] of Object.entries(DEFAULT_KEY_BINDINGS)) {
merged[action] = { ...binding };
}
// Override with user preferences
const userBindings = preferences?.keyBindings || {};
for (const [action, binding] of Object.entries(userBindings)) {
if (merged[action]) {
// Merge user's OS-specific overrides into defaults
merged[action] = {
...merged[action],
...binding
};
}
}
return merged;
}, [preferences?.keyBindings]);
// Build table data (action -> { name, keys })
const keyMapping = useMemo(() => {
const out = {};
for (const [action, binding] of Object.entries(keyBindings)) {
if (binding?.[os]) out[action] = { name: binding.name, keys: binding[os] };
}
return out;
}, [keyBindings, os]);
// ✏️ which row is allowed to edit (pencil clicked)
const [editingAction, setEditingAction] = useState(null);
// hover tracking (for showing pencil/refresh only on hover row)
const [hoveredAction, setHoveredAction] = useState(null);
// Recording state
const [recordingAction, setRecordingAction] = useState(null);
const pressedKeysRef = useRef(new Set());
const inputRefs = useRef({});
const [draftByAction, setDraftByAction] = useState({}); // action -> string[]
const [errorByAction, setErrorByAction] = useState({}); // action -> { code, message }
const getCurrentRowKeysString = (action) => keyBindings?.[action]?.[os] || '';
const getDefaultRowKeysString = (action) => DEFAULT_KEY_BINDINGS?.[action]?.[os] || '';
const isRowDirty = (action) => {
const current = getCurrentRowKeysString(action);
const def = getDefaultRowKeysString(action);
if (!DEFAULT_KEY_BINDINGS) return false;
return current !== def;
};
// Check if any keybinding is dirty (different from default)
const hasDirtyRows = useMemo(() => {
for (const action of Object.keys(DEFAULT_KEY_BINDINGS)) {
if (isRowDirty(action)) {
return true;
}
}
return false;
}, [keyBindings, os]);
const buildUsedSignatures = (excludeAction) => {
const used = new Set();
for (const [action, binding] of Object.entries(keyBindings)) {
if (action === excludeAction) continue;
const keysStr = binding?.[os];
if (!keysStr) continue;
used.add(comboSignature(fromKeysString(keysStr)));
}
return used;
};
const validateCombo = (action, arrRaw) => {
const arr = uniqSorted(arrRaw);
const sig = comboSignature(arr);
if (!sig) return { code: ERROR.EMPTY, message: `Shortcut cant be empty.` };
if (isOnlyModifiers(arr))
return { code: ERROR.ONLY_MODIFIERS, message: 'Add a non-modifier key (e.g. Ctrl + K).' };
// OS-specific must-have modifier rule
if (!hasRequiredModifier(os, arr)) {
return {
code: ERROR.MISSING_REQUIRED_MOD,
message:
os === 'mac'
? 'macOS shortcuts must include at least one modifier (command/alt/shift/ctrl).'
: 'Windows shortcuts must include at least one modifier (ctrl/alt/shift).'
};
}
// OS reserved
if (RESERVED_BY_OS[os]?.has(sig))
return { code: ERROR.RESERVED, message: 'This shortcut is reserved by the OS.' };
// No duplicates (across all other actions)
if (buildUsedSignatures(action).has(sig))
return { code: ERROR.DUPLICATE, message: 'That shortcut is already in use.' };
// Check for subset conflicts (e.g., Cmd+A conflicts with Cmd+Z+A)
for (const [otherAction, binding] of Object.entries(keyBindings)) {
if (otherAction === action) continue;
const otherKeysStr = binding?.[os];
if (!otherKeysStr) continue;
const otherKeys = fromKeysString(otherKeysStr);
// Check if current is a subset of other (current is shorter)
if (arr.length < otherKeys.length) {
const isSubset = arr.every((k) => otherKeys.includes(k));
if (isSubset) {
return {
code: ERROR.CONFLICT,
message: `Conflicts with "${binding.name}" (${otherKeys.join(' + ')}). Remove the longer shortcut first.`
};
}
}
// Check if other is a subset of current (current is longer)
if (arr.length > otherKeys.length) {
const isSubset = otherKeys.every((k) => arr.includes(k));
if (isSubset) {
return {
code: ERROR.CONFLICT,
message: `Conflicts with "${binding.name}" (${otherKeys.join(' + ')}). Remove that shortcut first.`
};
}
}
}
return null;
};
const persistToPreferences = (action, nextKeys) => {
const updatedPreferences = {
...preferences,
keyBindings: {
...(preferences?.keyBindings || {}),
[action]: {
...(preferences?.keyBindings?.[action] || {}),
name: preferences?.keyBindings?.[action]?.name || action,
[os]: nextKeys
}
}
};
dispatch(savePreferences(updatedPreferences));
};
// Commit only if valid. Returns true if commit succeeded (or no-op), false if invalid.
const commitCombo = (action) => {
const draftArr = draftByAction[action] || [];
if (!draftArr.length) return;
const arr = uniqSorted(draftArr);
const err = validateCombo(action, arr);
if (err) {
setErrorByAction((prev) => ({ ...prev, [action]: err }));
return false;
}
setErrorByAction((prev) => {
const next = { ...prev };
delete next[action];
return next;
});
const nextKeys = toKeysString(arr);
const currentKeys = getCurrentRowKeysString(action);
if (nextKeys === currentKeys) return true;
persistToPreferences(action, nextKeys);
// toast success for 2s with Command name
const commandName = keyBindings?.[action]?.name || action;
toast.success(`"${commandName}" shortcut updated`, { autoClose: 2000 });
return true;
};
const resetRowToDefault = (action) => {
const def = DEFAULT_KEY_BINDINGS?.[action]?.[os];
if (!def) return;
setErrorByAction((prev) => {
const next = { ...prev };
delete next[action];
return next;
});
setDraftByAction((prev) => {
const next = { ...prev };
delete next[action];
return next;
});
persistToPreferences(action, def);
};
const resetAllKeybindings = () => {
const updatedPreferences = {
...preferences,
keyBindings: {}
};
dispatch(savePreferences(updatedPreferences));
};
const startEditing = (action) => {
// if another row is editing, commit/stop it first
if (editingAction && editingAction !== action) {
const ok = commitCombo(editingAction);
if (ok) {
setRecordingAction(null);
setEditingAction(null);
pressedKeysRef.current = new Set();
} else {
// keep previous row editing if invalid
return;
}
}
setEditingAction(action);
setRecordingAction(action);
pressedKeysRef.current = new Set();
// seed draft with current value
setDraftByAction((prev) => ({
...prev,
[action]: fromKeysString(getCurrentRowKeysString(action))
}));
// clear error on start edit
setErrorByAction((prev) => {
const next = { ...prev };
delete next[action];
return next;
});
requestAnimationFrame(() => {
inputRefs.current[action]?.focus?.();
inputRefs.current[action]?.setSelectionRange?.(
inputRefs.current[action].value.length,
inputRefs.current[action].value.length
);
});
};
const stopEditing = (action) => {
const ok = commitCombo(action);
if (!ok) {
// If commit failed (validation error), reset to original value
cancelEditing(action);
return;
}
setRecordingAction(null);
setEditingAction(null);
pressedKeysRef.current = new Set();
};
// Reset draft to original value and clear error (used on blur with invalid state)
const cancelEditing = (action) => {
// Clear error for this action
setErrorByAction((prev) => {
const next = { ...prev };
delete next[action];
return next;
});
// Reset draft to current saved value
setDraftByAction((prev) => {
const next = { ...prev };
delete next[action];
return next;
});
setRecordingAction(null);
setEditingAction(null);
pressedKeysRef.current = new Set();
};
const handleKeyDown = (action, e) => {
if (recordingAction !== action || editingAction !== action) return;
e.preventDefault();
e.stopPropagation();
// allow user to clear and keep editing (do NOT auto-stop)
if (e.key === 'Backspace' || e.key === 'Delete') {
pressedKeysRef.current = new Set();
setDraftByAction((prev) => ({ ...prev, [action]: [] }));
setErrorByAction((prev) => ({
...prev,
[action]: { code: ERROR.EMPTY, message: `Shortcut can't be empty.` }
}));
return;
}
if (e.repeat) return;
const keyName = normalizeKey(e);
if (!keyName) return;
pressedKeysRef.current.add(keyName);
const currentDraft = uniqSorted(Array.from(pressedKeysRef.current));
setDraftByAction((prev) => ({
...prev,
[action]: currentDraft
}));
const err = validateCombo(action, currentDraft);
if (err) {
setErrorByAction((prev) => ({ ...prev, [action]: err }));
} else {
setErrorByAction((prev) => {
const next = { ...prev };
delete next[action];
return next;
});
}
};
const handleKeyUp = (action, e) => {
if (recordingAction !== action || editingAction !== action) return;
e.preventDefault();
e.stopPropagation();
const keyName = normalizeKey(e);
if (!keyName) return;
pressedKeysRef.current.delete(keyName);
// commit only when released AND currently valid
if (pressedKeysRef.current.size === 0) {
const currentDraft = draftByAction[action] || [];
// if empty -> keep editing
if (currentDraft.length === 0) return;
// if error -> keep editing
if (errorByAction[action]?.message) return;
stopEditing(action);
}
};
const renderValue = (action) => {
const arr
= recordingAction === action ? draftByAction[action] : fromKeysString(getCurrentRowKeysString(action));
return (arr || []).join(' + ');
};
const Keybindings = ({ close }) => {
const keyMapping = getKeyBindingsForOS(isMacOS() ? 'mac' : 'windows');
return (
<StyledWrapper className="w-full">
<Tooltip
id="kb-editing-error-tooltip"
place="bottom-start"
opacity={1}
className="kb-tooltip kb-tooltip--error"
/>
<div className="section-header">
<span>Keybindings</span>
{hasDirtyRows && (
<button
type="button"
className="reset-all-btn"
onClick={resetAllKeybindings}
title="Reset all keybindings to default"
>
<IconRefresh size={12} stroke={1} />
</button>
)}
</div>
<div className="section-header">Keybindings</div>
<div className="table-container">
<table>
<thead>
@@ -529,90 +19,18 @@ const Keybindings = () => {
</thead>
<tbody>
{keyMapping ? (
Object.entries(keyMapping).map(([action, row]) => {
const isEditing = editingAction === action;
const isHovered = hoveredAction === action;
const isDirty = isRowDirty(action);
const showPencil = isHovered && !isEditing && !isDirty;
const showRefresh = isDirty && !isEditing;
const hasError = Boolean(errorByAction[action]?.message);
const errorMessage = errorByAction[action]?.message;
const inputId = `kb-input-${action}`;
return (
<tr
key={action}
data-testid={`keybinding-row-${action}`}
onMouseEnter={() => setHoveredAction(action)}
onMouseLeave={() => setHoveredAction((prev) => (prev === action ? null : prev))}
>
<td data-testid={`keybinding-name-${action}`}>{row.name}</td>
<td>
<div className="keybinding-row">
<div className="shortcut-wrap">
<input
id={inputId}
ref={(el) => {
if (el) inputRefs.current[action] = el;
}}
data-testid={`keybinding-input-${action}`}
className={`shortcut-input ${hasError ? 'shortcut-input--error' : ''}`}
value={renderValue(action)}
readOnly={!isEditing}
onKeyDown={(e) => handleKeyDown(action, e)}
onKeyUp={(e) => handleKeyUp(action, e)}
onBlur={() => {
// If there's an error, reset to original value instead of keeping invalid state
if (isEditing && hasError) {
cancelEditing(action);
} else if (isEditing) {
stopEditing(action);
}
}}
spellCheck={false}
/>
{isEditing && hasError && (
<Tooltip
id={`kb-editing-error-tooltip-${action}`}
anchorSelect={`#${inputId}`}
place="bottom-start"
opacity={1}
isOpen={true}
content={errorMessage}
className="kb-tooltip kb-tooltip--error"
/>
)}
</div>
{showRefresh && (
<button
type="button"
className="reset-btn"
data-testid={`keybinding-reset-${action}`}
onClick={() => resetRowToDefault(action)}
title="Reset to default"
>
<IconRefresh size={12} stroke={1} />
</button>
)}
{showPencil && (
<button
type="button"
className="edit-btn"
data-testid={`keybinding-edit-${action}`}
onClick={() => startEditing(action)}
title="Edit shortcut"
>
<IconPencil size={12} stroke={1.5} />
</button>
)}
Object.entries(keyMapping).map(([action, { name, keys }], index) => (
<tr key={index}>
<td>{name}</td>
<td>
{keys.split('+').map((key, i) => (
<div className="key-button" key={i}>
{key}
</div>
</td>
</tr>
);
})
))}
</td>
</tr>
))
) : (
<tr>
<td colSpan="2">No key bindings available</td>

View File

@@ -9,8 +9,7 @@ import {
IconUserCircle,
IconKeyboard,
IconZoomQuestion,
IconSquareLetterB,
IconDatabase
IconSquareLetterB
} from '@tabler/icons';
import Support from './Support';
@@ -22,7 +21,6 @@ import Keybindings from './Keybindings';
import Beta from './Beta';
import StyledWrapper from './StyledWrapper';
import Cache from './Cache/index';
const Preferences = () => {
const dispatch = useDispatch();
@@ -67,10 +65,6 @@ const Preferences = () => {
case 'support': {
return <Support />;
}
case 'cache': {
return <Cache />;
}
}
};
@@ -98,10 +92,6 @@ const Preferences = () => {
<IconKeyboard size={16} strokeWidth={1.5} />
Keybindings
</div>
<div className={getTabClassname('cache')} role="tab" onClick={() => setTab('cache')}>
<IconDatabase size={16} strokeWidth={1.5} />
Cache
</div>
<div className={getTabClassname('support')} role="tab" onClick={() => setTab('support')}>
<IconZoomQuestion size={16} strokeWidth={1.5} />
Support

View File

@@ -1,10 +1,10 @@
import React from 'react';
import React, { useRef, forwardRef } from 'react';
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch, useSelector } from 'react-redux';
import { IconCaretDown, IconSettings, IconKey, IconHelp, IconAdjustmentsHorizontal } from '@tabler/icons';
import MenuDropdown from 'ui/MenuDropdown';
import Dropdown from 'components/Dropdown';
import SingleLineEditor from 'components/SingleLineEditor';
import StyledWrapper from './StyledWrapper';
import { inputsConfig } from './inputsConfig';
@@ -20,6 +20,8 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
const preferences = useSelector((state) => state.app.preferences);
const { storedTheme } = useTheme();
const useSystemBrowser = get(preferences, 'request.oauth2.useSystemBrowser', false);
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const { isSensitive } = useDetectSensitiveField(collection);
const oAuth = get(request, 'auth.oauth2', {});
const {
@@ -39,13 +41,30 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
refreshTokenUrl,
autoRefreshToken,
autoFetchToken,
tokenSource,
additionalParameters
} = oAuth;
const refreshTokenUrlAvailable = refreshTokenUrl?.trim() !== '';
const isAutoRefreshDisabled = !refreshTokenUrlAvailable;
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 CredentialsPlacementIcon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-end token-placement-label select-none">
{credentialsPlacement == 'body' ? 'Request Body' : 'Basic Auth Header'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
});
const handleSave = () => { save(); };
const handleChange = (key, value) => {
@@ -72,7 +91,6 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
refreshTokenUrl,
autoRefreshToken,
autoFetchToken,
tokenSource,
additionalParameters,
[key]: value
}
@@ -101,7 +119,6 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
tokenHeaderPrefix,
tokenQueryKey,
autoFetchToken,
tokenSource,
additionalParameters,
pkce: !Boolean(oAuth?.['pkce'])
}
@@ -209,19 +226,26 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
<div className="flex items-center gap-4 w-full" key="input-credentials-placement">
<label className="block min-w-[140px]">Add Credentials to</label>
<div className="inline-flex items-center cursor-pointer token-placement-selector">
<MenuDropdown
items={[
{ id: 'body', label: 'Request Body', onClick: () => handleChange('credentialsPlacement', 'body') },
{ id: 'basic_auth_header', label: 'Basic Auth Header', onClick: () => handleChange('credentialsPlacement', 'basic_auth_header') }
]}
selectedItemId={credentialsPlacement}
placement="bottom-end"
>
<div className="flex items-center justify-end token-placement-label select-none">
{credentialsPlacement == 'body' ? 'Request Body' : 'Basic Auth Header'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
<Dropdown onCreate={onDropdownCreate} icon={<CredentialsPlacementIcon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('credentialsPlacement', 'body');
}}
>
Request Body
</div>
</MenuDropdown>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('credentialsPlacement', 'basic_auth_header');
}}
>
Basic Auth Header
</div>
</Dropdown>
</div>
</div>
<div className="flex flex-row w-full gap-4" key="pkce">
@@ -241,24 +265,6 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
Token
</span>
</div>
<div className="flex items-center gap-4 w-full" key="input-token-type">
<label className="block min-w-[140px]">Token Source</label>
<div className="inline-flex items-center cursor-pointer token-placement-selector">
<MenuDropdown
items={[
{ id: 'access_token', label: 'Access Token', onClick: () => handleChange('tokenSource', 'access_token') },
{ id: 'id_token', label: 'ID Token', onClick: () => handleChange('tokenSource', 'id_token') }
]}
selectedItemId={tokenSource}
placement="bottom-end"
>
<div className="flex items-center justify-end token-placement-label select-none">
{tokenSource === 'id_token' ? 'ID Token' : 'Access Token'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
</MenuDropdown>
</div>
</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="single-line-editor-wrapper flex-1">
@@ -277,19 +283,26 @@ const OAuth2AuthorizationCode = ({ save, item = {}, request, handleRun, updateAu
<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">
<MenuDropdown
items={[
{ id: 'header', label: 'Header', onClick: () => handleChange('tokenPlacement', 'header') },
{ id: 'url', label: 'URL', onClick: () => handleChange('tokenPlacement', 'url') }
]}
selectedItemId={tokenPlacement}
placement="bottom-end"
>
<div 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} />
<Dropdown onCreate={onDropdownCreate} icon={<TokenPlacementIcon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('tokenPlacement', 'header');
}}
>
Header
</div>
</MenuDropdown>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('tokenPlacement', 'url');
}}
>
URL
</div>
</Dropdown>
</div>
</div>
{

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useRef, forwardRef } from 'react';
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
@@ -7,7 +7,7 @@ import { IconCaretDown, IconSettings, IconKey, IconAdjustmentsHorizontal, IconHe
import SingleLineEditor from 'components/SingleLineEditor';
import StyledWrapper from './StyledWrapper';
import { inputsConfig } from './inputsConfig';
import MenuDropdown from 'ui/MenuDropdown';
import Dropdown from 'components/Dropdown';
import Oauth2TokenViewer from '../Oauth2TokenViewer/index';
import Oauth2ActionButtons from '../Oauth2ActionButtons/index';
import AdditionalParams from '../AdditionalParams/index';
@@ -16,6 +16,8 @@ import SensitiveFieldWarning from 'components/SensitiveFieldWarning';
const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAuth, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const { isSensitive } = useDetectSensitiveField(collection);
const oAuth = get(request, 'auth.oauth2', {});
@@ -32,7 +34,6 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
refreshTokenUrl,
autoRefreshToken,
autoFetchToken,
tokenSource,
additionalParameters
} = oAuth;
@@ -41,6 +42,24 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
const handleSave = () => { save(); };
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 CredentialsPlacementIcon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-end token-placement-label select-none">
{credentialsPlacement == 'body' ? 'Request Body' : 'Basic Auth Header'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
});
const handleChange = (key, value) => {
dispatch(
updateAuth({
@@ -61,7 +80,6 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
refreshTokenUrl,
autoRefreshToken,
autoFetchToken,
tokenSource,
additionalParameters,
[key]: value
}
@@ -108,19 +126,26 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
<div className="flex items-center gap-4 w-full" key="input-credentials-placement">
<label className="block min-w-[140px]">Add Credentials to</label>
<div className="inline-flex items-center cursor-pointer token-placement-selector">
<MenuDropdown
items={[
{ id: 'body', label: 'Request Body', onClick: () => handleChange('credentialsPlacement', 'body') },
{ id: 'basic_auth_header', label: 'Basic Auth Header', onClick: () => handleChange('credentialsPlacement', 'basic_auth_header') }
]}
selectedItemId={credentialsPlacement}
placement="bottom-end"
>
<div className="flex items-center justify-end token-placement-label select-none">
{credentialsPlacement == 'body' ? 'Request Body' : 'Basic Auth Header'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
<Dropdown onCreate={onDropdownCreate} icon={<CredentialsPlacementIcon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('credentialsPlacement', 'body');
}}
>
Request Body
</div>
</MenuDropdown>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('credentialsPlacement', 'basic_auth_header');
}}
>
Basic Auth Header
</div>
</Dropdown>
</div>
</div>
<div className="flex items-center gap-2.5 mt-2">
@@ -131,24 +156,6 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
Token
</span>
</div>
<div className="flex items-center gap-4 w-full" key="input-token-type">
<label className="block min-w-[140px]">Token Source</label>
<div className="inline-flex items-center cursor-pointer token-placement-selector">
<MenuDropdown
items={[
{ id: 'access_token', label: 'Access Token', onClick: () => handleChange('tokenSource', 'access_token') },
{ id: 'id_token', label: 'ID Token', onClick: () => handleChange('tokenSource', 'id_token') }
]}
selectedItemId={tokenSource}
placement="bottom-end"
>
<div className="flex items-center justify-end token-placement-label select-none">
{tokenSource === 'id_token' ? 'ID Token' : 'Access Token'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
</MenuDropdown>
</div>
</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="single-line-editor-wrapper flex-1">
@@ -167,19 +174,26 @@ const OAuth2ClientCredentials = ({ save, item = {}, request, handleRun, updateAu
<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 w-fit">
<MenuDropdown
items={[
{ id: 'header', label: 'Header', onClick: () => handleChange('tokenPlacement', 'header') },
{ id: 'url', label: 'URL', onClick: () => handleChange('tokenPlacement', 'url') }
]}
selectedItemId={tokenPlacement}
placement="bottom-end"
>
<div 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} />
<Dropdown onCreate={onDropdownCreate} icon={<TokenPlacementIcon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('tokenPlacement', 'header');
}}
>
Header
</div>
</MenuDropdown>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('tokenPlacement', 'url');
}}
>
URL
</div>
</Dropdown>
</div>
</div>
{

View File

@@ -1,6 +1,6 @@
import React from 'react';
import React, { useRef, forwardRef } from 'react';
import get from 'lodash/get';
import MenuDropdown from 'ui/MenuDropdown';
import Dropdown from 'components/Dropdown';
import { useDispatch } from 'react-redux';
import StyledWrapper from './StyledWrapper';
import { IconCaretDown, IconKey } from '@tabler/icons';
@@ -10,10 +10,20 @@ import { useState } from 'react';
const GrantTypeSelector = ({ item = {}, request, updateAuth, collection }) => {
const dispatch = useDispatch();
const dropdownTippyRef = useRef();
const oAuth = get(request, 'auth.oauth2', {});
const [valuesCache, setValuesCache] = useState({
...oAuth
});
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const Icon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-end grant-type-label select-none">
{humanizeGrantType(oAuth?.grantType)} <IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
});
const onGrantTypeChange = (grantType) => {
let updatedValues = {
@@ -55,8 +65,7 @@ const GrantTypeSelector = ({ item = {}, request, updateAuth, collection }) => {
credentialsId: 'credentials',
tokenPlacement: 'header',
tokenHeaderPrefix: 'Bearer',
tokenQueryKey: 'access_token',
tokenSource: 'access_token'
tokenQueryKey: 'access_token'
}
})
);
@@ -73,20 +82,44 @@ const GrantTypeSelector = ({ item = {}, request, updateAuth, collection }) => {
</span>
</div>
<div className="inline-flex items-center cursor-pointer grant-type-mode-selector w-fit">
<MenuDropdown
items={[
{ id: 'password', label: 'Password Credentials', onClick: () => onGrantTypeChange('password') },
{ id: 'authorization_code', label: 'Authorization Code', onClick: () => onGrantTypeChange('authorization_code') },
{ id: 'implicit', label: 'Implicit', onClick: () => onGrantTypeChange('implicit') },
{ id: 'client_credentials', label: 'Client Credentials', onClick: () => onGrantTypeChange('client_credentials') }
]}
selectedItemId={oAuth?.grantType}
placement="bottom-end"
>
<div className="flex items-center justify-end grant-type-label select-none">
{humanizeGrantType(oAuth?.grantType)} <IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
<Dropdown onCreate={onDropdownCreate} icon={<Icon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onGrantTypeChange('password');
}}
>
Password Credentials
</div>
</MenuDropdown>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onGrantTypeChange('authorization_code');
}}
>
Authorization Code
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onGrantTypeChange('implicit');
}}
>
Implicit
</div>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
onGrantTypeChange('client_credentials');
}}
>
Client Credentials
</div>
</Dropdown>
</div>
</StyledWrapper>
);

View File

@@ -1,9 +1,9 @@
import React, { useMemo } from 'react';
import React, { useRef, forwardRef, useMemo } from 'react';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
import { useDispatch, useSelector } from 'react-redux';
import { IconCaretDown, IconSettings, IconKey, IconHelp, IconAdjustmentsHorizontal } from '@tabler/icons';
import MenuDropdown from 'ui/MenuDropdown';
import Dropdown from 'components/Dropdown';
import SingleLineEditor from 'components/SingleLineEditor';
import Wrapper from './StyledWrapper';
import { inputsConfig } from './inputsConfig';
@@ -20,6 +20,9 @@ const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, colle
const preferences = useSelector((state) => state.app.preferences);
const useSystemBrowser = get(preferences, 'request.oauth2.useSystemBrowser', false);
const { storedTheme } = useTheme();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const oAuth = get(request, 'auth.oauth2', {});
const {
callbackUrl,
@@ -31,8 +34,7 @@ const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, colle
tokenPlacement,
tokenHeaderPrefix,
tokenQueryKey,
autoFetchToken,
tokenSource
autoFetchToken
} = oAuth;
const interpolatedAuthUrl = useMemo(() => {
@@ -40,6 +42,15 @@ const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, colle
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 handleSave = () => { save(); };
const handleChange = (key, value) => {
@@ -60,7 +71,6 @@ const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, colle
tokenHeaderPrefix,
tokenQueryKey,
autoFetchToken,
tokenSource,
[key]: value
}
})
@@ -174,25 +184,6 @@ const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, colle
</span>
</div>
<div className="flex items-center gap-4 w-full" key="input-token-type">
<label className="block min-w-[140px]">Token Source</label>
<div className="inline-flex items-center cursor-pointer token-placement-selector">
<MenuDropdown
items={[
{ id: 'access_token', label: 'Access Token', onClick: () => handleChange('tokenSource', 'access_token') },
{ id: 'id_token', label: 'ID Token', onClick: () => handleChange('tokenSource', 'id_token') }
]}
selectedItemId={tokenSource}
placement="bottom-end"
>
<div className="flex items-center justify-end token-placement-label select-none">
{tokenSource === 'id_token' ? 'ID Token' : 'Access Token'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
</MenuDropdown>
</div>
</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">
@@ -212,19 +203,26 @@ const OAuth2Implicit = ({ save, item = {}, request, handleRun, updateAuth, colle
<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">
<MenuDropdown
items={[
{ id: 'header', label: 'Headers', onClick: () => handleChange('tokenPlacement', 'header') },
{ id: 'url', label: 'URL', onClick: () => handleChange('tokenPlacement', 'url') }
]}
selectedItemId={tokenPlacement}
placement="bottom-end"
>
<div 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} />
<Dropdown onCreate={onDropdownCreate} icon={<TokenPlacementIcon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('tokenPlacement', 'header');
}}
>
Headers
</div>
</MenuDropdown>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('tokenPlacement', 'url');
}}
>
URL
</div>
</Dropdown>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useRef, forwardRef } from 'react';
import { useDetectSensitiveField } from 'hooks/useDetectSensitiveField';
import get from 'lodash/get';
import { useTheme } from 'providers/Theme';
@@ -7,7 +7,7 @@ import { IconCaretDown, IconSettings, IconKey, IconAdjustmentsHorizontal, IconHe
import SingleLineEditor from 'components/SingleLineEditor';
import StyledWrapper from './StyledWrapper';
import { inputsConfig } from './inputsConfig';
import MenuDropdown from 'ui/MenuDropdown';
import Dropdown from 'components/Dropdown';
import Oauth2TokenViewer from '../Oauth2TokenViewer/index';
import Oauth2ActionButtons from '../Oauth2ActionButtons/index';
import AdditionalParams from '../AdditionalParams/index';
@@ -16,6 +16,8 @@ import SensitiveFieldWarning from 'components/SensitiveFieldWarning/index';
const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, updateAuth, collection }) => {
const dispatch = useDispatch();
const { storedTheme } = useTheme();
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
const oAuth = get(request, 'auth.oauth2', {});
const { isSensitive } = useDetectSensitiveField(collection);
@@ -34,7 +36,6 @@ const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, update
refreshTokenUrl,
autoRefreshToken,
autoFetchToken,
tokenSource,
additionalParameters
} = oAuth;
@@ -43,6 +44,24 @@ const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, update
const handleSave = () => { save(); };
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 CredentialsPlacementIcon = forwardRef((props, ref) => {
return (
<div ref={ref} className="flex items-center justify-end token-placement-label select-none">
{credentialsPlacement == 'body' ? 'Request Body' : 'Basic Auth Header'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
);
});
const handleChange = (key, value) => {
dispatch(
updateAuth({
@@ -65,7 +84,6 @@ const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, update
refreshTokenUrl,
autoRefreshToken,
autoFetchToken,
tokenSource,
additionalParameters,
[key]: value
}
@@ -112,19 +130,26 @@ const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, update
<div className="flex items-center gap-4 w-full" key="input-credentials-placement">
<label className="block min-w-[140px]">Add Credentials to</label>
<div className="inline-flex items-center cursor-pointer token-placement-selector">
<MenuDropdown
items={[
{ id: 'body', label: 'Request Body', onClick: () => handleChange('credentialsPlacement', 'body') },
{ id: 'basic_auth_header', label: 'Basic Auth Header', onClick: () => handleChange('credentialsPlacement', 'basic_auth_header') }
]}
selectedItemId={credentialsPlacement}
placement="bottom-end"
>
<div className="flex items-center justify-end token-placement-label select-none">
{credentialsPlacement == 'body' ? 'Request Body' : 'Basic Auth Header'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
<Dropdown onCreate={onDropdownCreate} icon={<CredentialsPlacementIcon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('credentialsPlacement', 'body');
}}
>
Request Body
</div>
</MenuDropdown>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('credentialsPlacement', 'basic_auth_header');
}}
>
Basic Auth Header
</div>
</Dropdown>
</div>
</div>
<div className="flex items-center gap-2.5 mt-2">
@@ -135,24 +160,6 @@ const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, update
Token
</span>
</div>
<div className="flex items-center gap-4 w-full" key="input-token-type">
<label className="block min-w-[140px]">Token Source</label>
<div className="inline-flex items-center cursor-pointer token-placement-selector">
<MenuDropdown
items={[
{ id: 'access_token', label: 'Access Token', onClick: () => handleChange('tokenSource', 'access_token') },
{ id: 'id_token', label: 'ID Token', onClick: () => handleChange('tokenSource', 'id_token') }
]}
selectedItemId={tokenSource}
placement="bottom-end"
>
<div className="flex items-center justify-end token-placement-label select-none">
{tokenSource === 'id_token' ? 'ID Token' : 'Access Token'}
<IconCaretDown className="caret ml-1 mr-1" size={14} strokeWidth={2} />
</div>
</MenuDropdown>
</div>
</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="single-line-editor-wrapper flex-1">
@@ -171,19 +178,26 @@ const OAuth2PasswordCredentials = ({ save, item = {}, request, handleRun, update
<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">
<MenuDropdown
items={[
{ id: 'header', label: 'Header', onClick: () => handleChange('tokenPlacement', 'header') },
{ id: 'url', label: 'URL', onClick: () => handleChange('tokenPlacement', 'url') }
]}
selectedItemId={tokenPlacement}
placement="bottom-end"
>
<div 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} />
<Dropdown onCreate={onDropdownCreate} icon={<TokenPlacementIcon />} placement="bottom-end">
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('tokenPlacement', 'header');
}}
>
Header
</div>
</MenuDropdown>
<div
className="dropdown-item"
onClick={() => {
dropdownTippyRef.current.hide();
handleChange('tokenPlacement', 'url');
}}
>
URL
</div>
</Dropdown>
</div>
</div>
{

View File

@@ -1,28 +1,9 @@
import { useState } from 'react';
import toast from 'react-hot-toast';
import { buildClientSchema, buildSchema, validateSchema } from 'graphql';
import { buildClientSchema, buildSchema } from 'graphql';
import { fetchGqlSchema } from 'utils/network';
import { simpleHash, safeParseJSON } from 'utils/common';
const buildAndValidateSchema = (data) => {
let schema;
if (typeof data === 'object') {
schema = buildClientSchema(data);
} else {
schema = buildSchema(data);
}
// Validate the schema to catch issues like empty object types
// The GraphQL spec requires object types to have at least one field
const validationErrors = validateSchema(schema);
if (validationErrors.length > 0) {
const errorMessages = validationErrors.map((e) => e.message).join('; ');
console.warn('GraphQL schema has validation issues:', errorMessages);
}
return { schema, validationErrors };
};
const schemaHashPrefix = 'bruno.graphqlSchema';
const useGraphqlSchema = (endpoint, environment, request, collection) => {
@@ -38,11 +19,13 @@ const useGraphqlSchema = (endpoint, environment, request, collection) => {
return null;
}
let parsedData = safeParseJSON(saved);
const { schema } = buildAndValidateSchema(parsedData);
return schema;
} catch (err) {
localStorage.removeItem(localStorageKey);
console.warn('Failed to load cached GraphQL schema:', err.message);
if (typeof parsedData === 'object') {
return buildClientSchema(parsedData);
} else {
return buildSchema(parsedData);
}
} catch {
localStorage.setItem(localStorageKey, null);
return null;
}
});
@@ -89,19 +72,13 @@ const useGraphqlSchema = (endpoint, environment, request, collection) => {
data = await loadSchemaFromIntrospection();
}
if (data) {
const { schema, validationErrors } = buildAndValidateSchema(data);
setSchema(schema);
localStorage.setItem(localStorageKey, JSON.stringify(data));
if (validationErrors.length > 0) {
const errorMessages = validationErrors.map((e) => e.message).join('; ');
toast(`Schema validation issues: ${errorMessages}`, {
icon: '⚠️',
duration: 5000
});
if (typeof data === 'object') {
setSchema(buildClientSchema(data));
} else {
toast.success('GraphQL Schema loaded successfully');
setSchema(buildSchema(data));
}
localStorage.setItem(localStorageKey, JSON.stringify(data));
toast.success('GraphQL Schema loaded successfully');
}
} catch (err) {
setError(err);

View File

@@ -118,7 +118,7 @@ const GrpcQueryUrl = ({ item, collection, handleRun }) => {
};
const handleReflection = async (url, isManualRefresh = false) => {
const { methods, error, fromCache } = await reflectionManagement.loadMethodsFromReflection(url, isManualRefresh);
const { methods, error } = await reflectionManagement.loadMethodsFromReflection(url, isManualRefresh);
if (error) {
toast.error(`Failed to load gRPC methods: ${error.message || 'Unknown error'}`);
@@ -139,7 +139,7 @@ const GrpcQueryUrl = ({ item, collection, handleRun }) => {
}));
}
if (!fromCache && methods && methods.length > 0) {
if (methods && methods.length > 0) {
toast.success(`Loaded ${methods.length} gRPC methods from reflection`);
}
@@ -161,7 +161,7 @@ const GrpcQueryUrl = ({ item, collection, handleRun }) => {
};
const handleProtoFileLoad = async (filePath, isManualRefresh = false) => {
const { methods, error, fromCache } = await protoFileManagement.loadMethodsFromProtoFile(filePath, isManualRefresh);
const { methods, error } = await protoFileManagement.loadMethodsFromProtoFile(filePath, isManualRefresh);
if (error) {
console.error('Failed to load gRPC methods:', error);
@@ -174,9 +174,7 @@ const GrpcQueryUrl = ({ item, collection, handleRun }) => {
setGrpcMethods(methods);
setIsReflectionMode(false);
if (!fromCache) {
toast.success(`Loaded ${methods.length} gRPC methods from proto file`);
}
toast.success(`Loaded ${methods.length} gRPC methods from proto file`);
if (methods && methods.length > 0) {
const haveSelectedMethod = selectedGrpcMethod && methods.some((method) => method.path === selectedGrpcMethod.path);

View File

@@ -24,25 +24,6 @@ const CodeMirror = require('codemirror');
const md = new MD();
const AUTO_COMPLETE_AFTER_KEY = /^[a-zA-Z0-9_@(]$/;
const createSafeGraphQLLinter = () => {
// Get the original GraphQL lint helper registered by codemirror-graphql
const originalLinter = CodeMirror.helpers?.lint?.graphql?.[0];
return (text, options) => {
try {
if (originalLinter) {
return originalLinter(text, options);
}
return [];
} catch (error) {
// Log the error but don't crash - return empty lint results
// This can happen if the schema has validation issues
console.warn('GraphQL lint error (schema may be invalid):', error.message);
return [];
}
};
};
export default class QueryEditor extends React.Component {
constructor(props) {
super(props);
@@ -76,7 +57,6 @@ export default class QueryEditor extends React.Component {
minFoldSize: 4
},
lint: {
getAnnotations: createSafeGraphQLLinter(),
schema: this.props.schema,
validationRules: this.props.validationRules ?? null,
// linting accepts string or FragmentDefinitionNode[]
@@ -176,15 +156,8 @@ export default class QueryEditor extends React.Component {
CodeMirror.signal(this.editor, 'change', this.editor);
}
if (this.props.value !== prevProps.value && this.props.value !== this.cachedValue && this.editor) {
// TODO: temporary fix for keeping cursor state when auto save and new line insertion collide PR#7098
const nextValue = this.props.value ?? '';
const currentValue = this.editor.getValue();
if (this.editor.hasFocus?.() && currentValue !== nextValue) {
this.cachedValue = currentValue;
} else {
this.cachedValue = nextValue;
this.editor.setValue(nextValue);
}
this.cachedValue = this.props.value;
this.editor.setValue(this.props.value);
}
if (this.props.theme !== prevProps.theme && this.editor) {

View File

@@ -179,7 +179,6 @@ const HttpMethodSelector = ({ method = DEFAULT_METHOD, onMethodSelect, showCaret
items={menuItems}
placement="bottom-start"
selectedItemId={selectedItemId}
data-testid="method-selector"
>
<TriggerButton method={method} showCaret={showCaret} methodSpanRef={methodSpanRef} />
</MenuDropdown>

View File

@@ -103,7 +103,7 @@ const RequestBodyMode = ({ item, collection }) => {
return (
<StyledWrapper>
<div className="inline-flex items-center cursor-pointer body-mode-selector" data-testid="request-body-mode-selector">
<div className="inline-flex items-center cursor-pointer body-mode-selector">
<MenuDropdown
items={menuItems}
placement="bottom-end"

View File

@@ -46,7 +46,7 @@ const RequestBody = ({ item, collection }) => {
};
return (
<StyledWrapper className="w-full" data-testid="request-body-editor">
<StyledWrapper className="w-full">
<CodeEditor
collection={collection}
item={item}

View File

@@ -1,11 +1,9 @@
import React, { useState, useEffect, useRef } from 'react';
import get from 'lodash/get';
import find from 'lodash/find';
import { useDispatch, useSelector } from 'react-redux';
import CodeEditor from 'components/CodeEditor';
import { updateRequestScript, updateResponseScript } from 'providers/ReduxStore/slices/collections';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { updateScriptPaneTab } from 'providers/ReduxStore/slices/tabs';
import { useTheme } from 'providers/Theme';
import { Tabs, TabsList, TabsTrigger, TabsContent } from 'components/Tabs';
import StatusDot from 'components/StatusDot';
@@ -17,22 +15,27 @@ const Script = ({ item, collection }) => {
const requestScript = item.draft ? get(item, 'draft.request.script.req') : get(item, 'request.script.req');
const responseScript = item.draft ? get(item, 'draft.request.script.res') : get(item, 'request.script.res');
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const focusedTab = find(tabs, (t) => t.uid === activeTabUid);
const scriptPaneTab = focusedTab?.scriptPaneTab;
// Default to post-response if pre-request script is empty (only when scriptPaneTab is null/undefined)
const getDefaultTab = () => {
// Default to post-response if pre-request script is empty
const getInitialTab = () => {
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
return hasPreRequestScript ? 'pre-request' : 'post-response';
};
const activeTab = scriptPaneTab || getDefaultTab();
const [activeTab, setActiveTab] = useState(getInitialTab);
const prevItemUidRef = useRef(item.uid);
const { displayedTheme } = useTheme();
const preferences = useSelector((state) => state.app.preferences);
// Update active tab only when switching to a different item
useEffect(() => {
if (prevItemUidRef.current !== item.uid) {
prevItemUidRef.current = item.uid;
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
setActiveTab(hasPreRequestScript ? 'pre-request' : 'post-response');
}
}, [item.uid, requestScript]);
// Refresh CodeMirror when tab becomes visible
useEffect(() => {
// Small delay to ensure DOM is updated
@@ -73,25 +76,17 @@ const Script = ({ item, collection }) => {
const hasPreRequestScript = requestScript && requestScript.trim().length > 0;
const hasPostResponseScript = responseScript && responseScript.trim().length > 0;
const onScriptTabChange = (tab) => {
dispatch(updateScriptPaneTab({ uid: item.uid, scriptPaneTab: tab }));
};
return (
<div className="w-full h-full flex flex-col">
<Tabs value={activeTab} onValueChange={onScriptTabChange}>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="pre-request">
Pre Request
{hasPreRequestScript && (
<StatusDot type={item.preRequestScriptErrorMessage ? 'error' : 'default'} />
)}
{hasPreRequestScript && <StatusDot />}
</TabsTrigger>
<TabsTrigger value="post-response">
Post Response
{hasPostResponseScript && (
<StatusDot type={item.postResponseScriptErrorMessage ? 'error' : 'default'} />
)}
{hasPostResponseScript && <StatusDot />}
</TabsTrigger>
</TabsList>

View File

@@ -58,7 +58,6 @@ const Tags = ({ item, collection }) => {
handleRemoveTag={handleRemove}
tags={tags}
onSave={handleRequestSave}
collectionFormat={collection.format}
/>
</div>
);

View File

@@ -116,7 +116,6 @@ const Settings = ({ item, collection }) => {
label="URL Encoding"
description="Automatically encode query parameters in the URL"
size="medium"
data-testid="encode-url-toggle"
/>
</div>

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { closeTabs } from 'providers/ReduxStore/slices/collections/actions';
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
import { useDispatch } from 'react-redux';
import ErrorBanner from 'ui/ErrorBanner';
import Button from 'ui/Button';

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState, useCallback } from 'react';
import { closeTabs } from 'providers/ReduxStore/slices/collections/actions';
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
import { useDispatch } from 'react-redux';
import ErrorBanner from 'ui/ErrorBanner';
import Button from 'ui/Button';

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { closeTabs } from 'providers/ReduxStore/slices/collections/actions';
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
import { useDispatch } from 'react-redux';
import ErrorBanner from 'ui/ErrorBanner';
import Button from 'ui/Button';

View File

@@ -32,7 +32,7 @@ import WSRequestPane from 'components/RequestPane/WSRequestPane';
import WSResponsePane from 'components/ResponsePane/WsResponsePane';
import { useTabPaneBoundaries } from 'hooks/useTabPaneBoundaries/index';
import ResponseExample from 'components/ResponseExample';
import WorkspaceOverview from 'components/WorkspaceHome/WorkspaceOverview';
import WorkspaceHome from 'components/WorkspaceHome';
import Preferences from 'components/Preferences';
import EnvironmentSettings from 'components/Environments/EnvironmentSettings';
import GlobalEnvironmentSettings from 'components/Environments/GlobalEnvironmentSettings';
@@ -43,6 +43,9 @@ const MIN_TOP_PANE_HEIGHT = 150;
const MIN_BOTTOM_PANE_HEIGHT = 150;
const RequestTabPanel = () => {
if (typeof window == 'undefined') {
return <div></div>;
}
const dispatch = useDispatch();
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
@@ -50,8 +53,6 @@ const RequestTabPanel = () => {
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
const _collections = useSelector((state) => state.collections.collections);
const preferences = useSelector((state) => state.app.preferences);
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
const isVerticalLayout = preferences?.layout?.responsePaneOrientation === 'vertical';
const isConsoleOpen = useSelector((state) => state.logs.isConsoleOpen);
@@ -170,10 +171,6 @@ const RequestTabPanel = () => {
}
}, [isConsoleOpen, isVerticalLayout]);
if (typeof window == 'undefined') {
return <div></div>;
}
if (!activeTabUid || !focusedTab) {
return <div className="pb-4 px-4">An error occurred!</div>;
}
@@ -186,14 +183,6 @@ const RequestTabPanel = () => {
return <Preferences />;
}
if (focusedTab.type === 'workspaceOverview') {
return activeWorkspace ? <WorkspaceOverview workspace={activeWorkspace} /> : null;
}
if (focusedTab.type === 'workspaceEnvironments') {
return <GlobalEnvironmentSettings />;
}
if (!focusedTab.uid || !focusedTab.collectionUid) {
return <div className="pb-4 px-4">An error occurred!</div>;
}

View File

@@ -1,129 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.collection-switcher {
display: flex;
align-items: center;
gap: 4px;
}
.switcher-trigger {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
border: none;
border-radius: 4px;
background: transparent;
color: ${(props) => props.theme.text};
cursor: pointer;
font-weight: 500;
transition: background-color 0.15s ease;
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
}
.switcher-name {
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&.scratch-collection {
font-weight: 600;
font-size: 15px;
}
}
.tab-count {
font-size: 11px;
font-weight: 500;
padding: 1px 6px;
border-radius: 10px;
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
min-width: 18px;
text-align: center;
}
.chevron {
opacity: 0.6;
flex-shrink: 0;
}
}
.workspace-actions-trigger {
cursor: pointer;
opacity: 0.6;
padding: 4px;
border-radius: 4px;
transition: opacity 0.15s ease, background-color 0.15s ease;
&:hover {
opacity: 1;
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
}
}
.workspace-rename-container {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
}
.workspace-name-input {
font-size: 14px;
font-weight: 500;
padding: 2px 6px;
border: 1px solid ${(props) => props.theme.input.border};
border-radius: 3px;
background: ${(props) => props.theme.input.bg};
color: ${(props) => props.theme.text};
outline: none;
min-width: 150px;
&:focus {
border-color: ${(props) => props.theme.input.focusBorder};
}
}
.inline-actions {
display: flex;
align-items: center;
gap: 2px;
}
.inline-action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: none;
border-radius: 3px;
cursor: pointer;
background: transparent;
color: ${(props) => props.theme.text};
&:hover {
background: ${(props) => props.theme.sidebar.collection.item.hoverBg};
}
&.save {
color: ${(props) => props.theme.colors.text.green};
}
&.cancel {
color: ${(props) => props.theme.colors.text.danger};
}
}
.workspace-error {
font-size: 12px;
color: ${(props) => props.theme.colors.text.danger};
margin-left: 8px;
}
`;
export default StyledWrapper;

View File

@@ -1,467 +0,0 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
IconCategory,
IconBox,
IconChevronDown,
IconRun,
IconEye,
IconSettings,
IconDots,
IconEdit,
IconX,
IconCheck,
IconFolder,
IconUpload
} from '@tabler/icons';
import { switchWorkspace, renameWorkspaceAction, exportWorkspaceAction } from 'providers/ReduxStore/slices/workspaces/actions';
import { updateWorkspace } from 'providers/ReduxStore/slices/workspaces';
import { showInFolder } from 'providers/ReduxStore/slices/collections/actions';
import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs';
import { uuid } from 'utils/common';
import toast from 'react-hot-toast';
import Dropdown from 'components/Dropdown';
import CloseWorkspace from 'components/Sidebar/CloseWorkspace';
import EnvironmentSelector from 'components/Environments/EnvironmentSelector';
import ToolHint from 'components/ToolHint';
import JsSandboxMode from 'components/SecuritySettings/JsSandboxMode';
import ActionIcon from 'ui/ActionIcon';
import { getRevealInFolderLabel } from 'utils/common/platform';
import classNames from 'classnames';
import StyledWrapper from './StyledWrapper';
const CollectionHeader = ({ collection, isScratchCollection }) => {
const dispatch = useDispatch();
const workspaces = useSelector((state) => state.workspaces.workspaces);
const activeWorkspaceUid = useSelector((state) => state.workspaces.activeWorkspaceUid);
const collections = useSelector((state) => state.collections.collections);
const tabs = useSelector((state) => state.tabs.tabs);
// Get the current active workspace
const currentWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
// Workspace rename state
const [isRenamingWorkspace, setIsRenamingWorkspace] = useState(false);
const [workspaceNameInput, setWorkspaceNameInput] = useState('');
const [workspaceNameError, setWorkspaceNameError] = useState('');
const [closeWorkspaceModalOpen, setCloseWorkspaceModalOpen] = useState(false);
const switcherRef = useRef();
const workspaceActionsRef = useRef();
const workspaceNameInputRef = useRef(null);
const workspaceRenameContainerRef = useRef(null);
const onSwitcherCreate = (ref) => (switcherRef.current = ref);
const onWorkspaceActionsCreate = (ref) => (workspaceActionsRef.current = ref);
// Auto-enter rename mode when workspace is newly created
useEffect(() => {
if (isScratchCollection && currentWorkspace?.isNewlyCreated) {
dispatch(updateWorkspace({ uid: currentWorkspace.uid, isNewlyCreated: false }));
setIsRenamingWorkspace(true);
setWorkspaceNameInput(currentWorkspace.name || '');
setWorkspaceNameError('');
const timer = setTimeout(() => {
workspaceNameInputRef.current?.focus();
workspaceNameInputRef.current?.select();
}, 50);
return () => clearTimeout(timer);
}
}, [isScratchCollection, currentWorkspace?.isNewlyCreated, currentWorkspace?.uid, currentWorkspace?.name, dispatch]);
const handleCancelWorkspaceRename = useCallback(() => {
setIsRenamingWorkspace(false);
setWorkspaceNameInput('');
setWorkspaceNameError('');
}, []);
useEffect(() => {
if (!isRenamingWorkspace) return;
const handleClickOutside = (event) => {
if (workspaceRenameContainerRef.current && !workspaceRenameContainerRef.current.contains(event.target)) {
handleCancelWorkspaceRename();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isRenamingWorkspace, handleCancelWorkspaceRename]);
if (!collection) {
return null;
}
// Get mounted collections for the current workspace (excluding scratch collections)
const mountedCollections = collections.filter((c) => {
if (c.mountStatus !== 'mounted') return false;
const isScratch = workspaces.some((w) => w.scratchCollectionUid === c.uid);
if (isScratch) return false;
const workspaceCollectionPaths = currentWorkspace?.collections?.map((wc) => wc.path) || [];
return workspaceCollectionPaths.some((wcPath) => c.pathname === wcPath);
});
// Count tabs for the current collection
const tabCount = tabs.filter((t) => t.collectionUid === collection.uid).length;
// Get tab count for a given collection uid
const getTabCount = (collectionUid) => tabs.filter((t) => t.collectionUid === collectionUid).length;
// Get tab count for workspace (scratch collection)
const workspaceTabCount = currentWorkspace?.scratchCollectionUid
? getTabCount(currentWorkspace.scratchCollectionUid)
: 0;
// Display name and icon based on context
const displayName = isScratchCollection
? (currentWorkspace?.name || 'Untitled Workspace')
: (collection.name || 'Untitled Collection');
const DisplayIcon = isScratchCollection ? IconCategory : IconBox;
// Switcher handlers
const handleSwitchToWorkspace = (workspaceUid) => {
switcherRef.current?.hide();
if (workspaceUid) {
dispatch(switchWorkspace(workspaceUid));
}
};
const handleSwitchToCollection = (targetCollection) => {
switcherRef.current?.hide();
if (!targetCollection?.uid) return;
const existingTab = tabs.find((t) => t.collectionUid === targetCollection.uid);
if (existingTab) {
dispatch(focusTab({ uid: existingTab.uid }));
} else {
dispatch(
addTab({
uid: targetCollection.uid,
collectionUid: targetCollection.uid,
type: 'collection-settings'
})
);
}
};
// Collection action handlers
const handleRun = () => {
dispatch(
addTab({
uid: uuid(),
collectionUid: collection.uid,
type: 'collection-runner'
})
);
};
const viewVariables = () => {
dispatch(
addTab({
uid: uuid(),
collectionUid: collection.uid,
type: 'variables'
})
);
};
const viewCollectionSettings = () => {
dispatch(
addTab({
uid: collection.uid,
collectionUid: collection.uid,
type: 'collection-settings'
})
);
};
// Workspace action handlers (only used when isScratchCollection is true)
const handleRenameWorkspaceClick = () => {
workspaceActionsRef.current?.hide();
setIsRenamingWorkspace(true);
setWorkspaceNameInput(currentWorkspace?.name || '');
setWorkspaceNameError('');
setTimeout(() => {
workspaceNameInputRef.current?.focus();
workspaceNameInputRef.current?.select();
}, 50);
};
const handleCloseWorkspaceClick = () => {
workspaceActionsRef.current?.hide();
if (currentWorkspace?.type === 'default') {
toast.error('Cannot close the default workspace');
return;
}
setCloseWorkspaceModalOpen(true);
};
const handleShowInFolder = () => {
workspaceActionsRef.current?.hide();
const pathname = currentWorkspace?.pathname;
if (pathname) {
dispatch(showInFolder(pathname)).catch(() => {
toast.error('Error opening the folder');
});
}
};
const handleExportWorkspace = () => {
workspaceActionsRef.current?.hide();
const uid = currentWorkspace?.uid;
if (!uid) return;
dispatch(exportWorkspaceAction(uid))
.then((result) => {
if (!result?.canceled) {
toast.success('Workspace exported successfully');
}
})
.catch((error) => {
toast.error(error?.message || 'Error exporting workspace');
});
};
const validateWorkspaceName = (name) => {
const trimmed = name?.trim();
if (!trimmed) {
return 'Name is required';
}
if (trimmed.length > 255) {
return 'Must be 255 characters or less';
}
return null;
};
const handleSaveWorkspaceRename = () => {
const error = validateWorkspaceName(workspaceNameInput);
if (error) {
setWorkspaceNameError(error);
return;
}
const uid = currentWorkspace?.uid;
if (!uid) return;
dispatch(renameWorkspaceAction(uid, workspaceNameInput))
.then(() => {
toast.success('Workspace renamed!');
setIsRenamingWorkspace(false);
setWorkspaceNameInput('');
setWorkspaceNameError('');
})
.catch((err) => {
toast.error(err?.message || 'An error occurred while renaming the workspace');
setWorkspaceNameError(err?.message || 'Failed to rename workspace');
});
};
const handleWorkspaceNameChange = (e) => {
setWorkspaceNameInput(e.target.value);
if (workspaceNameError) {
setWorkspaceNameError('');
}
};
const handleWorkspaceNameKeyDown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSaveWorkspaceRename();
} else if (e.key === 'Escape') {
e.preventDefault();
handleCancelWorkspaceRename();
}
};
// Check if workspace actions should be shown
const showWorkspaceActions = isScratchCollection
&& currentWorkspace
&& currentWorkspace.type !== 'default'
&& !isRenamingWorkspace;
return (
<StyledWrapper>
{closeWorkspaceModalOpen && currentWorkspace?.uid && (
<CloseWorkspace
workspaceUid={currentWorkspace.uid}
onClose={() => setCloseWorkspaceModalOpen(false)}
/>
)}
<div className="flex items-center justify-between gap-2 py-2 px-4">
{/* Left side: Switcher dropdown or rename input */}
<div className="collection-switcher">
{isRenamingWorkspace ? (
<div className="workspace-rename-container" ref={workspaceRenameContainerRef}>
<DisplayIcon size={18} strokeWidth={1.5} />
<input
ref={workspaceNameInputRef}
type="text"
className="workspace-name-input"
value={workspaceNameInput}
onChange={handleWorkspaceNameChange}
onKeyDown={handleWorkspaceNameKeyDown}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
/>
<div className="inline-actions">
<button
className="inline-action-btn save"
onClick={handleSaveWorkspaceRename}
onMouseDown={(e) => e.preventDefault()}
title="Save"
>
<IconCheck size={14} strokeWidth={2} />
</button>
<button
className="inline-action-btn cancel"
onClick={handleCancelWorkspaceRename}
onMouseDown={(e) => e.preventDefault()}
title="Cancel"
>
<IconX size={14} strokeWidth={2} />
</button>
</div>
{workspaceNameError && (
<span className="workspace-error">{workspaceNameError}</span>
)}
</div>
) : (
<Dropdown
placement="bottom-start"
onCreate={onSwitcherCreate}
appendTo={() => document.body}
icon={(
<button className="switcher-trigger">
<DisplayIcon size={18} strokeWidth={1.5} />
<span className={classNames('switcher-name', { 'scratch-collection': isScratchCollection })}>{displayName}</span>
<IconChevronDown size={14} strokeWidth={1.5} className="chevron" />
</button>
)}
>
{/* Workspace section */}
{currentWorkspace && (
<>
<div className="label-item">Workspace</div>
<div
className={classNames('dropdown-item', {
'dropdown-item-active': isScratchCollection
})}
onClick={() => handleSwitchToWorkspace(currentWorkspace.uid)}
>
<div className="dropdown-icon">
<IconCategory size={16} strokeWidth={1.5} />
</div>
<span className="dropdown-label">
{currentWorkspace.name || 'Untitled Workspace'}
</span>
{workspaceTabCount > 0 && (
<span className="dropdown-tab-count">{workspaceTabCount}</span>
)}
</div>
</>
)}
{/* Collections section */}
{mountedCollections.length > 0 && (
<>
<div className="dropdown-separator" />
<div className="label-item">Collections</div>
{mountedCollections.map((col) => {
const colTabCount = getTabCount(col.uid);
return (
<div
key={col.uid}
className={classNames('dropdown-item', {
'dropdown-item-active': !isScratchCollection && collection.uid === col.uid
})}
onClick={() => handleSwitchToCollection(col)}
>
<div className="dropdown-icon">
<IconBox size={16} strokeWidth={1.5} />
</div>
<span className="dropdown-label">{col.name || 'Untitled Collection'}</span>
{colTabCount > 0 && (
<span className="dropdown-tab-count">{colTabCount}</span>
)}
</div>
);
})}
</>
)}
</Dropdown>
)}
{/* Workspace actions dropdown */}
{showWorkspaceActions && (
<Dropdown
placement="bottom-start"
onCreate={onWorkspaceActionsCreate}
appendTo={() => document.body}
icon={<IconDots size={18} strokeWidth={1.5} className="workspace-actions-trigger" />}
>
<div className="dropdown-item" onClick={handleRenameWorkspaceClick}>
<div className="dropdown-icon">
<IconEdit size={16} strokeWidth={1.5} />
</div>
<span>Rename</span>
</div>
<div className="dropdown-item" onClick={handleShowInFolder}>
<div className="dropdown-icon">
<IconFolder size={16} strokeWidth={1.5} />
</div>
<span>{getRevealInFolderLabel()}</span>
</div>
<div className="dropdown-item" onClick={handleExportWorkspace}>
<div className="dropdown-icon">
<IconUpload size={16} strokeWidth={1.5} />
</div>
<span>Export</span>
</div>
<div className="dropdown-item" onClick={handleCloseWorkspaceClick}>
<div className="dropdown-icon">
<IconX size={16} strokeWidth={1.5} />
</div>
<span>Close</span>
</div>
</Dropdown>
)}
</div>
{/* Right side: Actions (only for regular collections) */}
{!isScratchCollection && (
<div className="flex flex-grow gap-1 items-center justify-end">
<ToolHint text="Runner" toolhintId="RunnerToolhintId" place="bottom">
<ActionIcon onClick={handleRun} aria-label="Runner" size="sm">
<IconRun size={16} strokeWidth={1.5} />
</ActionIcon>
</ToolHint>
<ToolHint text="Variables" toolhintId="VariablesToolhintId">
<ActionIcon onClick={viewVariables} aria-label="Variables" size="sm">
<IconEye size={16} strokeWidth={1.5} />
</ActionIcon>
</ToolHint>
<ToolHint text="Collection Settings" toolhintId="CollectionSettingsToolhintId">
<ActionIcon onClick={viewCollectionSettings} aria-label="Collection Settings" size="sm">
<IconSettings size={16} strokeWidth={1.5} />
</ActionIcon>
</ToolHint>
<JsSandboxMode collection={collection} />
<span className="ml-2">
<EnvironmentSelector collection={collection} />
</span>
</div>
)}
</div>
</StyledWrapper>
);
};
export default CollectionHeader;

View File

@@ -0,0 +1,5 @@
import styled from 'styled-components';
const StyledWrapper = styled.div``;
export default StyledWrapper;

View File

@@ -0,0 +1,83 @@
import React from 'react';
import { uuid } from 'utils/common';
import { IconBox, IconRun, IconEye, IconSettings } from '@tabler/icons';
import EnvironmentSelector from 'components/Environments/EnvironmentSelector';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { useDispatch } from 'react-redux';
import ToolHint from 'components/ToolHint';
import StyledWrapper from './StyledWrapper';
import JsSandboxMode from 'components/SecuritySettings/JsSandboxMode';
import ActionIcon from 'ui/ActionIcon';
const CollectionToolBar = ({ collection }) => {
const dispatch = useDispatch();
if (!collection) {
return null;
}
const handleRun = () => {
dispatch(
addTab({
uid: uuid(),
collectionUid: collection.uid,
type: 'collection-runner'
})
);
};
const viewVariables = () => {
dispatch(
addTab({
uid: uuid(),
collectionUid: collection.uid,
type: 'variables'
})
);
};
const viewCollectionSettings = () => {
dispatch(
addTab({
uid: collection.uid,
collectionUid: collection.uid,
type: 'collection-settings'
})
);
};
return (
<StyledWrapper>
<div className="flex items-center justify-between gap-2 py-2 px-4">
<button className="flex items-center cursor-pointer hover:underline bg-transparent border-none p-0 text-inherit" onClick={viewCollectionSettings}>
<IconBox size={18} strokeWidth={1.5} />
<span className="ml-2 mr-4 font-medium">{collection?.name}</span>
</button>
<div className="flex flex-grow gap-1 items-center justify-end">
<ToolHint text="Runner" toolhintId="RunnerToolhintId" place="bottom">
<ActionIcon onClick={handleRun} aria-label="Runner" size="sm">
<IconRun size={16} strokeWidth={1.5} />
</ActionIcon>
</ToolHint>
<ToolHint text="Variables" toolhintId="VariablesToolhintId">
<ActionIcon onClick={viewVariables} aria-label="Variables" size="sm">
<IconEye size={16} strokeWidth={1.5} />
</ActionIcon>
</ToolHint>
<ToolHint text="Collection Settings" toolhintId="CollectionSettingsToolhintId">
<ActionIcon onClick={viewCollectionSettings} aria-label="Collection Settings" size="sm">
<IconSettings size={16} strokeWidth={1.5} />
</ActionIcon>
</ToolHint>
{/* ToolHint is present within the JsSandboxMode component */}
<JsSandboxMode collection={collection} />
<span className="ml-2">
<EnvironmentSelector collection={collection} />
</span>
</div>
</div>
</StyledWrapper>
);
};
export default CollectionToolBar;

View File

@@ -1,8 +1,8 @@
import React, { useState, useRef, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import { closeTabs, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import { deleteRequestDraft } from 'providers/ReduxStore/slices/collections';
import { saveRequest, closeTabs } from 'providers/ReduxStore/slices/collections/actions';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { hasExampleChanges, findItemInCollection } from 'utils/collections';
import ExampleIcon from 'components/Icons/ExampleIcon';
import ConfirmRequestClose from '../RequestTab/ConfirmRequestClose';

View File

@@ -1,6 +1,6 @@
import React from 'react';
import GradientCloseButton from './GradientCloseButton';
import { IconVariable, IconSettings, IconRun, IconFolder, IconShieldLock, IconDatabase, IconWorld, IconHome } from '@tabler/icons';
import { IconVariable, IconSettings, IconRun, IconFolder, IconShieldLock, IconDatabase, IconWorld } from '@tabler/icons';
const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDraft }) => {
const getTabInfo = (type, tabName) => {
@@ -69,22 +69,6 @@ const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDra
</>
);
}
case 'workspaceOverview': {
return (
<>
<IconHome size={14} strokeWidth={1.5} className="special-tab-icon flex-shrink-0" />
<span className="ml-1 tab-name">Overview</span>
</>
);
}
case 'workspaceEnvironments': {
return (
<>
<IconWorld size={14} strokeWidth={1.5} className="special-tab-icon flex-shrink-0" />
<span className="ml-1 tab-name">Environments</span>
</>
);
}
}
};
@@ -96,7 +80,7 @@ const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDra
>
{getTabInfo(type, tabName)}
</div>
{handleCloseClick && <GradientCloseButton hasChanges={hasDraft} onClick={(e) => handleCloseClick(e)} />}
<GradientCloseButton hasChanges={hasDraft} onClick={(e) => handleCloseClick(e)} />
</>
);
};

View File

@@ -1,7 +1,7 @@
import React, { useCallback, useState, useRef, Fragment, useMemo, useEffect } from 'react';
import get from 'lodash/get';
import { makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import { saveRequest, saveCollectionRoot, saveFolderRoot, saveEnvironment, closeTabs } from 'providers/ReduxStore/slices/collections/actions';
import { closeTabs, makeTabPermanent } from 'providers/ReduxStore/slices/tabs';
import { saveRequest, saveCollectionRoot, saveFolderRoot, saveEnvironment } from 'providers/ReduxStore/slices/collections/actions';
import { deleteRequestDraft, deleteCollectionDraft, deleteFolderDraft, clearEnvironmentsDraft } from 'providers/ReduxStore/slices/collections';
import { clearGlobalEnvironmentDraft } from 'providers/ReduxStore/slices/global-environments';
import { saveGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
@@ -36,10 +36,6 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
const [showConfirmEnvironmentClose, setShowConfirmEnvironmentClose] = useState(false);
const [showConfirmGlobalEnvironmentClose, setShowConfirmGlobalEnvironmentClose] = useState(false);
const tabs = useSelector((state) => state.tabs.tabs);
const activeTabUid = useSelector((state) => state.tabs.activeTabUid);
const activeTab = tabs.find((t) => t.uid === activeTabUid);
const menuDropdownRef = useRef();
const item = findItemInCollection(collection, tab.uid);
@@ -90,62 +86,6 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
};
}, [item, item?.name, method, setHasOverflow]);
useEffect(() => {
const handleCloseTabFromHotkeys = () => {
if (!activeTabUid || !activeTab) return;
// Only the active tab component should handle this
if (tab.uid !== activeTabUid) return;
// Always compute item for the active tab
const activeItem = findItemInCollection(collection, activeTabUid);
switch (activeTab.type) {
case 'request':
if (activeItem && hasRequestChanges(activeItem)) {
console.log('Item have changes');
setShowConfirmClose(true);
} else {
console.log('Item dont have changes');
dispatch(closeTabs({ tabUids: [activeTabUid] }));
}
break;
case 'collection-settings':
if (collection?.draft) {
setShowConfirmCollectionClose(true);
} else {
dispatch(closeTabs({ tabUids: [activeTabUid] }));
}
break;
case 'folder-settings': {
const folderItem = findItemInCollection(collection, activeTab.folderUid || tab.folderUid);
if (folderItem?.draft) {
setShowConfirmFolderClose(true);
} else {
dispatch(closeTabs({ tabUids: [activeTabUid] }));
}
break;
}
case 'environment-settings':
if (collection?.environmentsDraft) {
setShowConfirmEnvironmentClose(true);
} else {
dispatch(closeTabs({ tabUids: [activeTabUid] }));
}
break;
default:
break;
}
};
window.addEventListener('close-active-tab', handleCloseTabFromHotkeys);
return () => window.removeEventListener('close-active-tab', handleCloseTabFromHotkeys);
}, [dispatch, activeTab, activeTabUid, tab.uid, collection]);
const handleCloseClick = (event) => {
event.stopPropagation();
event.preventDefault();
@@ -232,7 +172,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
setShowConfirmGlobalEnvironmentClose(true);
};
if (['collection-settings', 'collection-overview', 'folder-settings', 'variables', 'collection-runner', 'environment-settings', 'global-environment-settings', 'preferences', 'workspaceOverview', 'workspaceEnvironments'].includes(tab.type)) {
if (['collection-settings', 'collection-overview', 'folder-settings', 'variables', 'collection-runner', 'environment-settings', 'global-environment-settings', 'preferences'].includes(tab.type)) {
return (
<StyledWrapper
className={`flex items-center justify-between tab-container px-2 ${tab.preview ? 'italic' : ''}`}
@@ -296,7 +236,6 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
{showConfirmEnvironmentClose && tab.type === 'environment-settings' && (
<ConfirmCloseEnvironment
isGlobal={false}
isDotEnv={collection.environmentsDraft?.environmentUid?.startsWith('dotenv:')}
onCancel={() => setShowConfirmEnvironmentClose(false)}
onCloseWithoutSave={() => {
dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid }));
@@ -305,25 +244,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
}}
onSaveAndClose={() => {
const draft = collection.environmentsDraft;
if (draft?.environmentUid?.startsWith('dotenv:')) {
const onSuccess = () => {
cleanup();
dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid }));
dispatch(closeTabs({ tabUids: [tab.uid] }));
setShowConfirmEnvironmentClose(false);
};
const onFailed = () => {
cleanup();
setShowConfirmEnvironmentClose(false);
};
const cleanup = () => {
window.removeEventListener('dotenv-save-complete', onSuccess);
window.removeEventListener('dotenv-save-failed', onFailed);
};
window.addEventListener('dotenv-save-complete', onSuccess, { once: true });
window.addEventListener('dotenv-save-failed', onFailed, { once: true });
window.dispatchEvent(new Event('dotenv-save'));
} else if (draft?.environmentUid && draft?.variables) {
if (draft?.environmentUid && draft?.variables) {
dispatch(saveEnvironment(draft.variables, draft.environmentUid, collection.uid))
.then(() => {
dispatch(clearEnvironmentsDraft({ collectionUid: collection.uid }));
@@ -342,7 +263,6 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
{showConfirmGlobalEnvironmentClose && tab.type === 'global-environment-settings' && (
<ConfirmCloseEnvironment
isGlobal={true}
isDotEnv={globalEnvironmentDraft?.environmentUid?.startsWith('dotenv:')}
onCancel={() => setShowConfirmGlobalEnvironmentClose(false)}
onCloseWithoutSave={() => {
dispatch(clearGlobalEnvironmentDraft());
@@ -351,25 +271,7 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
}}
onSaveAndClose={() => {
const draft = globalEnvironmentDraft;
if (draft?.environmentUid?.startsWith('dotenv:')) {
const onSuccess = () => {
cleanup();
dispatch(clearGlobalEnvironmentDraft());
dispatch(closeTabs({ tabUids: [tab.uid] }));
setShowConfirmGlobalEnvironmentClose(false);
};
const onFailed = () => {
cleanup();
setShowConfirmGlobalEnvironmentClose(false);
};
const cleanup = () => {
window.removeEventListener('dotenv-save-complete', onSuccess);
window.removeEventListener('dotenv-save-failed', onFailed);
};
window.addEventListener('dotenv-save-complete', onSuccess, { once: true });
window.addEventListener('dotenv-save-failed', onFailed, { once: true });
window.dispatchEvent(new Event('dotenv-save'));
} else if (draft?.environmentUid && draft?.variables) {
if (draft?.environmentUid && draft?.variables) {
dispatch(saveGlobalEnvironment({ variables: draft.variables, environmentUid: draft.environmentUid }))
.then(() => {
dispatch(clearGlobalEnvironmentDraft());
@@ -395,10 +297,6 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
<SpecialTab handleCloseClick={handleCloseEnvironmentSettings} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} hasDraft={hasEnvironmentDraft} />
) : tab.type === 'global-environment-settings' ? (
<SpecialTab handleCloseClick={handleCloseGlobalEnvironmentSettings} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} hasDraft={hasGlobalEnvironmentDraft} />
) : tab.type === 'workspaceOverview' ? (
<SpecialTab handleCloseClick={null} type={tab.type} />
) : tab.type === 'workspaceEnvironments' ? (
<SpecialTab handleCloseClick={null} type={tab.type} />
) : (
<SpecialTab handleCloseClick={handleCloseClick} handleDoubleClick={() => dispatch(makeTabPermanent({ uid: tab.uid }))} type={tab.type} />
)}
@@ -576,42 +474,19 @@ function RequestTabMenu({ menuDropdownRef, tabLabelRef, collectionRequestTabs, t
} catch (err) { }
}
async function handleCloseMultipleTabs(tabs) {
const tabUidsToClose = [];
for (const tab of tabs) {
const item = findItemInCollection(collection, tab.uid);
if (item && hasRequestChanges(item)) {
try {
await dispatch(saveRequest(item.uid, collection.uid, true));
} catch (err) {
continue;
}
}
if (tab?.uid) {
tabUidsToClose.push(tab.uid);
}
}
if (tabUidsToClose.length > 0) {
dispatch(closeTabs({ tabUids: tabUidsToClose }));
}
}
async function handleCloseOtherTabs() {
const otherTabs = collectionRequestTabs.filter((_, index) => index !== tabIndex);
await handleCloseMultipleTabs(otherTabs);
await Promise.all(otherTabs.map((tab) => handleCloseTab(tab.uid)));
}
async function handleCloseTabsToTheLeft() {
const leftTabs = collectionRequestTabs.filter((_, index) => index < tabIndex);
await handleCloseMultipleTabs(leftTabs);
await Promise.all(leftTabs.map((tab) => handleCloseTab(tab.uid)));
}
async function handleCloseTabsToTheRight() {
const rightTabs = collectionRequestTabs.filter((_, index) => index > tabIndex);
await handleCloseMultipleTabs(rightTabs);
await Promise.all(rightTabs.map((tab) => handleCloseTab(tab.uid)));
}
function handleCloseSavedTabs() {
@@ -622,7 +497,7 @@ function RequestTabMenu({ menuDropdownRef, tabLabelRef, collectionRequestTabs, t
}
async function handleCloseAllTabs() {
await handleCloseMultipleTabs(collectionRequestTabs);
await Promise.all(collectionRequestTabs.map((tab) => handleCloseTab(tab.uid)));
}
const menuItems = useMemo(() => [

View File

@@ -1,4 +1,4 @@
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import React, { useState, useRef, useEffect, useCallback } from 'react';
import find from 'lodash/find';
import filter from 'lodash/filter';
import classnames from 'classnames';
@@ -6,7 +6,7 @@ import { IconChevronRight, IconChevronLeft } from '@tabler/icons';
import { useSelector, useDispatch } from 'react-redux';
import { focusTab, reorderTabs } from 'providers/ReduxStore/slices/tabs';
import NewRequest from 'components/Sidebar/NewRequest';
import CollectionHeader from './CollectionHeader';
import CollectionToolBar from './CollectionToolBar';
import RequestTab from './RequestTab';
import StyledWrapper from './StyledWrapper';
import DraggableTab from './DraggableTab';
@@ -27,7 +27,6 @@ const RequestTabs = () => {
const leftSidebarWidth = useSelector((state) => state.app.leftSidebarWidth);
const sidebarCollapsed = useSelector((state) => state.app.sidebarCollapsed);
const screenWidth = useSelector((state) => state.app.screenWidth);
const workspaces = useSelector((state) => state.workspaces.workspaces);
const createSetHasOverflow = useCallback((tabUid) => {
return (hasOverflow) => {
@@ -47,10 +46,6 @@ const RequestTabs = () => {
const activeCollection = find(collections, (c) => c?.uid === activeTab?.collectionUid);
const collectionRequestTabs = filter(tabs, (t) => t.collectionUid === activeTab?.collectionUid);
const isScratchCollection = useMemo(() => {
return activeCollection ? workspaces.some((w) => w.scratchCollectionUid === activeCollection.uid) : false;
}, [workspaces, activeCollection]);
useEffect(() => {
if (!activeTabUid || !activeTab) return;
@@ -115,12 +110,7 @@ const RequestTabs = () => {
)}
{collectionRequestTabs && collectionRequestTabs.length ? (
<>
{activeCollection && (
<CollectionHeader
collection={activeCollection}
isScratchCollection={isScratchCollection}
/>
)}
{activeCollection && <CollectionToolBar collection={activeCollection} />}
<div className="flex items-center gap-2 pl-2" ref={collectionTabsRef}>
<div className={classnames('scroll-chevrons', { hidden: !showChevrons })}>
<ActionIcon size="lg" onClick={leftSlide} aria-label="Left Chevron" style={{ marginBottom: '3px' }}>

View File

@@ -4,7 +4,7 @@ import { IconBookmark } from '@tabler/icons';
import { addResponseExample } from 'providers/ReduxStore/slices/collections';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { insertTaskIntoQueue } from 'providers/ReduxStore/slices/app';
import { uuid, formatResponse } from 'utils/common';
import { uuid } from 'utils/common';
import toast from 'react-hot-toast';
import CreateExampleModal from 'components/ResponseExample/CreateExampleModal';
import { getBodyType } from 'utils/responseBodyProcessor';
@@ -83,7 +83,7 @@ const ResponseBookmark = forwardRef(({ item, collection, responseSize, children
const contentType = contentTypeHeader?.value?.toLowerCase() || '';
const bodyType = getBodyType(contentType);
const content = formatResponse(response.data, response.dataBuffer, bodyType);
const content = response.data;
const exampleData = {
name: name,

View File

@@ -9,7 +9,7 @@ import ActionIcon from 'ui/ActionIcon/index';
const ResponseDownload = forwardRef(({ item, children }, ref) => {
const { ipcRenderer } = window;
const response = item.response || {};
const isDisabled = !response.dataBuffer || response.stream?.running;
const isDisabled = !response.dataBuffer ? true : false;
const elementRef = useRef(null);
useImperativeHandle(ref, () => ({

View File

@@ -164,7 +164,7 @@ const RunConfigurationPanel = ({ collection, selectedItems, setSelectedItems })
if (!items?.length) return;
items.forEach((item) => {
if (isItemARequest(item) && !item.partial && !item.isTransient) {
if (isItemARequest(item) && !item.partial) {
const relativePath = path.relative(collection.pathname, path.dirname(item.pathname));
const folderPath = relativePath !== '.' ? relativePath : '';

View File

@@ -1,39 +0,0 @@
import React, { useMemo, useCallback, memo } from 'react';
import { useSelector } from 'react-redux';
import { IconDatabase, IconLoader2 } from '@tabler/icons';
import { areItemsLoading } from 'utils/collections';
const CollectionListItem = memo(({ collectionUid, collectionPath, collectionName, isSelected, onSelect }) => {
const collection = useSelector((state) =>
state.collections.collections.find((c) => c.uid === collectionUid || c.pathname === collectionPath)
);
const isLoading = useMemo(() => {
const isMounted = collection?.mountStatus === 'mounted';
const fullyLoaded = isMounted && !areItemsLoading(collection);
return isSelected && !fullyLoaded;
}, [collection, isSelected]);
const handleClick = useCallback(() => {
if (!isLoading) {
onSelect();
}
}, [isLoading, onSelect]);
return (
<li
className={`collection-item ${isLoading ? 'mounting' : ''} ${isSelected ? 'selected' : ''}`}
onClick={handleClick}
>
<div className="collection-item-content">
<IconDatabase size={16} strokeWidth={1.5} />
<span className="collection-item-name">{collectionName}</span>
</div>
{isLoading && (
<IconLoader2 size={16} strokeWidth={1.5} className="animate-spin" />
)}
</li>
);
});
export default CollectionListItem;

View File

@@ -3,7 +3,7 @@ import { useSelector, useDispatch } from 'react-redux';
import { pluralizeWord } from 'utils/common';
import { IconAlertTriangle, IconDeviceFloppy } from '@tabler/icons';
import { clearAllSaveTransientRequestModals } from 'providers/ReduxStore/slices/collections';
import { closeTabs } from 'providers/ReduxStore/slices/collections/actions';
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
import toast from 'react-hot-toast';
import Modal from 'components/Modal';
import Button from 'ui/Button';

View File

@@ -1,38 +0,0 @@
import React from 'react';
import { IconChevronRight } from '@tabler/icons';
const FolderBreadcrumbs = ({
collectionName,
breadcrumbs,
isAtRoot,
onNavigateToRoot,
onNavigateToBreadcrumb
}) => {
return (
<>
<span
className={!isAtRoot ? 'collection-name-breadcrumb' : ''}
onClick={!isAtRoot ? onNavigateToRoot : undefined}
>
{collectionName}
</span>
{breadcrumbs.map((breadcrumb, index) => (
<React.Fragment key={breadcrumb.uid}>
<IconChevronRight size={16} strokeWidth={1.5} className="collection-name-chevron" />
<span
className="collection-name-breadcrumb"
onClick={(e) => {
e.stopPropagation();
onNavigateToBreadcrumb(index);
}}
>
{breadcrumb.name}
</span>
</React.Fragment>
))}
{isAtRoot && <IconChevronRight size={16} strokeWidth={1.5} className="collection-name-chevron" />}
</>
);
};
export default FolderBreadcrumbs;

View File

@@ -127,84 +127,11 @@ const StyledWrapper = styled.div`
color: ${(props) => props.theme.colors.text.muted};
}
.collection-list {
border: 1px solid ${(props) => props.theme.border.border1};
border-radius: ${(props) => props.theme.border.radius.sm};
max-height: 320px;
overflow-y: auto;
background-color: ${(props) => props.theme.modal.body.bg};
padding: 8px 8px;
}
.collection-list-items {
display: flex;
flex-direction: column;
gap: 4px;
list-style: none;
padding: 0;
margin: 0;
border-radius: ${(props) => props.theme.border.radius.sm};
}
.collection-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
cursor: pointer;
transition: background-color 0.15s ease;
color: ${(props) => props.theme.text};
border-radius: ${(props) => props.theme.border.radius.sm};
user-select: none;
border: 1px solid ${(props) => props.theme.border.border1};
&:hover {
background-color: ${(props) => props.theme.plainGrid.hoverBg};
border-color: ${(props) => props.theme.colors.text.muted};
}
}
.collection-item-content {
display: flex;
align-items: center;
gap: 10px;
}
.collection-item-name {
color: ${(props) => props.theme.text};
font-weight: 500;
}
.collection-empty-state {
padding: 20px 16px;
text-align: center;
font-size: 14px;
color: ${(props) => props.theme.colors.text.muted};
line-height: 1.5;
}
.animate-spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.icon-success {
color: ${(props) => props.theme.colors.success};
}
.custom-modal-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 0px 0px 0px;
padding: 16px 0px;
background-color: ${(props) => props.theme.modal.body.bg};
border-top: 1px solid ${(props) => props.theme.border.border0};
border-bottom-left-radius: ${(props) => props.theme.border.radius.base};
@@ -236,17 +163,30 @@ const StyledWrapper = styled.div`
padding-top: 12px;
}
.new-folder-header {
.new-folder-content {
display: flex;
align-items: center;
align-items: flex-start;
gap: 8px;
margin-bottom: 4px;
}
.new-folder-header-label {
font-size: 13px;
.new-folder-inputs {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
}
.new-folder-name-input-wrapper {
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
}
.new-folder-name-label {
font-size: 12px;
font-weight: 500;
color: ${(props) => props.theme.text};
color: ${(props) => props.theme.colors.text.muted};
}
.new-folder-input-row {
@@ -307,41 +247,13 @@ const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 4px;
}
.new-folder-filesystem-label {
font-size: 13px;
font-size: 12px;
font-weight: 500;
color: ${(props) => props.theme.text};
}
.filesystem-input-container {
display: flex;
align-items: center;
background: ${(props) => props.theme.requestTabPanel.url.bg};
border-radius: 4px;
padding: 8px 12px;
border: 1px solid rgba(0, 0, 0, 0.08);
margin-top: 8px;
}
.filesystem-input-icon {
flex-shrink: 0;
margin-right: 8px;
color: ${(props) => props.theme.colors.text.yellow};
}
.filesystem-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: ${(props) => props.theme.colors.text.yellow};
font-size: ${(props) => props.theme.font.size.base};
&::placeholder {
color: ${(props) => props.theme.colors.text.muted};
}
color: ${(props) => props.theme.colors.text.muted};
}
.new-folder-toggle-filesystem-btn {
@@ -370,98 +282,6 @@ const StyledWrapper = styled.div`
font-size: 12px;
margin-top: 4px;
}
/* New Collection Input Styles */
.new-collection-item {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
border-top: 1px solid ${(props) => props.theme.border.border1};
margin-top: 4px;
&:first-child {
border-top: none;
margin-top: 0;
}
}
.new-collection-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.new-collection-label {
font-size: 13px;
font-weight: 500;
color: ${(props) => props.theme.text};
}
.new-collection-input {
width: 100%;
padding: 8px 10px;
border-radius: ${(props) => props.theme.border.radius.sm};
background-color: ${(props) => props.theme.input.bg};
border: 1px solid ${(props) => props.theme.input.border};
color: ${(props) => props.theme.text};
font-size: 14px;
transition: border-color ease-in-out 0.1s;
&:focus {
border: solid 1px ${(props) => props.theme.input.focusBorder} !important;
outline: none !important;
}
&::placeholder {
color: ${(props) => props.theme.colors.text.muted};
}
&.cursor-pointer {
cursor: pointer;
}
}
.new-collection-location-row {
display: flex;
align-items: center;
gap: 8px;
}
.new-collection-select {
width: 100%;
padding: 8px 10px;
padding-right: 28px;
border-radius: ${(props) => props.theme.border.radius.sm};
background-color: ${(props) => props.theme.input.bg};
border: 1px solid ${(props) => props.theme.input.border};
color: ${(props) => props.theme.text};
font-size: 14px;
cursor: pointer;
transition: border-color ease-in-out 0.1s;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
&:focus {
border: solid 1px ${(props) => props.theme.input.focusBorder} !important;
outline: none !important;
}
}
.new-collection-actions-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 4px;
}
.collection-empty-state-subtitle {
font-size: 12px;
margin-top: 4px;
opacity: 0.8;
}
`;
export default StyledWrapper;

View File

@@ -1,29 +1,21 @@
import React, { useState, useMemo, useEffect, useCallback } from 'react';
import React, { useState, useMemo, useEffect, useRef } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import Modal from 'components/Modal';
import SearchInput from 'components/SearchInput';
import Button from 'ui/Button';
import { IconFolder, IconChevronRight, IconCheck, IconX, IconEye, IconEyeOff, IconEdit, IconArrowBackUp } from '@tabler/icons';
import PathDisplay from 'components/PathDisplay/index';
import Help from 'components/Help';
import { IconFolder, IconChevronRight, IconCheck, IconX, IconEye, IconEyeOff } from '@tabler/icons';
import filter from 'lodash/filter';
import toast from 'react-hot-toast';
import StyledWrapper from './StyledWrapper';
import CollectionListItem from './CollectionListItem';
import FolderBreadcrumbs from './FolderBreadcrumbs';
import useCollectionFolderTree from 'hooks/useCollectionFolderTree';
import { removeSaveTransientRequestModal } from 'providers/ReduxStore/slices/collections';
import { insertTaskIntoQueue } from 'providers/ReduxStore/slices/app';
import { newFolder, closeTabs, mountCollection, createCollection, browseDirectory } from 'providers/ReduxStore/slices/collections/actions';
import { removeSaveTransientRequestModal, deleteRequestDraft } from 'providers/ReduxStore/slices/collections';
import { newFolder } from 'providers/ReduxStore/slices/collections/actions';
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
import { sanitizeName, validateName, validateNameError } from 'utils/common/regex';
import { resolveRequestFilename } from 'utils/common/platform';
import path from 'utils/common/path';
import { transformRequestToSaveToFilesystem, findCollectionByUid, findItemInCollection, areItemsLoading } from 'utils/collections';
import { transformRequestToSaveToFilesystem, findCollectionByUid, findItemInCollection } from 'utils/collections';
import { DEFAULT_COLLECTION_FORMAT } from 'utils/common/constants';
import { itemSchema } from '@usebruno/schema';
import { uuid } from 'utils/common';
import { formatIpcError } from 'utils/common/error';
import get from 'lodash/get';
const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOpen = false, onClose }) => {
const dispatch = useDispatch();
@@ -36,32 +28,12 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
const item = itemProp;
const collection = collectionProp;
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
const allCollections = useSelector((state) => state.collections.collections);
const isScratchCollection = activeWorkspace?.scratchCollectionUid === collection?.uid;
const preferences = useSelector((state) => state.app.preferences);
const isDefaultWorkspace = activeWorkspace?.type === 'default';
const defaultCollectionLocation = isDefaultWorkspace
? get(preferences, 'general.defaultLocation', '')
: (activeWorkspace?.pathname ? `${activeWorkspace.pathname}/collections` : '');
const availableCollections = useMemo(() => {
if (!isScratchCollection || !activeWorkspace) return [];
return (activeWorkspace.collections || []).map((wc) => {
const fullCollection = allCollections.find((c) => c.pathname === wc.path);
// Use stable deterministic UID based on path to avoid duplicate Redux entries
const stableUid = wc.path ? `pending-${wc.path.replace(/[^a-zA-Z0-9]/g, '-')}` : uuid();
return fullCollection || { ...wc, uid: stableUid, mountStatus: 'unmounted' };
}).filter((c) => !workspaces.some((w) => w.scratchCollectionUid === c.uid));
}, [isScratchCollection, activeWorkspace, allCollections, workspaces]);
const handleClose = () => {
if (onClose) {
onClose();
return;
}
// Remove from Redux array
dispatch(removeSaveTransientRequestModal({ itemUid: item.uid }));
};
const [requestName, setRequestName] = useState(item?.name || '');
@@ -70,29 +42,7 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
const [newFolderName, setNewFolderName] = useState('');
const [newFolderDirectoryName, setNewFolderDirectoryName] = useState('');
const [showFilesystemName, setShowFilesystemName] = useState(false);
const [isEditingFolderFilename, setIsEditingFolderFilename] = useState(false);
const [pendingFolderNavigation, setPendingFolderNavigation] = useState(null);
// State for new collection creation
const [newCollection, setNewCollection] = useState({ show: false, name: '', location: '', format: DEFAULT_COLLECTION_FORMAT });
const [selectedTargetCollectionPath, setSelectedTargetCollectionPath] = useState(null);
const [isSelectingCollection, setIsSelectingCollection] = useState(isScratchCollection);
const folderTreeCollectionUid = selectedTargetCollectionPath
? availableCollections.find((c) => (c.path || c.pathname) === selectedTargetCollectionPath)?.uid
: collection?.uid;
const selectedTargetCollection = selectedTargetCollectionPath
? availableCollections.find((c) => (c.path || c.pathname) === selectedTargetCollectionPath)
: null;
useEffect(() => {
const isMounted = selectedTargetCollection?.mountStatus === 'mounted';
const isFullyLoaded = isMounted && !areItemsLoading(selectedTargetCollection);
if (selectedTargetCollectionPath && isFullyLoaded) {
setIsSelectingCollection(false);
}
}, [selectedTargetCollectionPath, selectedTargetCollection]);
const newFolderInputRef = useRef(null);
const {
currentFolders,
@@ -105,39 +55,27 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
getCurrentSelectedFolder,
reset,
isAtRoot
} = useCollectionFolderTree(folderTreeCollectionUid);
} = useCollectionFolderTree(collection?.uid);
const resetForm = useCallback(() => {
setRequestName(item?.name || '');
const resetForm = () => {
setRequestName(item.name || '');
setSearchText('');
reset();
setShowNewFolderInput(false);
setNewFolderName('');
setNewFolderDirectoryName('');
setShowFilesystemName(false);
setIsEditingFolderFilename(false);
setPendingFolderNavigation(null);
setSelectedTargetCollectionPath(null);
setIsSelectingCollection(isScratchCollection);
// Reset new collection state
setNewCollection({ show: false, name: '', location: '', format: DEFAULT_COLLECTION_FORMAT });
}, [item?.name, isScratchCollection, reset]);
};
useEffect(() => {
if (isOpen && item) {
resetForm();
}
}, [isOpen, item, resetForm]);
isOpen && item && resetForm();
}, [isOpen, item]);
useEffect(() => {
if (pendingFolderNavigation) {
const newFolder = currentFolders.find((f) => f.filename === pendingFolderNavigation);
if (newFolder) {
navigateIntoFolder(newFolder.uid);
setPendingFolderNavigation(null);
}
if (showNewFolderInput && newFolderInputRef.current) {
newFolderInputRef.current.focus();
}
}, [currentFolders, pendingFolderNavigation, navigateIntoFolder]);
}, [showNewFolderInput]);
const filteredFolders = useMemo(() => {
if (!searchText.trim()) {
@@ -152,41 +90,16 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
handleClose();
};
const handleSelectCollection = useCallback((selectedCollection) => {
const collectionPath = selectedCollection.path || selectedCollection.pathname;
const isMounted = selectedCollection.mountStatus === 'mounted';
const isFullyLoaded = isMounted && !areItemsLoading(selectedCollection);
setSelectedTargetCollectionPath(collectionPath);
if (isFullyLoaded) {
setIsSelectingCollection(false);
return;
}
if (!isMounted && selectedCollection.mountStatus !== 'mounting') {
dispatch(
mountCollection({
collectionUid: selectedCollection.uid || uuid(),
collectionPathname: collectionPath,
brunoConfig: selectedCollection.brunoConfig
})
);
}
}, [dispatch]);
const handleConfirm = async () => {
if (!item || !collection || !latestItem) {
return;
}
const targetCollection = selectedTargetCollection || collection;
try {
const { ipcRenderer } = window;
const selectedFolder = getCurrentSelectedFolder();
const targetDirname = selectedFolder ? selectedFolder.pathname : targetCollection.pathname;
const targetDirname = selectedFolder ? selectedFolder.pathname : collection.pathname;
const trimmedName = requestName.trim();
if (!trimmedName || trimmedName.length === 0) {
@@ -194,11 +107,6 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
return;
}
if (!validateName(trimmedName)) {
toast.error(validateNameError(trimmedName));
return;
}
const sanitizedFilename = sanitizeName(trimmedName);
const itemToSave = latestItem.draft ? { ...latestItem, ...latestItem.draft } : { ...latestItem };
@@ -208,32 +116,23 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
const transformedItem = transformRequestToSaveToFilesystem(itemToSave);
await itemSchema.validate(transformedItem);
const targetFormat = targetCollection.format || DEFAULT_COLLECTION_FORMAT;
const sourceFormat = collection.format || DEFAULT_COLLECTION_FORMAT;
const targetFilename = resolveRequestFilename(sanitizedFilename, targetFormat);
const targetPathname = path.join(targetDirname, targetFilename);
const format = collection.format || DEFAULT_COLLECTION_FORMAT;
const targetFilename = resolveRequestFilename(sanitizedFilename, format);
await ipcRenderer.invoke('renderer:save-transient-request', {
sourcePathname: item.pathname,
targetDirname,
targetFilename,
request: transformedItem,
format: targetFormat,
sourceFormat
format
});
dispatch(
insertTaskIntoQueue({
uid: uuid(),
type: 'OPEN_REQUEST',
collectionUid: targetCollection.uid,
itemPathname: targetPathname,
preview: false
closeTabs({
tabUids: [item.uid]
})
);
dispatch(closeTabs({ tabUids: [item.uid] }));
dispatch({
type: 'collections/deleteItem',
payload: {
@@ -245,7 +144,7 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
toast.success('Request saved successfully');
handleClose();
} catch (err) {
toast.error(formatIpcError(err) || 'Failed to save request');
toast.error(err?.message || 'Failed to save request');
console.error('Error saving request:', err);
}
};
@@ -255,7 +154,6 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
setNewFolderName('');
setNewFolderDirectoryName('');
setShowFilesystemName(false);
setIsEditingFolderFilename(false);
};
const handleCancelNewFolder = () => {
@@ -263,38 +161,26 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
setNewFolderName('');
setNewFolderDirectoryName('');
setShowFilesystemName(false);
setIsEditingFolderFilename(false);
};
const handleNewFolderNameChange = (value) => {
setNewFolderName(value);
if (!isEditingFolderFilename) {
if (!showFilesystemName) {
setNewFolderDirectoryName(sanitizeName(value));
}
};
const handleDirectoryNameChange = (value) => {
setNewFolderDirectoryName(value);
};
const handleCreateNewFolder = async () => {
const trimmedFolderName = newFolderName.trim();
if (!trimmedFolderName) {
toast.error('Folder name is required');
return;
}
if (!validateName(trimmedFolderName)) {
toast.error(validateNameError(trimmedFolderName));
return;
}
const directoryName = newFolderDirectoryName.trim() || sanitizeName(trimmedFolderName);
const directoryName = newFolderDirectoryName.trim() || sanitizeName(newFolderName.trim());
const parentFolder = getCurrentParentFolder();
const targetCollectionUid = selectedTargetCollection?.uid || collection?.uid;
try {
await dispatch(newFolder(trimmedFolderName, directoryName, targetCollectionUid, parentFolder?.uid));
await dispatch(newFolder(newFolderName.trim(), directoryName, collection?.uid, parentFolder?.uid));
toast.success('New folder created!');
setPendingFolderNavigation(directoryName);
handleCancelNewFolder();
} catch (err) {
const errorMessage = err?.message || 'An error occurred while adding the folder';
@@ -302,58 +188,11 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
}
};
// New Collection handlers
const handleShowNewCollection = () => {
setNewCollection({ show: true, name: '', location: defaultCollectionLocation, format: DEFAULT_COLLECTION_FORMAT });
};
const handleCancelNewCollection = () => {
setNewCollection({ show: false, name: '', location: '', format: DEFAULT_COLLECTION_FORMAT });
};
const handleBrowseCollectionLocation = () => {
dispatch(browseDirectory())
.then((dirPath) => {
if (typeof dirPath === 'string') {
setNewCollection((prev) => ({ ...prev, location: dirPath }));
}
})
.catch(() => {});
};
const handleCreateNewCollection = async () => {
const trimmedName = newCollection.name.trim();
if (!trimmedName) {
toast.error('Collection name is required');
return;
}
if (!validateName(trimmedName)) {
toast.error(validateNameError(trimmedName));
return;
}
if (!newCollection.location) {
toast.error('Location is required');
return;
}
try {
await dispatch(createCollection(trimmedName, sanitizeName(trimmedName), newCollection.location, { format: newCollection.format }));
toast.success('Collection created!');
handleCancelNewCollection();
} catch (err) {
toast.error(err?.message || 'An error occurred while creating the collection');
}
};
const handleFolderClick = (folderUid) => {
navigateIntoFolder(folderUid);
setSearchText('');
};
const handleBreadcrumbNavigate = useCallback((index) => {
navigateToBreadcrumb(index);
setSearchText('');
}, [navigateToBreadcrumb]);
if (!isOpen) {
return null;
}
@@ -362,7 +201,7 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
<StyledWrapper>
<Modal
size="md"
title={isSelectingCollection ? 'Select Collection' : 'Save Request'}
title="Save Request"
handleCancel={handleCancel}
handleConfirm={handleConfirm}
confirmText="Save"
@@ -384,360 +223,168 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
spellCheck="false"
value={requestName}
onChange={(e) => setRequestName(e.target.value)}
autoFocus={!isSelectingCollection}
autoFocus={true}
onFocus={(e) => e.target.select()}
/>
</div>
<div className="collections-section">
<div className="collections-label">
{isSelectingCollection ? 'Select a collection to save to' : 'Save to Collections'}
</div>
{isScratchCollection && (
<div className="collection-name">
<span
className={isSelectingCollection ? '' : 'collection-name-breadcrumb'}
onClick={!isSelectingCollection ? () => {
setIsSelectingCollection(true);
setSelectedTargetCollectionPath(null);
reset();
} : undefined}
>
Collections
</span>
{!isSelectingCollection && (
<div className="collections-label">Save to Collections</div>
{collection && (
<div
className={`collection-name ${!isAtRoot ? 'collection-name-clickable' : ''}`}
onClick={!isAtRoot ? navigateToRoot : undefined}
>
<span>{collection.name}</span>
{breadcrumbs.length > 0 && (
<>
<IconChevronRight size={16} strokeWidth={1.5} className="collection-name-chevron" />
<FolderBreadcrumbs
collectionName={(selectedTargetCollection || collection).name}
breadcrumbs={breadcrumbs}
isAtRoot={isAtRoot}
onNavigateToRoot={navigateToRoot}
onNavigateToBreadcrumb={handleBreadcrumbNavigate}
/>
{breadcrumbs.map((breadcrumb, index) => (
<React.Fragment key={breadcrumb.uid}>
<IconChevronRight size={16} strokeWidth={1.5} className="collection-name-chevron" />
<span
className="collection-name-breadcrumb"
onClick={(e) => {
e.stopPropagation();
navigateToBreadcrumb(index);
setSearchText('');
}}
>
{breadcrumb.name}
</span>
</React.Fragment>
))}
</>
)}
{isAtRoot && <IconChevronRight size={16} strokeWidth={1.5} className="collection-name-chevron" />}
</div>
)}
{isSelectingCollection ? (
<div className="collection-list">
{availableCollections.length > 0 || newCollection.show ? (
<ul className="collection-list-items">
{availableCollections.map((coll) => {
const collPath = coll.path || coll.pathname;
return (
<CollectionListItem
key={collPath}
collectionUid={coll.uid}
collectionPath={collPath}
collectionName={coll.name}
isSelected={selectedTargetCollectionPath === collPath}
onSelect={() => handleSelectCollection(coll)}
/>
);
})}
{newCollection.show && (
<li className="new-collection-item">
<div className="new-collection-field">
<label className="new-collection-label">
Collection name
</label>
<input
ref={(node) => node?.focus()}
type="text"
className="new-collection-input"
placeholder="Enter collection name"
value={newCollection.name}
onChange={(e) => setNewCollection((prev) => ({ ...prev, name: e.target.value }))}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
handleCreateNewCollection();
} else if (e.key === 'Escape') {
e.stopPropagation();
handleCancelNewCollection();
}
}}
/>
</div>
<div className="search-container">
<SearchInput
searchText={searchText}
setSearchText={setSearchText}
placeholder="Search for folder"
autoFocus={false}
/>
</div>
<div className="new-collection-field">
<label className="new-collection-label flex items-center">
Location
<Help width={250} placement="top">
<p>
Bruno stores your collections on your computer's filesystem.
</p>
<p className="mt-2">
Choose the location where you want to store this collection.
</p>
</Help>
</label>
<div className="new-collection-location-row">
<input
type="text"
className="new-collection-input cursor-pointer"
placeholder="Select location"
value={newCollection.location}
readOnly
onClick={handleBrowseCollectionLocation}
/>
<Button
type="button"
variant="outline"
color="secondary"
size="sm"
rounded="sm"
onClick={handleBrowseCollectionLocation}
>
Browse
</Button>
</div>
</div>
<div className="new-collection-field">
<label className="new-collection-label flex items-center">
File Format
<Help width={300} placement="top">
<p>
Choose the file format for storing requests in this collection.
</p>
<p className="mt-2">
<strong>OpenCollection (YAML):</strong> Industry-standard YAML format (.yml files)
</p>
<p className="mt-1">
<strong>BRU:</strong> Bruno's native file format (.bru files)
</p>
</Help>
</label>
<select
className="new-collection-select"
value={newCollection.format}
onChange={(e) => setNewCollection((prev) => ({ ...prev, format: e.target.value }))}
>
<option value="yml">OpenCollection (YAML)</option>
<option value="bru">BRU Format (.bru)</option>
</select>
</div>
<div className="new-collection-actions-footer">
<Button
type="button"
color="secondary"
variant="ghost"
size="sm"
onClick={handleCancelNewCollection}
>
Cancel
</Button>
<Button
type="button"
color="primary"
size="sm"
onClick={handleCreateNewCollection}
>
Save
</Button>
</div>
</li>
)}
</ul>
) : (
<div className="collection-empty-state">
<p>No collections Yet</p>
<p className="collection-empty-state-subtitle">Collections help you organize your requests. Create your first one to save this request.</p>
</div>
)}
</div>
) : (
<>
{!isScratchCollection && (selectedTargetCollection || collection) && (
<div className="collection-name">
<FolderBreadcrumbs
collectionName={(selectedTargetCollection || collection).name}
breadcrumbs={breadcrumbs}
isAtRoot={isAtRoot}
onNavigateToRoot={navigateToRoot}
onNavigateToBreadcrumb={handleBreadcrumbNavigate}
/>
</div>
)}
<div className="search-container">
<SearchInput
searchText={searchText}
setSearchText={setSearchText}
placeholder="Search for folder"
autoFocus={false}
/>
</div>
<div className="folder-list">
{filteredFolders.length > 0 || showNewFolderInput ? (
<ul className="folder-list-items">
{filteredFolders.map((folder) => (
<li
key={folder.uid}
className={`folder-item ${selectedFolderUid === folder.uid ? 'selected' : ''}`}
onClick={() => handleFolderClick(folder.uid)}
>
<div className="folder-item-content">
<IconFolder size={16} strokeWidth={1.5} />
<span className="folder-item-name">{folder.name}</span>
</div>
<IconChevronRight size={16} strokeWidth={1.5} />
</li>
))}
{showNewFolderInput && (
<li className="new-folder-item">
<div className="new-folder-header">
<IconFolder size={16} strokeWidth={1.5} />
<label className="new-folder-header-label">
{showFilesystemName ? 'New Folder name (in bruno)' : 'New Folder name'}
</label>
</div>
<div className="new-folder-input-row">
<input
ref={(node) => node?.focus()}
type="text"
className="new-folder-input"
placeholder="Untitled new folder"
value={newFolderName}
onChange={(e) => handleNewFolderNameChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
handleCreateNewFolder();
} else if (e.key === 'Escape') {
e.stopPropagation();
handleCancelNewFolder();
}
}}
/>
<div className="new-folder-actions">
<button
type="button"
className="new-folder-action-btn"
onClick={handleCancelNewFolder}
title="Cancel"
>
<IconX size={16} strokeWidth={1.5} />
</button>
<button
type="button"
className="new-folder-action-btn"
onClick={handleCreateNewFolder}
title="Create folder"
>
<IconCheck size={16} strokeWidth={1.5} />
</button>
<div className="folder-list">
{filteredFolders.length > 0 || showNewFolderInput ? (
<ul className="folder-list-items">
{filteredFolders.map((folder) => (
<li
key={folder.uid}
className={`folder-item ${selectedFolderUid === folder.uid ? 'selected' : ''}`}
onClick={() => handleFolderClick(folder.uid)}
>
<div className="folder-item-content">
<IconFolder size={16} strokeWidth={1.5} />
<span className="folder-item-name">{folder.name}</span>
</div>
<IconChevronRight size={16} strokeWidth={1.5} />
</li>
))}
{showNewFolderInput && (
<li className="new-folder-item">
<div className="new-folder-content">
<IconFolder size={16} strokeWidth={1.5} />
<div className="new-folder-inputs">
<div className="new-folder-name-input-wrapper">
{showFilesystemName && (
<label className="new-folder-name-label">New Folder name (in bruno)</label>
)}
<div className="new-folder-input-row">
<input
ref={newFolderInputRef}
type="text"
className="new-folder-input"
placeholder="Untitled new folder"
value={newFolderName}
onChange={(e) => handleNewFolderNameChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleCreateNewFolder();
} else if (e.key === 'Escape') {
handleCancelNewFolder();
}
}}
/>
<div className="new-folder-actions">
<button
type="button"
className="new-folder-action-btn"
onClick={handleCancelNewFolder}
title="Cancel"
>
<IconX size={16} strokeWidth={1.5} />
</button>
<button
type="button"
className="new-folder-action-btn"
onClick={handleCreateNewFolder}
title="Create folder"
>
<IconCheck size={16} strokeWidth={1.5} />
</button>
</div>
</div>
</div>
{showFilesystemName && (
<div className="new-folder-filesystem-wrapper">
<div className="flex items-center justify-between">
<label className="new-folder-filesystem-label flex items-center font-medium">
Folder Name <small className="font-normal text-muted ml-1">(on filesystem)</small>
<Help width={300} placement="top">
<p>
You can choose to save the folder as a different name on your file system versus what is displayed in the app.
</p>
</Help>
</label>
{isEditingFolderFilename ? (
<IconArrowBackUp
className="cursor-pointer opacity-50 hover:opacity-80"
size={16}
strokeWidth={1.5}
onClick={() => setIsEditingFolderFilename(false)}
/>
) : (
<IconEdit
className="cursor-pointer opacity-50 hover:opacity-80"
size={16}
strokeWidth={1.5}
onClick={() => setIsEditingFolderFilename(true)}
/>
)}
</div>
{isEditingFolderFilename ? (
<div className="relative flex flex-row gap-1 items-center justify-between">
<input
type="text"
className="block textbox mt-2 w-full"
placeholder="Folder Name"
value={newFolderDirectoryName}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={(e) => setNewFolderDirectoryName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
handleCreateNewFolder();
} else if (e.key === 'Escape') {
e.stopPropagation();
handleCancelNewFolder();
}
}}
/>
</div>
) : (
<div className="relative flex flex-row gap-1 items-center justify-between">
<PathDisplay
iconType="folder"
baseName={newFolderDirectoryName}
/>
</div>
)}
<label className="new-folder-filesystem-label">Name on filesystem</label>
<input
type="text"
className="new-folder-input"
value={newFolderDirectoryName}
onChange={(e) => handleDirectoryNameChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleCreateNewFolder();
}
}}
/>
</div>
)}
<button
type="button"
className="new-folder-toggle-filesystem-btn"
onClick={() => {
setShowFilesystemName(!showFilesystemName);
setNewFolderDirectoryName(sanitizeName(newFolderName));
setIsEditingFolderFilename(false);
}}
>
{showFilesystemName ? (
<>
<IconEyeOff size={16} strokeWidth={1.5} />
<span>Hide filesystem name</span>
</>
) : (
<>
<IconEye size={16} strokeWidth={1.5} />
<span>Show filesystem name</span>
</>
)}
</button>
</li>
)}
</ul>
) : (
<div className="folder-empty-state">
{searchText.trim() ? 'No folders found' : 'No folders available'}
</div>
</div>
</div>
<button
type="button"
className="new-folder-toggle-filesystem-btn"
onClick={() => {
setShowFilesystemName(!showFilesystemName);
setNewFolderDirectoryName(sanitizeName(newFolderName));
}}
>
{showFilesystemName ? (
<>
<IconEyeOff size={16} strokeWidth={1.5} />
<span>Hide filesystem name</span>
</>
) : (
<>
<IconEye size={16} strokeWidth={1.5} />
<span>Show filesystem name</span>
</>
)}
</button>
</li>
)}
</ul>
) : (
<div className="folder-empty-state">
{searchText.trim() ? 'No folders found' : 'No folders available'}
</div>
</>
)}
)}
</div>
</div>
</div>
<div className="custom-modal-footer">
<div className="footer-left">
{!showNewFolderInput && !isSelectingCollection && (
{!showNewFolderInput && (
<Button
type="button"
color="primary"
@@ -748,27 +395,14 @@ const SaveTransientRequest = ({ item: itemProp, collection: collectionProp, isOp
New Folder
</Button>
)}
{isSelectingCollection && !newCollection.show && (
<Button
type="button"
color="primary"
variant="ghost"
icon={<IconFolder size={16} strokeWidth={1.5} />}
onClick={handleShowNewCollection}
>
New collection
</Button>
)}
</div>
<div className="footer-right">
<Button type="button" color="secondary" variant="ghost" onClick={handleCancel}>
Cancel
</Button>
{!isSelectingCollection && (
<Button type="button" color="primary" onClick={handleConfirm}>
Save
</Button>
)}
<Button type="button" color="primary" onClick={handleConfirm}>
Save
</Button>
</div>
</div>
</Modal>

View File

@@ -85,13 +85,8 @@ const CreateApiSpec = ({ onClose }) => {
...variables
};
}
// Convert envVariables (keyed by filename) to environments array for multi-server export
const environmentsList = Object.entries(envVariables || {}).map(([envFile, vars]) => ({
name: envFile.replace(/\.(bru|yml)$/, ''),
variables: vars
}));
// Create API spec yaml
let exportedYamlContentData = exportApiSpec({ name: values?.apiSpecName, variables, items: files, environments: environmentsList });
let exportedYamlContentData = exportApiSpec({ name: values?.apiSpecName, variables, items: files });
if (exportedYamlContentData?.content) {
yamlContent = exportedYamlContentData?.content;
}

View File

@@ -1,28 +0,0 @@
import styled from 'styled-components';
import { darken } from 'polished';
const StyledWrapper = styled.div`
.current-group {
background-color: ${(props) => props.theme.background.surface1};
border-radius: 4px;
padding: 0.4rem;
cursor: pointer;
border: 1px solid ${(props) => props.theme.background.surface2};
}
.current-group:hover {
background-color: ${(props) => darken(0.03, props.theme.background.surface1)};
border-color: ${(props) => darken(0.03, props.theme.background.surface2)};
}
/* Fix dropdown positioning */
[data-tippy-root] {
left: 0 !important;
}
.bruno-modal-footer {
padding-top: 0;
}
`;
export default StyledWrapper;

View File

@@ -1,887 +0,0 @@
import React, { useRef, useEffect, useState, useMemo, forwardRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import { browseDirectory, importCollection } from 'providers/ReduxStore/slices/collections/actions';
import Modal from 'components/Modal';
import { isElectron } from 'utils/common/platform';
import { IconX, IconLoader2, IconCheck, IconCaretDown } from '@tabler/icons';
import InfoTip from 'components/InfoTip/index';
import Help from 'components/Help';
import { addGlobalEnvironment } from 'providers/ReduxStore/slices/global-environments';
import Dropdown from 'components/Dropdown';
import { postmanToBruno } from 'utils/importers/postman-collection';
import { convertInsomniaToBruno } from 'utils/importers/insomnia-collection';
import { convertOpenapiToBruno } from 'utils/importers/openapi-collection';
import { processBrunoCollection } from 'utils/importers/bruno-collection';
import { wsdlToBruno } from '@usebruno/converters';
import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';
import get from 'lodash/get';
const STATUS = {
LOADING: 'loading',
SUCCESS: 'success',
ERROR: 'error'
};
const IMPORT_TYPE = {
BULK: 'bulk',
MULTIPLE: 'multiple'
};
const groupingOptions = [
{ value: 'tags', label: 'Tags', description: 'Group requests by OpenAPI tags', testId: 'grouping-option-tags' },
{ value: 'path', label: 'Paths', description: 'Group requests by URL path structure', testId: 'grouping-option-path' }
];
// Extract collection name from raw data
const getCollectionName = (format, rawData) => {
if (!rawData) return 'Collection';
switch (format) {
case 'openapi':
return rawData.info?.title || 'OpenAPI Collection';
case 'postman':
return rawData.info?.name || rawData.collection?.info?.name || 'Postman Collection';
case 'insomnia':
// For Insomnia v4 format, name is in the workspace resource
if (rawData.resources && Array.isArray(rawData.resources)) {
const workspace = rawData.resources.find((r) => r._type === 'workspace');
if (workspace?.name) {
return workspace.name;
}
}
// Fallback to root name property
return rawData.name || 'Insomnia Collection';
case 'bruno':
return rawData.name || 'Bruno Collection';
case 'wsdl':
return 'WSDL Collection';
default:
return 'Collection';
}
};
// Convert raw data to Bruno collection format
const convertCollection = async (format, rawData, groupingType) => {
let collection;
switch (format) {
case 'openapi':
collection = convertOpenapiToBruno(rawData, { groupBy: groupingType });
break;
case 'wsdl':
collection = await wsdlToBruno(rawData);
break;
case 'postman':
collection = await postmanToBruno(rawData);
break;
case 'insomnia':
collection = convertInsomniaToBruno(rawData);
break;
case 'bruno':
collection = await processBrunoCollection(rawData);
break;
default:
throw new Error('Unknown collection format');
}
return collection;
};
export function normalizeName(name) {
if (typeof name !== 'string') {
return '';
}
return name.trim().toLowerCase();
}
/**
* Generate a unique name by adding "copy" suffix if the name already exists.
* @param {string} baseName - The original name
* @param {function} checkExists - Function that returns true if name exists
* @returns {string} - Unique name with "copy" suffix if needed
*/
export function generateUniqueName(baseName, checkExists) {
const normalizedBase = normalizeName(baseName);
if (!checkExists(normalizedBase)) {
return baseName;
}
let counter = 1;
let uniqueName = `${baseName} copy`;
while (checkExists(normalizeName(uniqueName))) {
counter++;
uniqueName = `${baseName} copy ${counter}`;
}
return uniqueName;
}
export const BulkImportCollectionLocation = ({
onClose,
handleSubmit,
importData
}) => {
const dispatch = useDispatch();
const dropdownTippyRef = useRef();
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
const preferences = useSelector((state) => state.app.preferences);
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
const isDefaultWorkspace = !activeWorkspace || activeWorkspace.type === 'default';
const defaultLocation = isDefaultWorkspace
? get(preferences, 'general.defaultLocation', '')
: (activeWorkspace?.pathname ? `${activeWorkspace.pathname}/collections` : '');
const [status, setStatus] = useState({});
const [errorMessages, setErrorMessages] = useState({});
const [importStarted, setImportStarted] = useState(false);
const [environmentStatus, setEnvironmentStatus] = useState({});
const [showErrorModal, setShowErrorModal] = useState(false);
const [selectedError, setSelectedError] = useState(null);
const [applyToGlobal, setApplyToGlobal] = useState(true);
const [applyToCollection, setApplyToCollection] = useState(false);
const [groupingType, setGroupingType] = useState('tags');
const [collectionFormat, setCollectionFormat] = useState('bru');
const [renamedCollectionNames, setRenamedCollectionNames] = useState({});
const [renamedEnvironmentNames, setRenamedEnvironmentNames] = useState({});
// Extract data based on import type
const importType = importData?.type;
const isBulkImport = importType === IMPORT_TYPE.BULK;
const isMultipleImport = importType === IMPORT_TYPE.MULTIPLE;
// For bulk import (ZIP files)
const importedCollectionFromBulk = isBulkImport ? importData.collection : [];
const importedEnvironmentFromBulk = isBulkImport ? (importData.environment || []) : [];
// For multiple files import
const filesData = isMultipleImport ? importData.filesData : [];
const hasOpenApiSpec = filesData.some((f) => f.type === 'openapi');
// Create unified collection structure for display
const importedCollection = isMultipleImport
? filesData.map((fileData, index) => ({
uid: `file-${index}`,
name: getCollectionName(fileData.type, fileData.data),
_fileData: fileData
}))
: importedCollectionFromBulk;
const importedEnvironment = isBulkImport ? importedEnvironmentFromBulk : [];
const globalEnvironments = useSelector((state) => state?.globalEnvironments?.globalEnvironments);
const existingCollections = useSelector((state) => state?.collections?.collections || []);
// Initialize selected items based on import type
const [selectedCollections, setSelectedCollections] = useState(importedCollection.map((col) => col.uid));
const [selectedEnvironments, setSelectedEnvironments] = useState(isBulkImport ? importedEnvironmentFromBulk.map((env) => env.uid) : []);
const allCollectionsSelected = selectedCollections.length === importedCollection.length;
const allEnvironmentsSelected = selectedEnvironments.length === importedEnvironment.length;
// Sort collections to show selected items first, then unselected items
// This helps users see their selections at the top of the list
const sortedCollections = useMemo(() => {
const arr = [...importedCollection];
arr.sort((a, b) => {
const aSelected = selectedCollections.includes(a.uid);
const bSelected = selectedCollections.includes(b.uid);
// Convert boolean to number: true = 1, false = 0
// bSelected - aSelected means: selected items (1) come before unselected (0)
return Number(bSelected) - Number(aSelected);
});
return arr;
}, [importedCollection, selectedCollections]);
// Sort environments to show selected items first, then unselected items
// This helps users see their selections at the top of the list
const sortedEnvironments = useMemo(() => {
const arr = [...importedEnvironment];
arr.sort((a, b) => {
const aSelected = selectedEnvironments.includes(a.uid);
const bSelected = selectedEnvironments.includes(b.uid);
// selected (true) should come before unselected (false)
return Number(bSelected) - Number(aSelected);
});
return arr;
}, [importedEnvironment, selectedEnvironments]);
const importStatus = useMemo(() => {
const selectedSet = new Set(selectedCollections);
const totalSelected = selectedCollections.length;
const failedCount = Object.entries(status).reduce((acc, [uid, s]) => {
return selectedSet.has(uid) && s === STATUS.ERROR ? acc + 1 : acc;
}, 0);
return {
totalSelected,
failedCount
};
}, [status, selectedCollections]);
// Handlers
const handleCollectionToggle = (uid) => {
setSelectedCollections((prev) =>
prev.includes(uid) ? prev.filter((id) => id !== uid) : [...prev, uid]
);
};
const handleEnvironmentToggle = (uid) => {
setSelectedEnvironments((prev) =>
prev.includes(uid) ? prev.filter((id) => id !== uid) : [...prev, uid]
);
};
const handleSelectAllCollections = (e) => {
setSelectedCollections(e.target.checked ? importedCollection.map((col) => col.uid) : []);
};
const handleSelectAllEnvironments = (e) => {
setSelectedEnvironments(
e.target.checked ? importedEnvironment.map((env) => env.uid) : []
);
};
const onDropdownCreate = (ref) => {
dropdownTippyRef.current = ref;
};
const GroupingDropdownIcon = forwardRef((props, ref) => {
const selectedOption = groupingOptions.find((option) => option.value === groupingType);
return (
<div ref={ref} className="flex items-center justify-between w-full current-group" data-testid="grouping-dropdown">
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">{selectedOption.label}</div>
</div>
<IconCaretDown size={16} className="text-gray-400 ml-[0.25rem]" fill="currentColor" />
</div>
);
});
const formik = useFormik({
enableReinitialize: true,
initialValues: {
collectionLocation: defaultLocation
},
validationSchema: Yup.object({
collectionLocation: Yup.string()
.min(1, 'must be at least 1 character')
.max(500, 'must be 500 characters or less')
.required('Location is required')
}),
onSubmit: async (values) => {
let filteredCollections = [];
const selectedItems = importedCollection.filter((col) => selectedCollections.includes(col.uid));
if (isMultipleImport) {
// Convert selected files to collections at submit time
for (const item of selectedItems) {
try {
const collection = await convertCollection(item._fileData.type, item._fileData.data, groupingType);
if (collection) {
// Preserve the synthetic UID so status tracking, rename tracking,
// and UI rendering all use the same key
collection.uid = item.uid;
filteredCollections.push(collection);
}
} catch (err) {
console.warn(`Failed to convert file ${item._fileData.file.name}:`, err);
}
}
} else if (isBulkImport) {
// For bulk import, use selected collections directly
filteredCollections = selectedItems;
}
const initialStatus = {};
filteredCollections.forEach((col) => {
initialStatus[col.uid] = STATUS.LOADING;
});
setStatus(initialStatus);
setErrorMessages({});
const filteredEnvironments = importedEnvironment.filter((env) =>
selectedEnvironments.includes(env.uid)
);
// Handle duplicate collection names by renaming new ones to a unique "{originalName} N" suffix
const existingCollectionNames = new Set(existingCollections.map((col) => normalizeName(col.name)));
const usedNames = new Set();
const renamedNames = {};
filteredCollections.forEach((collection) => {
const originalName = collection.name;
let finalName = originalName;
let index = 0;
while (existingCollectionNames.has(normalizeName(finalName)) || usedNames.has(normalizeName(finalName))) {
finalName = `${originalName} ${index + 1}`;
index++;
}
collection.name = finalName;
usedNames.add(normalizeName(finalName));
// Store renamed name for summary display
if (finalName !== originalName) {
renamedNames[collection.uid] = finalName;
}
});
setRenamedCollectionNames(renamedNames);
// Process all selected environments and rename duplicates
// Don't use getUniqueEnvironments as it filters out duplicates - we want to rename them instead
const collectionRenamedEnvNames = {};
const globalRenamedEnvNames = {};
if (applyToCollection) {
// add selected environments to each selected collection
// Rename duplicates with "copy" suffix instead of filtering them out
filteredCollections.forEach((collection) => {
const existingNamesSet = new Set((collection.environments || []).map((e) => normalizeName(e?.name)));
const usedNamesInBatch = new Set();
const envsForCollection = filteredEnvironments.map((env) => {
const originalName = env.name;
const normalizedOriginalName = normalizeName(originalName);
// Check if name exists in collection or was already used in this batch
const checkExists = (name) => existingNamesSet.has(name) || usedNamesInBatch.has(name);
const finalName = generateUniqueName(originalName, checkExists);
// Track renamed name for summary display
if (finalName !== originalName) {
collectionRenamedEnvNames[env.uid] = finalName;
}
usedNamesInBatch.add(normalizeName(finalName));
existingNamesSet.add(normalizeName(finalName));
return { ...env, name: finalName };
});
collection.environments = envsForCollection;
});
// Mark all collection environments as success (they're processed with the collection import)
const envStatusUpdate = {};
filteredEnvironments.forEach((env) => {
envStatusUpdate[env.uid] = STATUS.SUCCESS;
});
setEnvironmentStatus((prev) => ({ ...prev, ...envStatusUpdate }));
if (Object.keys(collectionRenamedEnvNames).length > 0) {
setRenamedEnvironmentNames((prev) => ({ ...prev, ...collectionRenamedEnvNames }));
}
}
if (applyToGlobal && filteredEnvironments.length > 0) {
// Pre-compute unique names for all environments to avoid race conditions
const existingGlobalNames = new Set((globalEnvironments || []).map((env) => normalizeName(env?.name)));
const usedNamesInBatch = new Set();
const envsToImport = [];
filteredEnvironments.forEach((environment) => {
const checkExists = (name) => existingGlobalNames.has(name) || usedNamesInBatch.has(name);
const uniqueName = generateUniqueName(environment.name, checkExists);
if (uniqueName !== environment.name) {
globalRenamedEnvNames[environment.uid] = uniqueName;
}
usedNamesInBatch.add(normalizeName(uniqueName));
envsToImport.push({ ...environment, name: uniqueName });
});
if (Object.keys(globalRenamedEnvNames).length > 0) {
setRenamedEnvironmentNames((prev) => ({ ...prev, ...globalRenamedEnvNames }));
}
envsToImport.forEach((envToImport) => {
const originalUid = envToImport.uid;
setEnvironmentStatus((prev) => ({ ...prev, [originalUid]: STATUS.LOADING }));
dispatch(addGlobalEnvironment(envToImport))
.then(() => setEnvironmentStatus((prev) => ({ ...prev, [originalUid]: STATUS.SUCCESS })))
.catch((error) => {
setEnvironmentStatus((prev) => ({ ...prev, [originalUid]: STATUS.ERROR }));
setErrorMessages((prev) => ({ ...prev, [originalUid]: error.message || 'Failed to add environment' }));
});
});
}
setImportStarted(true);
if (filteredCollections.length > 1 || isBulkImport || isMultipleImport) {
dispatch(importCollection(filteredCollections, values.collectionLocation, { format: collectionFormat }))
.catch((err) => {
console.error('Failed to import collections', err);
filteredCollections.forEach((collection) => {
setStatus((prev) => ({ ...prev, [collection.uid]: STATUS.ERROR }));
setErrorMessages((prev) => ({ ...prev, [collection.uid]: err.message || 'Failed to import collection' }));
});
});
} else {
handleSubmit(filteredCollections[0], values.collectionLocation, { format: collectionFormat });
}
}
});
const browse = () => {
dispatch(browseDirectory())
.then((dirPath) => {
if (typeof dirPath === 'string' && dirPath.length > 0) {
formik.setFieldValue('collectionLocation', dirPath);
}
})
.catch((error) => {
formik.setFieldValue('collectionLocation', '');
console.error(error);
});
};
useEffect(() => {
if (!isElectron()) {
return () => {};
}
const { ipcRenderer } = window;
const handleImportStatus = (collectionId, status, errorMessage = '') => {
setStatus((prev) => ({ ...prev, [collectionId]: status }));
if (status === STATUS.ERROR) {
setErrorMessages((prev) => ({
...prev,
[collectionId]: errorMessage
}));
}
};
const importingCollectionStarted = ipcRenderer.on(
'main:collection-import-started',
(collectionId) => {
handleImportStatus(collectionId, STATUS.LOADING);
}
);
const importingCollectionCompleted = ipcRenderer.on(
'main:collection-import-ended',
(collectionId) => {
handleImportStatus(collectionId, STATUS.SUCCESS);
}
);
const importingCollectionFailed = ipcRenderer.on(
'main:collection-import-failed',
(collectionId, { message }) => {
handleImportStatus(collectionId, STATUS.ERROR, message);
}
);
const allCollectionsImportCompleted = ipcRenderer.on(
'main:all-collections-import-ended',
(report) => {
toast.success(report?.message);
}
);
return () => {
importingCollectionStarted();
importingCollectionCompleted();
importingCollectionFailed();
allCollectionsImportCompleted();
};
}, []);
const onSubmit = () => {
if (importStarted) {
onClose();
} else {
formik.handleSubmit();
}
};
const handleErrorClick = (error, uid) => {
setSelectedError({ message: error, uid });
setShowErrorModal(true);
};
const ErrorModal = ({ error, onClose }) => (
<Modal
size="sm"
title="Error Details"
handleConfirm={onClose}
handleCancel={onClose}
showCancelButton={false}
disableCloseOnOutsideClick={true}
hideFooter={true}
>
<div className="p-4">
<pre className="whitespace-pre-wrap text-red-600 text-sm">{error}</pre>
</div>
</Modal>
);
return (
<StyledWrapper>
<Modal
size="md"
title="Bulk Import"
confirmText={importStarted ? 'Close' : 'Import'}
confirmDisabled={Boolean(!selectedCollections?.length)}
handleConfirm={onSubmit}
handleCancel={onClose}
showConfirm={true}
disableCloseOnOutsideClick={true}
disableEscapeKey={false}
hideCancel={importStarted}
>
<form className="bruno-form" onSubmit={(e) => e.preventDefault()}>
<div className="flex flex-col">
{importStarted ? (
<>
<div className="mb-6">
<div className="flex items-center justify-between relative mb-5 w-full">
<div className="font-semibold">Location</div>
<div className="text-sm border border-slate-600 rounded px-3 py-1.5 ml-4 flex-1">
{formik.values.collectionLocation
|| 'No location selected'}
</div>
</div>
<div className="flex items-center justify-between mb-2">
<div className="font-semibold">
Importing Collections ({importStatus.totalSelected})
</div>
{importStatus.failedCount > 0 && importStatus.totalSelected > 0 && (
<div className="text-sm text-red-500">
({importStatus.failedCount}/{importStatus.totalSelected} failed)
</div>
)}
</div>
<div className="max-h-[180px] overflow-y-scroll border border-slate-600 rounded-md py-2 scrollbar-visible">
{sortedCollections
.filter((collection) =>
selectedCollections.includes(collection.uid)
)
.map((collection) => (
<div
key={collection.uid}
className="flex items-center px-4 py-1.5 text-sm font-normal justify-between"
>
<div className="flex items-center flex-1">
<div className="flex items-center mr-2">
{status[collection.uid] === STATUS.LOADING && (
<IconLoader2
className="animate-spin text-blue-500"
size={16}
strokeWidth={1.5}
/>
)}
{status[collection.uid] === STATUS.SUCCESS && (
<div className="flex items-center text-green-500">
<IconCheck size={16} strokeWidth={1.5} />
</div>
)}
{status[collection.uid] === STATUS.ERROR && (
<div className="flex items-center">
<IconX
className="text-red-500"
size={16}
strokeWidth={1.5}
/>
</div>
)}
</div>
<span>{renamedCollectionNames[collection.uid] || collection.name}</span>
</div>
{status[collection.uid] === STATUS.ERROR && (
<button
onClick={() =>
handleErrorClick(
errorMessages[collection.uid],
collection.uid
)}
className="text-red-500 text-sm hover:underline"
>
See error
</button>
)}
</div>
))}
</div>
</div>
{selectedEnvironments.length > 0 && (
<div className="mb-6">
<div className="font-semibold mb-2">
Importing Environments ({selectedEnvironments.length})
</div>
<div className="max-h-[180px] overflow-y-scroll border border-slate-600 rounded-md py-2 scrollbar-visible">
{sortedEnvironments
.filter((env) => selectedEnvironments.includes(env.uid))
.map((env) => (
<div
key={env.uid}
className="flex items-center px-4 py-1.5 text-sm font-normal justify-between"
>
<div className="flex items-center flex-1">
<div className="flex items-center mr-2">
{!environmentStatus[env.uid] || environmentStatus[env.uid] === STATUS.LOADING ? (
<IconLoader2
className="animate-spin text-blue-500"
size={16}
strokeWidth={1.5}
/>
) : environmentStatus[env.uid] === STATUS.SUCCESS ? (
<div className="flex items-center text-green-500">
<IconCheck size={16} strokeWidth={1.5} />
</div>
) : environmentStatus[env.uid] === STATUS.ERROR ? (
<div className="flex items-center">
<IconX
className="text-red-500"
size={16}
strokeWidth={1.5}
/>
</div>
) : null}
</div>
<span>{renamedEnvironmentNames[env.uid] || env.name}</span>
</div>
{environmentStatus[env.uid] === STATUS.ERROR && (
<button
onClick={() =>
handleErrorClick(
errorMessages[env.uid],
env.uid
)}
className="text-red-500 text-sm hover:underline"
>
See error
</button>
)}
</div>
))}
</div>
</div>
)}
</>
) : (
<>
<div className="mb-6">
<div className="font-semibold mb-2 flex justify-between items-center">
<span>Collections ({importedCollection.length})</span>
<label className="flex items-center text-sm font-normal select-none cursor-pointer">
<input
type="checkbox"
checked={allCollectionsSelected}
onChange={handleSelectAllCollections}
className="mr-2"
/>
Select All
</label>
</div>
<div className="max-h-[180px] overflow-y-scroll border border-slate-600 rounded-md py-2">
{importedCollection.length === 0 && (
<div className="px-4 py-2 text-gray-400 italic">
No collections found
</div>
)}
{sortedCollections.map((collection) => (
<label
key={collection.uid}
className="flex items-center px-4 py-1.5 text-sm font-normal select-none cursor-pointer justify-between"
>
<div className="flex items-center flex-1">
<input
type="checkbox"
checked={selectedCollections.includes(collection.uid)}
onChange={() => handleCollectionToggle(collection.uid)}
className="mr-3"
/>
<span>{collection.name}</span>
</div>
</label>
))}
</div>
</div>
{importType === 'bulk' && (
<>
<div className="mb-4">
<div className="font-semibold mb-2 flex justify-between items-center">
<span>Environments ({importedEnvironment.length})</span>
<label className="flex items-center text-sm font-normal select-none cursor-pointer">
<input
type="checkbox"
checked={allEnvironmentsSelected}
onChange={handleSelectAllEnvironments}
className="mr-2"
/>
Select All
</label>
</div>
<div className="max-h-[180px] overflow-y-scroll border border-slate-600 rounded-md py-2 scrollbar-visible">
{importedEnvironment.length === 0 && (
<div className="px-4 py-2 text-gray-400 italic">
No environments found
</div>
)}
{sortedEnvironments.map((env) => (
<label
key={env.uid}
className="flex items-center px-4 py-1.5 text-sm font-normal select-none cursor-pointer"
>
<input
type="checkbox"
checked={selectedEnvironments.includes(env.uid)}
onChange={() => handleEnvironmentToggle(env.uid)}
className="mr-3"
/>
<span>{env.name}</span>
</label>
))}
</div>
</div>
<div className="mb-6">
<div className="font-semibold mb-2">
Environment Assignment
</div>
<div className="flex gap-8 mt-2 ml-2">
<label className="flex items-center">
<input
type="checkbox"
checked={applyToGlobal}
onChange={(e) => setApplyToGlobal(e.target.checked)}
className="mr-2"
/>
<span className="ml-2">
Global Environment
<InfoTip
content="Environments will be imported and stored as global, accessible across collections."
infotipId="apply-to-global-infotip"
/>
</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={applyToCollection}
onChange={(e) =>
setApplyToCollection(e.target.checked)}
className="mr-2"
/>
<span className="ml-2">
Duplicate Across Collections
<InfoTip
content="Each imported collection will receive its own copy of the environments."
infotipId="apply-to-each-infotip"
/>
</span>
</label>
</div>
</div>
</>
)}
<div className="flex items-start flex-col relative">
<div className="font-semibold mb-2">Location</div>
<input
id="collection-location"
type="text"
placeholder="Select a location to save the collection"
name="collectionLocation"
className="block textbox w-full cursor-pointer"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={formik.values.collectionLocation || ''}
onClick={browse}
onChange={(e) => {
formik.setFieldValue('collectionLocation', e.target.value);
}}
/>
{formik.touched.collectionLocation && formik.errors.collectionLocation ? (
<div className="text-red-500 mt-1">
{formik.errors.collectionLocation}
</div>
) : null}
<div className="mt-1">
<span className="text-link cursor-pointer hover:underline" onClick={browse}>
Browse
</span>
</div>
</div>
<div className="mt-4">
<label htmlFor="format" className="flex items-center font-semibold">
File Format
<Help width="300">
<p>Choose the file format for storing requests in this collection.</p>
<p className="mt-2">
<strong>OpenCollection (YAML):</strong> Industry-standard YAML format (.yml files)
</p>
<p className="mt-1">
<strong>BRU:</strong> Bruno's native file format (.bru files)
</p>
</Help>
</label>
<select
id="format"
name="format"
className="block textbox mt-2 w-full"
value={collectionFormat}
onChange={(e) => setCollectionFormat(e.target.value)}
>
<option value="yml">OpenCollection (YAML)</option>
<option value="bru">BRU Format (.bru)</option>
</select>
</div>
{isMultipleImport && hasOpenApiSpec && (
<div>
<div className="flex gap-4 items-center">
<div>
<label htmlFor="groupingType" className="block font-semibold">
Folder arrangement
</label>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1 mb-2">
Select whether to create folders according to the spec's paths or tags.
</p>
</div>
<div className="relative">
<Dropdown onCreate={onDropdownCreate} icon={<GroupingDropdownIcon />} placement="bottom-start">
{groupingOptions.map((option) => (
<div
key={option.value}
className="dropdown-item"
data-testid={option.testId}
onClick={() => {
dropdownTippyRef?.current?.hide();
setGroupingType(option.value);
}}
>
{option.label}
</div>
))}
</Dropdown>
</div>
</div>
</div>
)}
</>
)}
</div>
</form>
</Modal>
{showErrorModal && (
<ErrorModal
error={selectedError?.message}
onClose={() => setShowErrorModal(false)}
/>
)}
</StyledWrapper>
);
};
export default BulkImportCollectionLocation;

View File

@@ -1,30 +0,0 @@
import { normalizeName, generateUniqueName } from './index';
describe('BulkImportCollectionLocation helpers', () => {
describe('normalizeName', () => {
it('should trim and lowercase names', () => {
expect(normalizeName(' Beta ')).toBe('beta');
expect(normalizeName('TEST')).toBe('test');
expect(normalizeName(null)).toBe('');
});
});
describe('generateUniqueName', () => {
it('should return original name if no conflict', () => {
const checkExists = () => false;
expect(generateUniqueName('Beta', checkExists)).toBe('Beta');
});
it('should add "copy" suffix on first conflict', () => {
const existing = new Set(['beta']);
const checkExists = (name) => existing.has(name);
expect(generateUniqueName('Beta', checkExists)).toBe('Beta copy');
});
it('should increment copy number on multiple conflicts', () => {
const existing = new Set(['beta', 'beta copy']);
const checkExists = (name) => existing.has(name);
expect(generateUniqueName('Beta', checkExists)).toBe('Beta copy 2');
});
});
});

View File

@@ -1,18 +0,0 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.info-box {
background-color: ${(props) => props.theme.background.mantle};
color: ${(props) => props.theme.text};
border: 1px solid ${(props) => props.theme.border.border2};
padding: 10px;
border-radius: 5px;
margin-top: 5px;
width: 400px;
white-space: pre-wrap;
max-height: 150px;
overflow-y: auto;
}
`;
export default StyledWrapper;

View File

@@ -1,372 +0,0 @@
import React, { useRef, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import {
browseDirectory,
cloneGitRepository,
openMultipleCollections,
scanForBrunoFiles
} from 'providers/ReduxStore/slices/collections/actions';
import { removeGitOperationProgress } from 'providers/ReduxStore/slices/app';
import Modal from 'components/Modal';
import * as path from 'path';
import Portal from 'components/Portal';
import { IconRefresh, IconCheck, IconAlertCircle, IconBrandGit } from '@tabler/icons';
import { uuid } from 'utils/common/index';
import StyledWrapper from './StyledWrapper';
import { getRepoNameFromUrl } from 'utils/git';
import GitNotFoundModal from 'components/Git/GitNotFoundModal/index';
import get from 'lodash/get';
const CloneGitRepository = ({ onClose, onFinish, collectionRepositoryUrl = null }) => {
const [collectionPaths, setCollectionPaths] = useState([]);
const [selectedCollectionPaths, setSelectedCollectionPaths] = useState([]);
const [processUid, setProcessUid] = useState(uuid());
const [steps, setSteps] = useState([]);
const [view, setView] = useState('form');
const progressData = useSelector((state) => state.app.gitOperationProgress[processUid]);
const { gitVersion } = useSelector((state) => state.app);
const { workspaces, activeWorkspaceUid } = useSelector((state) => state.workspaces);
const preferences = useSelector((state) => state.app.preferences);
const activeWorkspace = workspaces.find((w) => w.uid === activeWorkspaceUid);
const isDefaultWorkspace = !activeWorkspace || activeWorkspace.type === 'default';
const defaultLocation = isDefaultWorkspace
? get(preferences, 'general.defaultLocation', '')
: (activeWorkspace?.pathname ? `${activeWorkspace.pathname}/collections` : '');
const inputRef = useRef();
const dispatch = useDispatch();
useEffect(() => {
if (progressData) {
setSteps((prev) =>
prev.map((step) =>
step.step === 'clone' && !step?.completed
? { ...step, title: 'Cloning repository', completed: false, info: progressData.progressData }
: step
)
);
}
}, [progressData]);
useEffect(() => {
if (inputRef?.current) {
inputRef.current.focus();
}
}, []);
const cloneInProgress = () => {
setSteps((prev) => [
...prev,
{
step: 'clone',
title: 'Cloning repository',
completed: false
}
]);
};
const cloneFinished = () => {
setSteps((prev) =>
prev.map((step) =>
step.step === 'clone'
? { ...step, title: 'Cloning successful', completed: true, info: '' }
: step
)
);
};
const cloneError = () => {
setSteps((prev) =>
prev.map((step) =>
step.step === 'clone'
? { ...step, title: 'Cloning failed', completed: true, error: true }
: step
)
);
};
const scanInProgress = () => {
setSteps((prev) => [
...prev,
{
step: 'scan',
title: 'Scanning for Bruno files',
completed: false
}
]);
};
const scanFinished = () => {
setSteps((prev) =>
prev.map((step) =>
step.step === 'scan' ? { ...step, title: 'Scan successful', completed: true, info: '' } : step
)
);
};
const formik = useFormik({
enableReinitialize: true,
initialValues: {
repositoryUrl: collectionRepositoryUrl || '',
collectionLocation: defaultLocation
},
validationSchema: Yup.object({
repositoryUrl: Yup.string().required('Repository URL is required'),
collectionLocation: Yup.string().min(1, 'Location is required').required('Location is required')
}),
onSubmit: async (values) => {
try {
setView('progress');
cloneInProgress();
const { repositoryUrl, collectionLocation } = values;
const repoName = getRepoNameFromUrl(repositoryUrl);
const targetPath = path.join(collectionLocation, repoName);
await dispatch(cloneGitRepository({ url: values.repositoryUrl, path: targetPath, processUid }));
cloneFinished();
dispatch(removeGitOperationProgress(processUid));
scanInProgress();
const foundCollectionPaths = await dispatch(scanForBrunoFiles(targetPath));
scanFinished();
setCollectionPaths(foundCollectionPaths);
} catch (err) {
cloneError();
dispatch(removeGitOperationProgress(processUid));
console.error(err);
}
}
});
const browse = () => {
dispatch(browseDirectory())
.then((dirPath) => {
if (typeof dirPath === 'string') {
formik.setFieldValue('collectionLocation', dirPath);
}
})
.catch((error) => {
formik.setFieldValue('collectionLocation', '');
console.error(error);
});
};
const handleCollectionSelect = (collection) => {
setSelectedCollectionPaths((prevSelected) =>
prevSelected.includes(collection)
? prevSelected.filter((c) => c !== collection)
: [...prevSelected, collection]
);
};
const getRelativePath = (fullPath, pathname) => {
let relativePath = path.relative(fullPath, pathname);
const { dir, name } = path.parse(relativePath);
return path.join(dir, name);
};
const isScanCompleted = () => steps.some((step) => step.step === 'scan' && step.completed);
const isConfirmDisabled = () => isScanCompleted() && collectionPaths?.length > 0 && selectedCollectionPaths?.length === 0;
const isFooterHidden = () => steps.some((step) => !step.completed);
const isError = () => steps.some((step) => step.error);
const handleConfirm = () => {
const buttonText = getConfirmText();
switch (buttonText) {
case 'Clone':
formik.handleSubmit();
break;
case 'Close':
onClose();
break;
case 'Open':
if (collectionPaths.length > 0 && selectedCollectionPaths.length > 0) {
dispatch(openMultipleCollections(selectedCollectionPaths));
onClose();
onFinish();
}
break;
default:
break;
}
};
const getConfirmText = () =>
!steps.length
? 'Clone'
: steps.some((step) => !step.completed || step.error || (isScanCompleted() && !collectionPaths?.length))
? 'Close'
: 'Open';
const handleBackButtonClick = () => {
setView('form');
setSteps([]);
setSelectedCollectionPaths([]);
};
if (!gitVersion) {
return <GitNotFoundModal onClose={onClose} />;
}
return (
<Portal id="clone-repository-portal">
<Modal
size="md"
title="Clone Git Repository"
confirmText={getConfirmText()}
handleConfirm={handleConfirm}
handleCancel={onClose}
confirmDisabled={isConfirmDisabled()}
hideFooter={isFooterHidden()}
hideCancel={isError() || (isScanCompleted() && !collectionPaths?.length)}
showBackButton={isError()}
handleBack={handleBackButtonClick}
>
<StyledWrapper>
{view === 'form' && (
<form className="bruno-form" onSubmit={(e) => e.preventDefault()}>
<div>
{collectionRepositoryUrl
? (
<div className="flex items-start">
<div className="flex-shrink-0 p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
<IconBrandGit className="w-6 h-6 text-purple-500" stroke={1.5} />
</div>
<div className="ml-4">
<div className="font-semibold text-sm">{getRepoNameFromUrl(collectionRepositoryUrl)}</div>
<div className="mt-1 text-xs text-muted font-mono">
{collectionRepositoryUrl}
</div>
</div>
</div>
)
: (
<>
<label htmlFor="repository-url" className="flex items-center font-semibold">
Git Repository URL
</label>
<input
id="repository-url"
type="text"
name="repositoryUrl"
ref={inputRef}
className="block textbox mt-2 w-full"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
onChange={formik.handleChange}
value={formik.values.repositoryUrl || ''}
/>
</>
)}
{formik.touched.repositoryUrl && formik.errors.repositoryUrl && (
<div className="text-red-500">{formik.errors.repositoryUrl}</div>
)}
<label htmlFor="collection-location" className="block font-semibold mt-3">
Location
</label>
<input
id="collection-location"
type="text"
name="collectionLocation"
readOnly
className="block textbox mt-2 w-full cursor-pointer"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
value={formik.values.collectionLocation || ''}
onClick={browse}
/>
{formik.touched.collectionLocation && formik.errors.collectionLocation && (
<div className="text-red-500">{formik.errors.collectionLocation}</div>
)}
<div className="mt-1">
<span className="text-link cursor-pointer hover:underline" onClick={browse}>
Browse
</span>
</div>
</div>
</form>
)}
{view === 'progress' && (
<>
{steps.length > 0 && (
<div className="mt-4">
<ul>
{steps.map((step, index) => (
<li key={index} className="flex-col items-center space-x-2 mt-1">
<div className="flex">
{step.error ? (
<IconAlertCircle className="text-red-500" size={18} strokeWidth={1.5} />
) : (
<>
{step.completed ? (
<IconCheck className="text-green-500" size={18} strokeWidth={1.5} />
) : (
<IconRefresh className="text-yellow-500 animate-spin" size={18} strokeWidth={1.5} />
)}
</>
)}
<span className="ml-2">{step.title}</span>
</div>
{step.info && (
<div className="w-full mt-2">
<pre className="info-box ml-4">{step.info}</pre>
</div>
)}
</li>
))}
</ul>
</div>
)}
{isScanCompleted() && (
<div className="mt-4 mb-4">
{collectionPaths.length === 0 && (
<div className="flex">
<IconAlertCircle className="text-yellow-500" size={18} strokeWidth={1.5} />
<h3 className="text-sm ml-2">No bruno collections found in this repository.</h3>
</div>
)}
{collectionPaths.length > 0 && (
<>
<h3 className="text-sm mb-2">
{collectionPaths.length} bruno collections found. Please select the collections to open:
</h3>
<ul>
{collectionPaths.map((collection) => (
<li key={collection} className="mb-2">
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={selectedCollectionPaths.includes(collection)}
onChange={() => handleCollectionSelect(collection)}
className="form-checkbox"
/>
<span>{getRelativePath(formik.values.collectionLocation, collection)}</span>
</label>
</li>
))}
</ul>
</>
)}
</div>
)}
</>
)}
</StyledWrapper>
</Modal>
</Portal>
);
};
export default CloneGitRepository;

View File

@@ -26,7 +26,7 @@ const CloneCollection = ({ onClose, collectionUid }) => {
const isDefaultWorkspace = activeWorkspace?.type === 'default';
const defaultLocation = isDefaultWorkspace
? get(preferences, 'general.defaultLocation', '')
? get(preferences, 'general.defaultCollectionLocation', '')
: (activeWorkspace?.pathname ? `${activeWorkspace.pathname}/collections` : '');
const { name } = collection;

View File

@@ -3,7 +3,7 @@ import toast from 'react-hot-toast';
import { useFormik } from 'formik';
import * as Yup from 'yup';
import Modal from 'components/Modal';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import { isItemAFolder } from 'utils/tabs';
import { cloneItem } from 'providers/ReduxStore/slices/collections/actions';
import { IconArrowBackUp, IconEdit, IconCaretDown } from '@tabler/icons';
@@ -18,7 +18,6 @@ import Button from 'ui/Button';
const CloneCollectionItem = ({ collectionUid, item, onClose }) => {
const dispatch = useDispatch();
const collection = useSelector((state) => state.collections.collections?.find((c) => c.uid === collectionUid));
const isFolder = isItemAFolder(item);
const inputRef = useRef();
const [isEditing, toggleEditing] = useState(false);
@@ -169,7 +168,7 @@ const CloneCollectionItem = ({ collectionUid, item, onClose }) => {
onChange={formik.handleChange}
value={formik.values.filename || ''}
/>
{itemType !== 'folder' && <span className="absolute right-2 top-4 flex justify-center items-center file-extension">.{collection?.format || 'bru'}</span>}
{itemType !== 'folder' && <span className="absolute right-2 top-4 flex justify-center items-center file-extension">.bru</span>}
</div>
) : (
<div className="relative flex flex-row gap-1 items-center justify-between">
@@ -203,7 +202,7 @@ const CloneCollectionItem = ({ collectionUid, item, onClose }) => {
<Button type="button" color="secondary" variant="ghost" onClick={onClose} className="mr-2">
Cancel
</Button>
<Button type="submit" data-testid="collection-item-clone">
<Button type="submit">
Clone
</Button>
</div>

View File

@@ -2,7 +2,8 @@ import React from 'react';
import Modal from 'components/Modal';
import { isItemAFolder } from 'utils/tabs';
import { useDispatch } from 'react-redux';
import { deleteItem, closeTabs } from 'providers/ReduxStore/slices/collections/actions';
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
import { deleteItem } from 'providers/ReduxStore/slices/collections/actions';
import { recursivelyGetAllItemUids } from 'utils/collections';
import StyledWrapper from './StyledWrapper';
import toast from 'react-hot-toast';

View File

@@ -3,7 +3,8 @@ import Modal from 'components/Modal';
import Portal from 'components/Portal';
import { useDispatch } from 'react-redux';
import { deleteResponseExample } from 'providers/ReduxStore/slices/collections';
import { saveRequest, closeTabs } from 'providers/ReduxStore/slices/collections/actions';
import { saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { closeTabs } from 'providers/ReduxStore/slices/tabs';
const DeleteResponseExampleModal = ({ onClose, example, item, collection }) => {
const dispatch = useDispatch();

View File

@@ -44,7 +44,6 @@ const CodeView = ({ language, item }) => {
<StyledWrapper>
<CopyToClipboard
text={snippet}
options={{ format: 'text/plain' }}
onCopy={() => toast.success('Copied to clipboard!')}
>
<button className="copy-to-clipboard">

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