Skip to content
10 changes: 2 additions & 8 deletions packages/bruno-app/src/components/FilePickerEditor/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import path from 'utils/common/path';
import { getRelativePathWithinBasePath } from 'utils/common/path';
import { useDispatch } from 'react-redux';
import { browseFiles } from 'providers/ReduxStore/slices/collections/actions';
import { IconX, IconUpload, IconFile } from '@tabler/icons';
Expand Down Expand Up @@ -48,13 +48,7 @@ const FilePickerEditor = ({
// If file is in the collection's directory, then we use relative path
// Otherwise, we use the absolute path
filePaths = filePaths.map((filePath) => {
const collectionDir = collection.pathname;

if (filePath.startsWith(collectionDir)) {
return path.relative(collectionDir, filePath);
}

return filePath;
return getRelativePathWithinBasePath(collection.pathname, filePath);
});

onChange(isSingleFilePicker ? filePaths[0] : filePaths);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { sendRequest, saveRequest } from 'providers/ReduxStore/slices/collection
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
import EditableTable from 'components/EditableTable';
import StyledWrapper from './StyledWrapper';
import path from 'utils/common/path';
import { getRelativePathWithinBasePath } from 'utils/common/path';
import { usePersistedState } from 'hooks/usePersistedState';
import { useTrackScroll } from 'hooks/useTrackScroll';
import { isWindowsOS } from 'utils/common/platform';
Expand Down Expand Up @@ -60,11 +60,7 @@ const MultipartFormParams = ({ item, collection }) => {
dispatch(browseFiles())
.then((filePaths) => {
const processedPaths = filePaths.map((filePath) => {
const collectionDir = collection.pathname;
if (filePath.startsWith(collectionDir)) {
return path.relative(collectionDir, filePath);
}
return filePath;
return getRelativePathWithinBasePath(collection.pathname, filePath);
});

const currentParams = item.draft
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { updateResponseExampleMultipartFormParams } from 'providers/ReduxStore/s
import { browseFiles } from 'providers/ReduxStore/slices/collections/actions';
import { updateTableColumnWidths } from 'providers/ReduxStore/slices/tabs';
import mime from 'mime-types';
import path from 'utils/common/path';
import path, { getRelativePathWithinBasePath } from 'utils/common/path';
import EditableTable from 'components/EditableTable';
import MultiLineEditor from 'components/MultiLineEditor';
import SingleLineEditor from 'components/SingleLineEditor';
Expand Down Expand Up @@ -51,11 +51,7 @@ const ResponseExampleMultipartFormParams = ({ item, collection, exampleUid, edit
dispatch(browseFiles())
.then((filePaths) => {
const processedPaths = filePaths.map((filePath) => {
const collectionDir = collection.pathname;
if (filePath.startsWith(collectionDir)) {
return path.relative(collectionDir, filePath);
}
return filePath;
return getRelativePathWithinBasePath(collection.pathname, filePath);
});

const currentParams = params || [];
Expand Down
52 changes: 51 additions & 1 deletion packages/bruno-app/src/utils/common/path.js
Original file line number Diff line number Diff line change
Expand Up @@ -163,10 +163,60 @@ const getAbsoluteFilePath = (basePath, relativePath, shouldPosixify = false) =>
return shouldPosixify ? posixify(result) : result;
};

/**
* Returns a relative path when filePath is contained within basePath.
* For paths outside basePath (or same path), returns the original filePath unchanged.
*
* @param {string} basePath - The base path to check containment against (e.g., collection pathname).
* @param {string} filePath - The absolute file path to compute a relative path for.
* @param {boolean} [shouldPosixify=false] - When true, output uses '/' separators for
* cross-platform safety. Callers storing to version-controlled config files should opt in
* by passing true. Default false preserves legacy platform-native separators for
* backwards compatibility.
* @returns {string} Relative path if filePath is inside basePath, otherwise filePath itself.
*
* @example
* getRelativePathWithinBasePath('/users/john/collections/api', '/users/john/collections/api/files/payload.txt');
* → "files/payload.txt"
*
* @example
* getRelativePathWithinBasePath('/users/john/collections/api', '/users/john/downloads/payload.txt');
* → "/users/john/downloads/payload.txt"
*
* @example
* On Windows with posixify enabled
* getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', 'C:\\Users\\John\\Collections\\Api\\files\\payload.txt', true);
* → "files/payload.txt"
*/
const getRelativePathWithinBasePath = (basePath, filePath, shouldPosixify = false) => {
if (!basePath || !filePath) {
return filePath;
}

try {
const relativePath = getRelativePath(basePath, filePath, shouldPosixify);
const sep = shouldPosixify ? '/' : brunoPath.sep;

if (
!relativePath
|| relativePath === '.'
|| relativePath === '..'
|| relativePath.startsWith(`..${sep}`)
|| brunoPath.isAbsolute(relativePath)
) {
return shouldPosixify ? posixify(filePath) : filePath;
}

return relativePath;
} catch (error) {
return shouldPosixify ? posixify(filePath) : filePath;
}
};

const normalizePath = (p) => {
if (!p) return '';
return p.replace(/\\/g, '/').replace(/\/+$/, '');
};

export default brunoPath;
export { getRelativePath, getBasename, getAbsoluteFilePath, normalizePath };
export { getRelativePath, getBasename, getAbsoluteFilePath, getRelativePathWithinBasePath, normalizePath };
79 changes: 78 additions & 1 deletion packages/bruno-app/src/utils/common/path.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ jest.mock('platform', () => ({
}
}));

import { getRelativePath, getBasename, getAbsoluteFilePath } from './path';
import path from 'path';
import { getRelativePath, getBasename, getAbsoluteFilePath, getRelativePathWithinBasePath } from './path';

describe('Path Utilities - Unix Platform', () => {
describe('getRelativePath', () => {
Expand All @@ -25,6 +26,10 @@ describe('Path Utilities - Unix Platform', () => {
expect(getRelativePath('/users/john/projects', '/users/john/projects/src/components')).toBe('src/components');
});

it('should return ".." for direct parent directory', () => {
expect(getRelativePath('/users/john/projects', '/users/john')).toBe('..');
});

it('should handle null/undefined inputs', () => {
expect(getRelativePath(null, '/users/john/projects')).toBe('/users/john/projects');
expect(getRelativePath(undefined, '/users/john/projects')).toBe('/users/john/projects');
Expand Down Expand Up @@ -113,6 +118,78 @@ describe('Path Utilities - Unix Platform', () => {
});
});

describe('getRelativePathWithinBasePath', () => {
it('should store in-collection files as relative paths', () => {
const result = getRelativePathWithinBasePath('/users/john/collections/api', '/users/john/collections/api/files/payload.txt');
expect(result).toBe('files/payload.txt');
});

it('should handle collection paths with trailing separators', () => {
const result = getRelativePathWithinBasePath('/users/john/collections/api/', '/users/john/collections/api/files/payload.txt');
expect(result).toBe('files/payload.txt');
});

it('should resolve dot segments before deciding whether a file is inside the collection', () => {
const result = getRelativePathWithinBasePath('/users/john/collections/api', '/users/john/collections/api/files/../payload.txt');
expect(result).toBe('payload.txt');
});

it('should keep paths that resolve outside the collection absolute', () => {
const filePath = '/users/john/collections/api/../payload.txt';
const result = getRelativePathWithinBasePath('/users/john/collections/api', filePath);
expect(result).toBe(filePath);
});

it('should keep outside collection paths absolute', () => {
const filePath = '/users/john/downloads/payload.txt';
const result = getRelativePathWithinBasePath('/users/john/collections/api', filePath);
expect(result).toBe(filePath);
});

it('should keep sibling prefix paths absolute', () => {
const filePath = '/users/john/collections/api-other/payload.txt';
const result = getRelativePathWithinBasePath('/users/john/collections/api', filePath);
expect(result).toBe(filePath);
});

it('should keep same-path values unchanged', () => {
const filePath = '/users/john/collections/api';
const result = getRelativePathWithinBasePath('/users/john/collections/api', filePath);
expect(result).toBe(filePath);
});

it('should store in-collection paths whose names begin with two dots as relative paths', () => {
const result = getRelativePathWithinBasePath('/users/john/collections/api', '/users/john/collections/api/..payload.txt');
expect(result).toBe('..payload.txt');
});

it('should keep the original file path when inputs are missing', () => {
expect(getRelativePathWithinBasePath('', '/users/john/downloads/payload.txt')).toBe('/users/john/downloads/payload.txt');
expect(getRelativePathWithinBasePath('/users/john/collections/api', '')).toBe('');
});

it('should treat relative collection path as cwd-relative when file path is absolute', () => {
const collectionPath = 'collections/api';
const filePath = path.resolve(collectionPath, 'files/payload.txt');
const result = getRelativePathWithinBasePath(collectionPath, filePath);
expect(result).toBe('files/payload.txt');
});

it('should treat relative file path as cwd-relative when collection path is absolute', () => {
const collectionPath = path.resolve('collections/api');
const filePath = 'collections/api/files/payload.txt';
const result = getRelativePathWithinBasePath(collectionPath, filePath);
expect(result).toBe('files/payload.txt');
});

it('should treat both relative paths as cwd-relative for containment checks', () => {
const collectionPath = 'collections/api';
const filePath = 'collections/api/files/payload.txt';
const result = getRelativePathWithinBasePath(collectionPath, filePath);
expect(result).toBe('files/payload.txt');
});
});

describe('Edge cases', () => {
it('should handle very long paths', () => {
const longPath = '/users/john/projects/' + 'a'.repeat(100);
Expand Down
117 changes: 116 additions & 1 deletion packages/bruno-app/src/utils/common/path.windows.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ jest.mock('platform', () => ({
}
}));

import { getRelativePath, getBasename, getAbsoluteFilePath } from './path';
import { getRelativePath, getBasename, getAbsoluteFilePath, getRelativePathWithinBasePath } from './path';

describe('Path Utilities - Windows Platform', () => {
describe('getRelativePath', () => {
Expand All @@ -25,6 +25,14 @@ describe('Path Utilities - Windows Platform', () => {
expect(getRelativePath('C:\\Users\\John\\Projects', 'C:\\Users\\John\\Projects\\src\\components', false)).toBe('src\\components');
});

it('should return ".." for direct parent directory', () => {
expect(getRelativePath('C:\\Users\\John\\Projects', 'C:\\Users\\John', false)).toBe('..');
});

it('should return an absolute path for cross-drive targets', () => {
expect(getRelativePath('C:\\Users\\John\\Projects', 'D:\\payload.txt', false)).toBe('D:\\payload.txt');
});

describe('with posixify enabled', () => {
it('should convert backslashes to forward slashes', () => {
expect(getRelativePath('C:\\Users\\John\\Projects', 'C:\\Users\\John\\Projects\\App')).toBe('App');
Expand Down Expand Up @@ -181,6 +189,113 @@ describe('Path Utilities - Windows Platform', () => {
});
});

describe('getRelativePathWithinBasePath', () => {
it('should store in-collection files as Windows relative paths with mixed separators', () => {
const result = getRelativePathWithinBasePath('C:/Users/John/Collections/Api', 'C:\\Users\\John\\Collections\\Api\\files\\payload.txt');
expect(result).toBe('files\\payload.txt');
});

it('should store nested in-collection files as Windows relative paths', () => {
const result = getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', 'C:\\Users\\John\\Collections\\Api\\folder\\payload.txt');
expect(result).toBe('folder\\payload.txt');
});

it('should handle collection paths with trailing separators', () => {
const result = getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api\\', 'C:\\Users\\John\\Collections\\Api\\folder\\payload.txt');
expect(result).toBe('folder\\payload.txt');
});

it('should handle case differences in Windows drive paths', () => {
const result = getRelativePathWithinBasePath('c:\\users\\john\\collections\\api', 'C:\\Users\\John\\Collections\\Api\\folder\\payload.txt');
expect(result).toBe('folder\\payload.txt');
});

it('should resolve dot segments before deciding whether a file is inside the collection', () => {
const result = getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', 'C:\\Users\\John\\Collections\\Api\\folder\\..\\payload.txt');
expect(result).toBe('payload.txt');
});

it('should keep paths that resolve outside the collection absolute', () => {
const filePath = 'C:\\Users\\John\\Collections\\Api\\..\\payload.txt';
const result = getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', filePath);
expect(result).toBe(filePath);
});

it('should keep sibling prefix paths absolute', () => {
const filePath = 'C:\\Users\\John\\Collections\\ApiOther\\payload.txt';
const result = getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', filePath);
expect(result).toBe(filePath);
});

it('should keep outside collection paths absolute', () => {
const filePath = 'C:\\Users\\John\\Downloads\\payload.txt';
const result = getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', filePath);
expect(result).toBe(filePath);
});

it('should keep cross-drive paths absolute', () => {
const filePath = 'D:\\payload.txt';
const result = getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', filePath);
expect(result).toBe(filePath);
});

it('should keep same-path values unchanged', () => {
const filePath = 'C:\\Users\\John\\Collections\\Api';
const result = getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', filePath);
expect(result).toBe(filePath);
});

it('should keep the original file path when inputs are missing', () => {
expect(getRelativePathWithinBasePath('', 'C:\\Users\\John\\Downloads\\payload.txt')).toBe('C:\\Users\\John\\Downloads\\payload.txt');
expect(getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', '')).toBe('');
});

describe('mixed separators (posix base / win file)', () => {
it('inside → relative path with native separators (default)', () => {
const r = getRelativePathWithinBasePath('C:/Users/John/Collections/Api', 'C:\\Users\\John\\Collections\\Api\\files\\payload.txt');
expect(r).toBe('files\\payload.txt');
});

it('outside → returns original filePath unchanged (default)', () => {
const r = getRelativePathWithinBasePath('C:/Users/John/Collections/Api', 'C:\\Users\\John\\Downloads\\payload.txt');
expect(r).toBe('C:\\Users\\John\\Downloads\\payload.txt');
});

it('outside → posixified absolute fallback when posixify=true', () => {
const r = getRelativePathWithinBasePath('C:/Users/John/Collections/Api', 'C:\\Users\\John\\Downloads\\payload.txt', true);
expect(r).toBe('C:/Users/John/Downloads/payload.txt');
});

it('inside → posixified relative path when posixify=true', () => {
const r = getRelativePathWithinBasePath('C:/Users/John/Collections/Api', 'C:\\Users\\John\\Collections\\Api\\files\\payload.txt', true);
expect(r).toBe('files/payload.txt');
});
});

describe('mixed separators (win base / posix file)', () => {
it('inside → relative path with native separators (default)', () => {
const r = getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', 'C:/Users/John/Collections/Api/files/payload.txt');
expect(r).toBe('files\\payload.txt');
});

it('outside → returns original filePath as-is (default)', () => {
const r = getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', 'C:/Users/John/Downloads/payload.txt');
// filePath uses '/', returned as-is since shouldPosixify=false
expect(r).toBe('C:/Users/John/Downloads/payload.txt');
});

it('outside → posixified fallback when posixify=true', () => {
const r = getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', 'C:/Users/John/Downloads/payload.txt', true);
expect(r).toBe('C:/Users/John/Downloads/payload.txt');
});

it('inside → posixified relative path when posixify=true', () => {
const r = getRelativePathWithinBasePath('C:\\Users\\John\\Collections\\Api', 'C:/Users/John/Collections/Api/files/payload.txt', true);
expect(r).toBe('files/payload.txt');
});
});
});

describe('Cross-platform path handling', () => {
describe('Windows fromPath with POSIX toPath', () => {
it('should handle Windows fromPath with POSIX toPath in getAbsoluteFilePath', () => {
Expand Down
Loading
Loading