diff --git a/packages/bruno-app/src/components/MultipartFileChipsCell/StyledWrapper.js b/packages/bruno-app/src/components/MultipartFileChipsCell/StyledWrapper.js
new file mode 100644
index 000000000..2bea4cb1a
--- /dev/null
+++ b/packages/bruno-app/src/components/MultipartFileChipsCell/StyledWrapper.js
@@ -0,0 +1,202 @@
+import styled from 'styled-components';
+
+const Wrapper = styled.div`
+ width: 100%;
+ display: flex;
+ align-items: center;
+ min-width: 0;
+ position: relative;
+
+ .file-chips-row {
+ display: flex;
+ flex-wrap: nowrap;
+ align-items: center;
+ gap: 4px;
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ }
+
+ .file-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 3px 6px;
+ border-radius: 6px;
+ background: transparent;
+ border: 1px solid ${(props) => props.theme.input.border};
+ font-size: 12px;
+ line-height: 1;
+ color: ${(props) => props.theme.text};
+ max-width: 140px;
+ min-width: 75px;
+ flex: 0 1 auto;
+ white-space: nowrap;
+ }
+
+ .file-chip-icon {
+ flex: 0 0 auto;
+ color: ${(props) => props.theme.colors.text.muted};
+ }
+
+ .file-chip-name {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ flex: 1 1 auto;
+ min-width: 0;
+ }
+
+ .file-chip-remove {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 1px;
+ color: ${(props) => props.theme.colors.text.muted};
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ border-radius: 3px;
+ flex: 0 0 auto;
+
+ &:hover {
+ color: ${(props) => props.theme.colors.text.danger};
+ }
+ }
+
+ .file-more-chip {
+ display: inline-flex;
+ align-items: center;
+ padding: 2px 4px;
+ background: transparent;
+ border: none;
+ font-size: 12px;
+ line-height: 1;
+ color: ${(props) => props.theme.primary.text};
+ cursor: pointer;
+ flex: 0 0 auto;
+ white-space: nowrap;
+
+ &:hover {
+ color: ${(props) => props.theme.primary.text};
+ opacity: 0.8;
+ }
+ }
+
+ .file-summary-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 3px 6px;
+ border-radius: 6px;
+ background: transparent;
+ border: 1px solid ${(props) => props.theme.input.border};
+ font-size: 12px;
+ line-height: 1;
+ color: ${(props) => props.theme.text};
+ cursor: pointer;
+ flex: 0 1 auto;
+ min-width: 0;
+ white-space: nowrap;
+
+ > span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ color: ${(props) => props.theme.text};
+ }
+
+ > svg {
+ color: ${(props) => props.theme.colors.text.muted};
+ }
+
+ &:hover,
+ &:hover > span {
+ color: ${(props) => props.theme.text};
+ }
+
+ &:hover {
+ border-color: ${(props) => props.theme.colors.text.muted};
+ background: ${(props) => props.theme.requestTabs.icon.hoverBg};
+ }
+ }
+
+ .upload-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 4px;
+ color: ${(props) => props.theme.colors.text.muted};
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ border-radius: 4px;
+ transition: color 0.15s ease;
+ flex: 0 0 auto;
+ margin-left: auto;
+
+ &:hover {
+ color: ${(props) => props.theme.text};
+ }
+ }
+`;
+
+export const OverflowList = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ padding: 4px;
+ max-height: 260px;
+ overflow-y: auto;
+ min-width: 220px;
+ max-width: 360px;
+
+ .overflow-row {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ width: 100%;
+ padding: 6px 8px;
+ border-radius: 4px;
+ background: transparent;
+ font-size: 12px;
+ line-height: 1.2;
+ color: ${(props) => props.theme.text};
+
+ &:hover {
+ background: ${(props) => props.theme.requestTabs.icon.hoverBg};
+ }
+ }
+
+ .overflow-row-icon {
+ flex: 0 0 auto;
+ color: ${(props) => props.theme.colors.text.muted};
+ }
+
+ .overflow-row-name {
+ flex: 1 1 auto;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .overflow-row-remove {
+ margin-left: auto;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 2px;
+ color: ${(props) => props.theme.colors.text.muted};
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ border-radius: 3px;
+ flex: 0 0 auto;
+
+ &:hover {
+ color: ${(props) => props.theme.colors.text.danger};
+ }
+ }
+`;
+
+export default Wrapper;
diff --git a/packages/bruno-app/src/components/MultipartFileChipsCell/index.js b/packages/bruno-app/src/components/MultipartFileChipsCell/index.js
new file mode 100644
index 000000000..62340e766
--- /dev/null
+++ b/packages/bruno-app/src/components/MultipartFileChipsCell/index.js
@@ -0,0 +1,195 @@
+import React, { useLayoutEffect, useRef, useState } from 'react';
+import { IconUpload, IconX, IconFile, IconChevronDown } from '@tabler/icons';
+import Dropdown from 'components/Dropdown';
+import ToolHint from 'components/ToolHint';
+import path, { normalizePath } from 'utils/common/path';
+import Wrapper, { OverflowList } from './StyledWrapper';
+
+const basename = (filePath) => (filePath ? path.basename(normalizePath(String(filePath))) : '');
+
+// Keep in sync with the corresponding CSS values in StyledWrapper.js:
+// MIN_CHIP_W ↔ .file-chip { min-width: 75px }
+// CHIP_GAP ↔ .file-chips-row { gap: 4px }
+const MIN_CHIP_W = 75;
+const CHIP_GAP = 4;
+const UPLOAD_RESERVE = 28;
+const MORE_CHIP_RESERVE = 56;
+
+const MultipartFileChipsCell = ({ files, onRemove, onAdd, editMode = true }) => {
+ const containerRef = useRef(null);
+ const tooltipPrefix = useRef(`mp-tip-${Math.random().toString(36).slice(2, 10)}`).current;
+ const [visibleCount, setVisibleCount] = useState(files.length);
+
+ useLayoutEffect(() => {
+ const container = containerRef.current;
+ if (!container) return;
+ // Measure the td (column-width, stable) rather than the content-sized cell,
+ // which would feed back on visibleCount.
+ const td = container.closest('td') || container.parentElement;
+ if (!td) return;
+
+ const compute = () => {
+ const tdStyle = window.getComputedStyle(td);
+ const padX = parseFloat(tdStyle.paddingLeft) + parseFloat(tdStyle.paddingRight);
+ const total = td.clientWidth - padX;
+ if (files.length === 0) {
+ setVisibleCount(0);
+ return;
+ }
+
+ const allAtMin = files.length * MIN_CHIP_W + Math.max(0, files.length - 1) * CHIP_GAP;
+ if (allAtMin + UPLOAD_RESERVE <= total) {
+ setVisibleCount(files.length);
+ return;
+ }
+
+ const available = total - UPLOAD_RESERVE - MORE_CHIP_RESERVE;
+ const n = Math.max(0, Math.floor((available + CHIP_GAP) / (MIN_CHIP_W + CHIP_GAP)));
+ setVisibleCount(n);
+ };
+
+ compute();
+ const ro = new ResizeObserver(compute);
+ ro.observe(td);
+ return () => ro.disconnect();
+ }, [files]);
+
+ const visible = files.slice(0, visibleCount);
+ const overflow = files.slice(visibleCount);
+ const collapsed = visibleCount === 0 && files.length > 0;
+
+ const renderChip = (filePath, idx) => (
+
+
+
+ {basename(filePath)}
+
+ {editMode && (
+
+ )}
+
+ );
+
+ const renderOverflowList = (list) => (
+
+ {list.map((p, i) => (
+
+
+
+ {basename(p)}
+
+ {editMode && (
+
+ )}
+
+ ))}
+
+ );
+
+ return (
+
+ {collapsed ? (
+ <>
+ document.body}
+ icon={(
+
+ )}
+ >
+ {renderOverflowList(files)}
+
+
+ >
+ ) : (
+ <>
+
+ {visible.map((p, i) => renderChip(p, i))}
+
+ {overflow.length > 0 && (
+ document.body}
+ icon={(
+
+ )}
+ >
+ {renderOverflowList(overflow)}
+
+ )}
+ >
+ )}
+ {editMode && (
+
+ )}
+
+ );
+};
+
+export default MultipartFileChipsCell;
diff --git a/packages/bruno-app/src/components/RequestPane/MultipartFormParams/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/MultipartFormParams/StyledWrapper.js
index b191ace70..36f85c31d 100644
--- a/packages/bruno-app/src/components/RequestPane/MultipartFormParams/StyledWrapper.js
+++ b/packages/bruno-app/src/components/RequestPane/MultipartFormParams/StyledWrapper.js
@@ -13,6 +13,7 @@ const Wrapper = styled.div`
cursor: pointer;
border-radius: 4px;
transition: color 0.15s ease;
+ flex: 0 0 auto;
&:hover {
color: ${(props) => props.theme.text};
@@ -23,15 +24,6 @@ const Wrapper = styled.div`
color: ${(props) => props.theme.colors.text.danger};
}
- .file-value-cell {
- width: 100%;
-
- .file-name {
- font-size: 12px;
- color: ${(props) => props.theme.text};
- }
- }
-
.value-cell {
width: 100%;
diff --git a/packages/bruno-app/src/components/RequestPane/MultipartFormParams/index.js b/packages/bruno-app/src/components/RequestPane/MultipartFormParams/index.js
index 05fb33b88..a5e6f507c 100644
--- a/packages/bruno-app/src/components/RequestPane/MultipartFormParams/index.js
+++ b/packages/bruno-app/src/components/RequestPane/MultipartFormParams/index.js
@@ -1,8 +1,9 @@
import React, { useCallback, useRef } from 'react';
import get from 'lodash/get';
+import toast from 'react-hot-toast';
import { useDispatch, useSelector } from 'react-redux';
import { useTheme } from 'providers/Theme';
-import { IconUpload, IconX, IconFile } from '@tabler/icons';
+import { IconUpload } from '@tabler/icons';
import {
moveMultipartFormParam,
setMultipartFormParams
@@ -10,14 +11,18 @@ import {
import { browseFiles } from 'providers/ReduxStore/slices/collections/actions';
import MultiLineEditor from 'components/MultiLineEditor';
import SingleLineEditor from 'components/SingleLineEditor';
+import MultipartFileChipsCell from 'components/MultipartFileChipsCell';
import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collections/actions';
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
import EditableTable from 'components/EditableTable';
import StyledWrapper from './StyledWrapper';
-import { getRelativePathWithinBasePath } from 'utils/common/path';
+import path, { getRelativePathWithinBasePath, normalizePath } from 'utils/common/path';
+import { getMultipartAutoContentType } from 'utils/common/multipartContentType';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
-import { isWindowsOS } from 'utils/common/platform';
+
+const fileBasename = (filePath) =>
+ filePath ? path.basename(normalizePath(String(filePath))) : '';
const MultipartFormParams = ({ item, collection }) => {
const dispatch = useDispatch();
@@ -57,8 +62,10 @@ const MultipartFormParams = ({ item, collection }) => {
}, [dispatch, collection.uid, item.uid]);
const handleBrowseFiles = useCallback((row, onChange) => {
- dispatch(browseFiles())
+ dispatch(browseFiles([], ['multiSelections']))
.then((filePaths) => {
+ if (!Array.isArray(filePaths) || filePaths.length === 0) return;
+
const processedPaths = filePaths.map((filePath) => {
return getRelativePathWithinBasePath(collection.pathname, filePath);
});
@@ -66,19 +73,42 @@ const MultipartFormParams = ({ item, collection }) => {
const currentParams = item.draft
? get(item, 'draft.request.body.multipartForm')
: get(item, 'request.body.multipartForm');
- const existsInParams = (currentParams || []).some((p) => p.uid === row.uid);
+ const existingParam = (currentParams || []).find((p) => p.uid === row.uid);
+ const existingValue = existingParam && existingParam.type === 'file' && Array.isArray(existingParam.value)
+ ? existingParam.value
+ : [];
+ const seen = new Set(existingValue);
+ const merged = [...existingValue];
+ const skipped = [];
+ for (const p of processedPaths) {
+ if (!seen.has(p)) {
+ seen.add(p);
+ merged.push(p);
+ } else {
+ skipped.push(p);
+ }
+ }
+
+ if (skipped.length === 1) {
+ toast(`"${fileBasename(skipped[0])}" is already added`);
+ } else if (skipped.length > 1) {
+ toast(`${skipped.length} files are already added — skipped`);
+ }
+
+ const autoContentType = getMultipartAutoContentType(merged);
+
let updatedParams;
- if (existsInParams) {
+ if (existingParam) {
updatedParams = currentParams.map((p) => {
if (p.uid === row.uid) {
- return { ...p, type: 'file', value: processedPaths };
+ return { ...p, type: 'file', value: merged, contentType: autoContentType };
}
return p;
});
} else {
updatedParams = [
...(currentParams || []),
- { uid: row.uid, name: row.name || '', enabled: true, type: 'file', value: processedPaths, contentType: '' }
+ { uid: row.uid, name: row.name || '', enabled: true, type: 'file', value: merged, contentType: autoContentType }
];
}
handleParamsChange(updatedParams);
@@ -88,13 +118,21 @@ const MultipartFormParams = ({ item, collection }) => {
});
}, [dispatch, collection.pathname, item, handleParamsChange]);
- const handleClearFile = useCallback((row) => {
+ const handleRemoveFile = useCallback((row, filePathToRemove) => {
const currentParams = params || [];
+ const target = currentParams.find((p) => p.uid === row.uid);
+ if (!target || target.type !== 'file') return;
+ const currentValue = Array.isArray(target.value)
+ ? target.value
+ : (target.value ? [target.value] : []);
+ const nextValue = currentValue.filter((p) => p !== filePathToRemove);
+
const updatedParams = currentParams.map((p) => {
- if (p.uid === row.uid) {
- return { ...p, type: 'text', value: '' };
+ if (p.uid !== row.uid) return p;
+ if (nextValue.length === 0) {
+ return { ...p, type: 'text', value: '', contentType: '' };
}
- return p;
+ return { ...p, type: 'file', value: nextValue, contentType: getMultipartAutoContentType(nextValue) };
});
handleParamsChange(updatedParams);
}, [params, handleParamsChange]);
@@ -115,19 +153,12 @@ const MultipartFormParams = ({ item, collection }) => {
}
}, [params, handleParamsChange]);
- const getFileName = (filePaths) => {
+ const getFileList = (filePaths) => {
if (!filePaths || (Array.isArray(filePaths) && filePaths.length === 0)) {
- return null;
+ return [];
}
const paths = Array.isArray(filePaths) ? filePaths : [filePaths];
- const validPaths = paths.filter((v) => v != null && v !== '');
- if (validPaths.length === 0) return null;
-
- const separator = isWindowsOS() ? '\\' : '/';
- if (validPaths.length === 1) {
- return validPaths[0].split(separator).pop();
- }
- return `${validPaths.length} file(s)`;
+ return paths.filter((v) => v != null && v !== '');
};
const columns = [
@@ -144,29 +175,14 @@ const MultipartFormParams = ({ item, collection }) => {
placeholder: 'Value',
width: '35%',
render: ({ row, value, onChange }) => {
- const isFile = row.type === 'file';
- const fileName = isFile ? getFileName(value) : null;
- if (fileName) {
+ const files = row.type === 'file' ? getFileList(value) : [];
+ if (files.length > 0) {
return (
-
-
-
-
-
-
-
+ handleRemoveFile(row, filePath)}
+ onAdd={() => handleBrowseFiles(row, onChange)}
+ />
);
}
@@ -186,6 +202,7 @@ const MultipartFormParams = ({ item, collection }) => {
/>