diff --git a/packages/bruno-app/src/components/Modal/StyledWrapper.js b/packages/bruno-app/src/components/Modal/StyledWrapper.js
index a6a188245..327f51134 100644
--- a/packages/bruno-app/src/components/Modal/StyledWrapper.js
+++ b/packages/bruno-app/src/components/Modal/StyledWrapper.js
@@ -208,21 +208,35 @@ const Wrapper = styled.div`
outline-offset: 2px;
}
- &:checked {
+ &:checked,
+ &:indeterminate {
background: ${(props) => props.theme.button2.color.primary.bg};
border-color: ${(props) => props.theme.button2.color.primary.border};
+ }
- &::after {
- content: '';
- position: absolute;
- left: 4px;
- top: 1px;
- width: 5px;
- height: 9px;
- border: solid ${(props) => props.theme.button2.color.primary.text};
- border-width: 0 2px 2px 0;
- transform: rotate(45deg);
- }
+ &:checked::after,
+ &:indeterminate::after {
+ content: '';
+ position: absolute;
+ }
+
+ &:checked::after {
+ left: 4px;
+ top: 1px;
+ width: 5px;
+ height: 9px;
+ border: solid ${(props) => props.theme.button2.color.primary.text};
+ border-width: 0 2px 2px 0;
+ transform: rotate(45deg);
+ }
+
+ &:indeterminate::after {
+ left: 2px;
+ top: 6px;
+ width: 10px;
+ height: 2px;
+ background: ${(props) => props.theme.button2.color.primary.text};
+ border-radius: 2px;
}
}
`;
diff --git a/packages/bruno-app/src/components/SelectionList/StyledWrapper.js b/packages/bruno-app/src/components/SelectionList/StyledWrapper.js
new file mode 100644
index 000000000..c67b7a7d7
--- /dev/null
+++ b/packages/bruno-app/src/components/SelectionList/StyledWrapper.js
@@ -0,0 +1,88 @@
+import styled from 'styled-components';
+import { transparentize } from 'polished';
+
+const getListHeight = ({ $visibleRows, $rowHeight, $rowGap, $listPadding }) => {
+ const rowsHeight = $rowHeight * $visibleRows;
+ const gapsHeight = $rowGap * Math.max($visibleRows - 1, 0);
+ const paddingHeight = $listPadding * 2;
+ const bordersHeight = 2;
+
+ return `${rowsHeight + gapsHeight + paddingHeight + bordersHeight}px`;
+};
+
+const StyledWrapper = styled.div`
+ .selection-toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ margin-bottom: 0.5rem;
+ }
+
+ .selection-title {
+ margin: 0;
+ font-size: ${(props) => props.theme.font.size.base};
+ font-weight: 600;
+ }
+
+ .selection-toggle {
+ display: inline-flex;
+ align-items: center;
+ cursor: pointer;
+ user-select: none;
+ color: ${(props) => props.theme.text};
+ font-size: ${(props) => props.theme.font.size.md};
+ font-weight: 400;
+ }
+
+ .selection-toggle input[type='checkbox'] {
+ cursor: pointer;
+ margin-right: 0.5rem;
+ }
+
+ .selection-list {
+ max-height: ${getListHeight};
+ overflow-y: auto;
+ border: 1px solid ${(props) => transparentize(0.4, props.theme.border.border2)};
+ border-radius: ${(props) => props.theme.border.radius.base};
+ padding: ${(props) => `${props.$listPadding}px 0`};
+ margin: 0;
+ list-style: none;
+ }
+
+ .selection-item {
+ box-sizing: border-box;
+ display: flex;
+ align-items: center;
+ min-height: ${(props) => `${props.$rowHeight}px`};
+ padding: 0.375rem 1rem;
+ cursor: pointer;
+ user-select: none;
+ font-size: ${(props) => props.theme.font.size.md};
+ font-weight: 400;
+ }
+
+ .selection-list li + li .selection-item {
+ margin-top: ${(props) => `${props.$rowGap}px`};
+ }
+
+ .selection-item input[type='checkbox'] {
+ accent-color: ${(props) => props.theme.workspace.accent};
+ cursor: pointer;
+ margin-right: 0.75rem;
+ }
+
+ .selection-path {
+ line-height: 1.2;
+ word-break: break-word;
+ }
+
+ .selection-empty {
+ padding: 0.5rem;
+ color: ${(props) => props.theme.colors.text.muted};
+ font-size: ${(props) => props.theme.font.size.sm};
+ font-style: italic;
+ }
+`;
+
+export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/SelectionList/index.js b/packages/bruno-app/src/components/SelectionList/index.js
new file mode 100644
index 000000000..2913e4803
--- /dev/null
+++ b/packages/bruno-app/src/components/SelectionList/index.js
@@ -0,0 +1,74 @@
+import React, { useRef, useEffect } from 'react';
+import StyledWrapper from './StyledWrapper';
+
+const SelectionList = ({
+ title,
+ items,
+ selectedItems,
+ onSelectAll,
+ onItemToggle,
+ getItemId,
+ renderItemLabel,
+ visibleRows = 8,
+ rowHeight = 30,
+ rowGap = 2,
+ listPadding = 8,
+ emptyMessage = 'No items found'
+}) => {
+ const allSelected = items.length > 0 && selectedItems.length === items.length;
+ const someSelected = items.length > 0 && selectedItems.length > 0 && !allSelected;
+ const selectAllRef = useRef(null);
+
+ useEffect(() => {
+ if (selectAllRef.current) {
+ selectAllRef.current.indeterminate = someSelected;
+ }
+ }, [someSelected]);
+
+ return (
+
+
+ {title}
+
+
+
+
+ );
+};
+
+export default SelectionList;
diff --git a/packages/bruno-app/src/components/Sidebar/BulkImportCollectionLocation/index.js b/packages/bruno-app/src/components/Sidebar/BulkImportCollectionLocation/index.js
index 5886aaae0..e9829272f 100644
--- a/packages/bruno-app/src/components/Sidebar/BulkImportCollectionLocation/index.js
+++ b/packages/bruno-app/src/components/Sidebar/BulkImportCollectionLocation/index.js
@@ -11,6 +11,7 @@ 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 SelectionList from 'components/SelectionList';
import { postmanToBruno } from 'utils/importers/postman-collection';
import { convertInsomniaToBruno } from 'utils/importers/insomnia-collection';
import { convertOpenapiToBruno } from 'utils/importers/openapi-collection';
@@ -181,9 +182,6 @@ export const BulkImportCollectionLocation = ({
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(() => {
@@ -443,7 +441,7 @@ export const BulkImportCollectionLocation = ({
useEffect(() => {
if (!isElectron()) {
- return () => {};
+ return () => { };
}
const { ipcRenderer } = window;
@@ -667,79 +665,33 @@ export const BulkImportCollectionLocation = ({
) : (
<>
-
- Collections ({importedCollection.length})
-
-
-
- {importedCollection.length === 0 && (
-
- No collections found
-
- )}
- {sortedCollections.map((collection) => (
-
- ))}
-
+
collection.uid}
+ renderItemLabel={(collection) => collection.name}
+ visibleRows={5}
+ emptyMessage="No collections found"
+ />
{importType === 'bulk' && (
<>
-
- Environments ({importedEnvironment.length})
-
-
-
- {importedEnvironment.length === 0 && (
-
- No environments found
-
- )}
- {sortedEnvironments.map((env) => (
-
- ))}
-
+
env.uid}
+ renderItemLabel={(env) => env.name}
+ visibleRows={5}
+ emptyMessage="No environments found"
+ />
diff --git a/packages/bruno-app/src/components/Sidebar/CloneGitRespository/index.js b/packages/bruno-app/src/components/Sidebar/CloneGitRespository/index.js
index 40bcdc8e8..151bc02d9 100644
--- a/packages/bruno-app/src/components/Sidebar/CloneGitRespository/index.js
+++ b/packages/bruno-app/src/components/Sidebar/CloneGitRespository/index.js
@@ -15,6 +15,7 @@ import Portal from 'components/Portal';
import { IconRefresh, IconCheck, IconAlertCircle, IconBrandGit } from '@tabler/icons';
import { uuid } from 'utils/common/index';
import StyledWrapper from './StyledWrapper';
+import SelectionList from 'components/SelectionList';
import { getRepoNameFromUrl } from 'utils/git';
import GitNotFoundModal from 'components/Git/GitNotFoundModal/index';
import get from 'lodash/get';
@@ -164,6 +165,10 @@ const CloneGitRepository = ({ onClose, onFinish, collectionRepositoryUrl = null
);
};
+ const handleSelectAllCollections = (e) => {
+ setSelectedCollectionPaths(e.target.checked ? [...collectionPaths] : []);
+ };
+
const getRelativePath = (fullPath, pathname) => {
let relativePath = path.relative(fullPath, pathname);
const { dir, name } = path.parse(relativePath);
@@ -338,26 +343,16 @@ const CloneGitRepository = ({ onClose, onFinish, collectionRepositoryUrl = null
)}
{collectionPaths.length > 0 && (
- <>
-
- {collectionPaths.length} bruno collections found. Please select the collections to open:
-
-
- >
+ collection}
+ renderItemLabel={(collection) => getRelativePath(formik.values.collectionLocation, collection)}
+ visibleRows={8}
+ />
)}
)}
diff --git a/packages/bruno-app/src/globalStyles.js b/packages/bruno-app/src/globalStyles.js
index 84e7e35b8..c947dc3c7 100644
--- a/packages/bruno-app/src/globalStyles.js
+++ b/packages/bruno-app/src/globalStyles.js
@@ -210,10 +210,13 @@ const GlobalStyle = createGlobalStyle`
}
}
- // Utility class for scrollbars that are hidden by default and shown on hover
+ // Utility class for scrollbars that are hidden by default and shown on hover.
+ // scrollbar-width/color: auto is required so macOS Chromium uses
+ // ::-webkit-scrollbar instead of standard overlay rendering, which
+ // auto-hides regardless of CSS.
.scrollbar-hover {
- scrollbar-width: thin;
- scrollbar-color: transparent transparent;
+ scrollbar-width: auto !important;
+ scrollbar-color: auto;
&::-webkit-scrollbar {
width: 5px;
@@ -228,13 +231,10 @@ const GlobalStyle = createGlobalStyle`
background-color: transparent;
border-radius: 14px;
border: 3px solid transparent;
- background-clip: content-box;
transition: background-color 0.2s ease;
}
&:hover {
- scrollbar-color: ${(props) => props.theme.scrollbar.color} transparent;
-
&::-webkit-scrollbar-thumb {
background-color: ${(props) => props.theme.scrollbar.color};
}
diff --git a/tests/import/bulk-import/003-selection-list-viewport.spec.ts b/tests/import/bulk-import/003-selection-list-viewport.spec.ts
new file mode 100644
index 000000000..308d91728
--- /dev/null
+++ b/tests/import/bulk-import/003-selection-list-viewport.spec.ts
@@ -0,0 +1,86 @@
+import { test, expect } from '../../../playwright';
+import type { Locator } from '@playwright/test';
+import * as path from 'path';
+import * as fs from 'fs/promises';
+import { closeAllCollections } from '../../utils/page';
+
+const getViewportCollectionName = (index: number) => `Viewport Collection ${String(index).padStart(2, '0')}`;
+
+const getFullyVisibleRowNames = async (list: Locator) => {
+ return list.evaluate((node) => {
+ const listRect = node.getBoundingClientRect();
+ const items = Array.from(node.querySelectorAll('.selection-item'));
+
+ return items
+ .filter((item) => {
+ const rect = item.getBoundingClientRect();
+ return rect.top >= listRect.top && rect.bottom <= listRect.bottom;
+ })
+ .map((item) => item.textContent?.trim())
+ .filter(Boolean);
+ });
+};
+
+test.describe('Bulk Import Selection List', () => {
+ const testDataDir = path.join(__dirname, '../test-data');
+ const expectedVisibleRows = 5;
+
+ test.afterEach(async ({ page }) => {
+ await closeAllCollections(page);
+ });
+
+ test('shows the configured number of visible rows and reveals later rows when scrolled', async ({ page, createTmpDir }) => {
+ const sourceFile = path.join(testDataDir, 'sample-postman.json');
+ const tempDir = await createTmpDir('bulk-import-selection-list');
+ const sourceContent = JSON.parse(await fs.readFile(sourceFile, 'utf-8'));
+
+ const importFiles: string[] = [];
+ for (let index = 1; index <= 10; index++) {
+ const filePath = path.join(tempDir, `sample-postman-${index}.json`);
+ const fileContent = {
+ ...sourceContent,
+ info: {
+ ...sourceContent.info,
+ name: getViewportCollectionName(index)
+ }
+ };
+
+ await fs.writeFile(filePath, JSON.stringify(fileContent, null, 2), 'utf-8');
+ importFiles.push(filePath);
+ }
+
+ await page.getByTestId('collections-header-add-menu').click();
+ await page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Import collection' }).click();
+
+ const importModal = page.getByRole('dialog');
+ await importModal.waitFor({ state: 'visible' });
+ await expect(importModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');
+
+ await page.setInputFiles('input[type="file"]', importFiles);
+ await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
+
+ const bulkImportModal = page.getByRole('dialog');
+ await expect(bulkImportModal.locator('.bruno-modal-header-title')).toContainText('Bulk Import');
+ await expect(bulkImportModal.getByText('Collections (10)')).toBeVisible();
+
+ const collectionList = bulkImportModal.locator('.selection-list').first();
+ await expect(collectionList).toBeVisible();
+
+ const initialVisibleRows = await getFullyVisibleRowNames(collectionList);
+ expect(initialVisibleRows).toHaveLength(expectedVisibleRows);
+ expect(initialVisibleRows[0]).toBe(getViewportCollectionName(1));
+ expect(initialVisibleRows[expectedVisibleRows - 1]).toBe(getViewportCollectionName(expectedVisibleRows));
+ expect(initialVisibleRows).not.toContain(getViewportCollectionName(expectedVisibleRows + 1));
+
+ await collectionList.evaluate((list) => {
+ list.scrollTop = list.scrollHeight;
+ });
+
+ await expect(async () => {
+ const scrolledVisibleRows = await getFullyVisibleRowNames(collectionList);
+ expect(scrolledVisibleRows).toHaveLength(expectedVisibleRows);
+ expect(scrolledVisibleRows).toContain(getViewportCollectionName(9));
+ expect(scrolledVisibleRows).toContain(getViewportCollectionName(10));
+ }).toPass({ timeout: 5000 });
+ });
+});