feat: add ZIP file import for collections#7063
Conversation
WalkthroughAdds ZIP-backed collection import: UI now delegates file input to a new FileTab component which emits Changes
Sequence Diagram(s)sequenceDiagram
participant User
participant UI as ImportCollection / FileTab
participant Sections as CollectionsSection / WorkspaceOverview
participant Redux as Redux Thunk
participant IPC as Electron IPC
participant FS as File System
User->>UI: drop/select ZIP
UI->>Sections: submit({ isZipImport: true, zipFilePath, collectionName })
Sections->>Redux: dispatch importCollectionFromZip(zipFilePath, location, ...)
Redux->>IPC: ipcRenderer.invoke('renderer:import-collection-zip', zipFilePath, location)
IPC->>FS: extract ZIP to temp dir
IPC->>FS: run security checks (no escaping symlinks)
IPC->>FS: find bruno.json / opencollection.yml, derive name
IPC->>FS: sanitize & compute unique dest path
IPC->>FS: move collection to target
IPC->>FS: cleanup temp files
IPC-->>Redux: return final collectionPath
Redux->>Sections: resolve with collectionPath
Sections->>User: show success or error
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Fix all issues with AI agents
In `@packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js`:
- Around line 2673-2678: Replace the brittle split('/').pop() extraction with
the platform-aware path.basename to get the collection name; in the block that
checks activeWorkspace (the code that computes collectionName from
collectionPath before calling
ipcRenderer.invoke('renderer:add-collection-to-workspace', ...)), use
path.basename(collectionPath) so Windows backslashes are handled correctly and
pass that name into the payload.
- Around line 2663-2685: The importCollectionFromZip thunk uses an async Promise
executor which is an antipattern; replace the new Promise(async (resolve,
reject) => { ... }) wrapper by making the thunk itself async: export const
importCollectionFromZip = (zipFilePath, collectionLocation) => async (dispatch,
getState) => { ... }; inside, await
ipcRenderer.invoke('renderer:import-collection-zip', ...), perform the workspace
add via ipcRenderer.invoke if needed, then return collectionPath (and let thrown
errors propagate instead of calling reject) so callers receive the resolved
value or exception normally; update references to resolve/reject accordingly.
In `@packages/bruno-electron/src/ipc/collection.js`:
- Around line 2065-2071: If sanitizeName(collectionName) can return an empty
string, ensure you provide a safe fallback before building finalCollectionPath:
after computing sanitizedName in the function handling collection creation
(where sanitizeName, finalCollectionPath, collectionLocation and collectionName
are used) check if sanitizedName is falsy and replace it with a default like
"untitled" (optionally append a timestamp or UUID to avoid collisions), then run
the existing existsSync loop to append " (n)" as needed so finalCollectionPath
never resolves to the parent directory.
- Around line 2018-2086: The import handler (ipcMain.handle
'renderer:import-collection-zip') must reject ZIPs that create symlinks pointing
outside the extraction dir: after extraction to tempDir but before touching
brunoJsonPath/openCollectionYmlPath or calling fs.statSync/fs.readFileSync,
recursively walk collectionDir/extractedItems and for each entry use
fs.lstatSync to detect symbolic links; for any symlink use fs.realpathSync (or
path.resolve) to compute its target and ensure the resolved target is inside
tempDir (startsWith tempDir) — if any symlink points outside, abort the import
(clean up tempDir) and throw; additionally, when reading
bruno.json/opencollection.yml check with lstat that those specific files are not
symlinks and fail if they are, then proceed to move with fsExtra.move only after
validation.
packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js
Outdated
Show resolved
Hide resolved
packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@packages/bruno-electron/src/ipc/collection.js`:
- Line 2109: The ZIP import branch returns finalCollectionPath without emitting
the UI event, so add the same emit used by renderer:import-collection: after
computing finalCollectionPath and before returning, send the
main:collection-opened event (with finalCollectionPath) for non-workspace
imports; keep the existing main:workspace-config-updated behavior for workspace
imports and ensure you call the same sending helper (the code path that
currently emits main:collection-opened at renderer:import-collection) so the
sidebar auto-loads the imported collection.
🧹 Nitpick comments (2)
packages/bruno-electron/src/ipc/collection.js (2)
2082-2094: Silentcatchblocks hide config-parsing failures.If
bruno.jsonoropencollection.ymlis present but malformed, the import silently falls back to'Imported Collection'with zero diagnostics. Consider logging atwarnlevel so users (and devs debugging support tickets) know why the name wasn't picked up.Proposed fix
collectionName = brunoConfig?.name || collectionName; - } catch (e) {} + } catch (e) { + console.warn('Failed to parse opencollection.yml for collection name:', e.message); + } } else if (fs.existsSync(brunoJsonPath)) { try { const config = JSON.parse(fs.readFileSync(brunoJsonPath, 'utf8')); collectionName = config.name || collectionName; - } catch (e) {} + } catch (e) { + console.warn('Failed to parse bruno.json for collection name:', e.message); + }
2114-2117: Redundant outertry-catch— just re-throws.The inner
catch(line 2110) already cleans uptempDirand re-throws. The outercatchon line 2114 does nothing butthrow error, adding an unnecessary nesting level.Proposed simplification
ipcMain.handle('renderer:import-collection-zip', async (event, zipFilePath, collectionLocation) => { - try { if (!fs.existsSync(zipFilePath)) { throw new Error('ZIP file does not exist'); } // ... rest of validation ... // ... inner try-catch handles cleanup ... - } catch (error) { - throw error; - } });
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Fix all issues with AI agents
In `@packages/bruno-app/src/components/Sidebar/ImportCollection/FileTab.js`:
- Around line 123-148: The processFiles function currently processes only the
first non-ZIP file (fileArray[0]) and silently ignores others; update
processFiles to detect multiple non-ZIP files and either (a) setErrorMessage
with a clear warning like "Multiple collection files selected. Please select
only one collection file or a single ZIP." and return, or (b) iterate and call
processFile for each file if batch imports are supported; locate and change
logic inside processFiles (referencing processFiles, processFile,
processZipFile, and setErrorMessage) to enforce one-of-two behaviors (explicit
error on multiple non-ZIP files or loop through fileArray and call processFile
for each) and ensure user feedback is shown when files are rejected.
- Around line 165-169: The file input's onChange can fail to fire when a user
re-selects the same file because the input value isn't cleared; inside
handleFileInputChange (which calls processFiles), after awaiting
processFiles(e.target.files) reset the file input value (e.target.value = '') so
subsequent selections of the same file trigger onChange again. Ensure this reset
happens only after processing completes and keep the existing null/length guard.
🧹 Nitpick comments (1)
packages/bruno-app/src/components/Sidebar/ImportCollection/FileTab.js (1)
13-34: WSDL guard catches all XML MIME types, not just WSDL.Line 17 checks
file.type === 'text/xml' || file.type === 'application/xml', which will short-circuit any XML file (e.g., a non-WSDL XML config) to raw text, skipping JSON/YAML parsing. Downstream detection (isWSDLCollectionetc.) will catch it as unsupported, so it's not broken, but the comment says "Handle WSDL files" while the condition is broader than that.Consider tightening the condition to just the
.wsdlextension check, and let mistyped XML files fall through to the normal parse-and-detect path.Suggested narrowing
- if (file.name.endsWith('.wsdl') || file.type === 'text/xml' || file.type === 'application/xml') { + if (file.name.endsWith('.wsdl')) {
ccbb755 to
92b326a
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
packages/bruno-app/src/components/WorkspaceHome/WorkspaceOverview/index.js (1)
60-75:⚠️ Potential issue | 🟡 MinorInconsistent error toast between WorkspaceOverview and CollectionsSection.
Line 73 uses
toast.error(err.message)which shows the raw error, whileCollectionsSection(line 74) usestoast.error('An error occurred while importing the collection'). Consider aligning them — raw error messages from IPC can be technical and unhelpful to users.
🤖 Fix all issues with AI agents
In `@packages/bruno-electron/src/ipc/collection.js`:
- Around line 2119-2131: The two empty catch blocks around parsing
opencollection.yml and bruno.json should log the parsing errors instead of
swallowing them; update the catch for the parseCollection call (relating to
openCollectionYmlPath and brunoConfig) and the catch for JSON.parse (relating to
brunoJsonPath) to emit a clear error message including the file path and the
exception (e.g., console.error or the project's logger) while preserving the
existing fallback to 'Imported Collection'.
🧹 Nitpick comments (2)
packages/bruno-electron/src/ipc/collection.js (1)
2151-2153: Redundant outer try-catch re-throws without modification.The outer
catchat line 2152 just doesthrow error, which is a no-op wrapper around the inner try-catch that already handles cleanup and re-throws.♻️ Simplified structure
ipcMain.handle('renderer:import-collection-zip', async (event, zipFilePath, collectionLocation) => { - try { if (!fs.existsSync(zipFilePath)) { throw new Error('ZIP file does not exist'); } if (!collectionLocation || !fs.existsSync(collectionLocation)) { throw new Error('Collection location does not exist'); } // ... rest of handler unchanged ... - } catch (error) { - throw error; - } });packages/bruno-app/src/components/Sidebar/ImportCollectionLocation/index.js (1)
77-80: Dead code path forbruno-zipinconvertCollection.The
formik.onSubmitat line 129 is never invoked for ZIP imports (guarded by line 170), so the'bruno-zip'case here is unreachable. It's harmless as a defensive fallback, but worth a comment.
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Fix all issues with AI agents
In `@packages/bruno-app/src/components/Sidebar/ImportCollection/FileTab.js`:
- Around line 75-86: The processZipFile function clears loading too early
because handleSubmit is not awaited; change processZipFile to await
handleSubmit({ rawData: { zipFilePath: filePath, collectionName }, type:
'bruno-zip' }) inside the try so setIsLoading(false) in the finally runs only
after the async import completes, and keep the existing catch (toastError) to
surface any errors from handleSubmit; reference the processZipFile function, the
handleSubmit call, setIsLoading and toastError when making this change.
- Around line 88-121: The processFile function calls handleSubmit({ rawData:
data, type }) without awaiting it, which can cause loading state to clear before
submission completes; update processFile to await handleSubmit(...) (i.e., use
await handleSubmit({ rawData: data, type })) so the try/finally lifecycle and
setIsLoading state behave correctly — locate the call inside processFile in
FileTab.js and add the missing await.
In `@packages/bruno-electron/src/ipc/collection.js`:
- Around line 2069-2086: The code uses fs.realpathSync in
validateNoExternalSymlinks which throws on dangling symlinks; replace that with
resolving the symlink target via fs.readlinkSync combined with path.resolve so
broken links don't cause ENOENT. Specifically, inside validateNoExternalSymlinks
where stat.isSymbolicLink() is checked, call const linkTarget =
fs.readlinkSync(fullPath) and then const resolvedTarget =
path.resolve(path.dirname(fullPath), linkTarget) (this handles both relative and
absolute link contents), and keep the existing check that resolvedTarget
startsWith(baseDir + path.sep) || resolvedTarget === baseDir, throwing the same
security error if it points outside; leave the surrounding recursion and
directory checks unchanged.
- Around line 2148-2153: The ZIP-import flow currently returns
finalCollectionPath after performing fsExtra.move(...) (and cleaning tempDir)
but never notifies the app; before returning finalCollectionPath add the same
notifications used by the renderer:import-collection handler: call
mainWindow.webContents.send('main:collection-opened', finalCollectionPath) and
ipcMain.emit('main:collection-opened', finalCollectionPath) (or the same payload
object used elsewhere) so the renderer sidebar picks up the new collection;
locate the block with fsExtra.move(collectionDir, finalCollectionPath), tempDir
checks, and the return finalCollectionPath and insert the two emits just prior
to the return.
🧹 Nitpick comments (1)
packages/bruno-electron/src/ipc/collection.js (1)
2158-2161: Redundant outer try-catch just re-throws.The outer
try { ... } catch (error) { throw error; }(lines 2056/2158–2160) adds no value — the inner block already throws, and theipcMain.handlerejection path works the same either way. Removing it flattens the nesting.
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Fix all issues with AI agents
In `@packages/bruno-app/src/components/Sidebar/ImportCollection/FileTab.js`:
- Around line 75-86: processZipFile can produce an empty collectionName for a
file literally named ".zip"; after computing collectionName from zipFile.name in
processZipFile, add a guard: if collectionName is empty, derive a safe fallback
(for example use the file basename from filePath without extension, or
zipFile.name if non-empty, or a deterministic default like
"imported-zip-<timestamp>"). Update the logic inside processZipFile (the
collectionName assignment and immediately before calling handleSubmit) to ensure
collectionName is never an empty string.
- Around line 13-34: The current convertFileToObject function treats any XML
MIME as WSDL which short-circuits JSON/YAML parsing; change the WSDL detection
to be specific: keep filename check (file.name.endsWith('.wsdl')) and accept the
official MIME 'application/wsdl+xml', and otherwise detect WSDL by inspecting
the file text for WSDL-specific root elements (e.g. '<definitions' or
'wsdl:definitions') before returning raw text; if none of those are present,
allow the JSON/YAML parsing branches (JSON.parse and jsyaml.load) to run so
valid JSON served with an XML MIME still gets parsed; update convertFileToObject
accordingly and leave processFile behavior unchanged.
🧹 Nitpick comments (4)
packages/bruno-electron/src/ipc/collection.js (2)
2138-2147: Consider reusinggenerateUniqueNameutility.The counter-based dedup loop works, but the codebase already has
generateUniqueName(imported at line 50, used elsewhere e.g. line 534). Using it here would reduce duplication.♻️ Suggested refactor
let sanitizedName = sanitizeName(collectionName); if (!sanitizedName) { sanitizedName = `untitled-${Date.now()}`; } - let finalCollectionPath = path.join(collectionLocation, sanitizedName); - let counter = 1; - while (fs.existsSync(finalCollectionPath)) { - finalCollectionPath = path.join(collectionLocation, `${sanitizedName} (${counter})`); - counter++; - } + const uniqueName = generateUniqueName(sanitizedName, (name) => fs.existsSync(path.join(collectionLocation, name))); + let finalCollectionPath = path.join(collectionLocation, uniqueName);
2159-2162: Redundant outer try-catch.The outer
try/catch(lines 2056, 2159-2161) only re-throws. All meaningful error handling (temp dir cleanup) is in the inner block. The outer wrapper can be removed to reduce nesting.♻️ Suggested simplification
ipcMain.handle('renderer:import-collection-zip', async (event, zipFilePath, collectionLocation) => { - try { if (!fs.existsSync(zipFilePath)) { throw new Error('ZIP file does not exist'); } if (!collectionLocation || !fs.existsSync(collectionLocation)) { throw new Error('Collection location does not exist'); } const tempDir = path.join(os.tmpdir(), `bruno_zip_import_${Date.now()}`); await fsExtra.ensureDir(tempDir); // ... rest of inner try/catch stays as-is ... - } catch (error) { - throw error; - } });packages/bruno-app/src/components/Sidebar/ImportCollection/FileTab.js (2)
172-215: JSX rendering is clean.One small observation: the drag-active styling uses hardcoded Tailwind
dark:classes (line 182) while also pullinguseTheme()for the link color (line 204). This is a minor inconsistency in theming approach, but not blocking.
36-40: Consider adding brief JSDoc to the exported component and key helper functions.
FileTab,processFile,processFiles, andconvertFileToObjectare non-trivial abstractions. A one-liner JSDoc describing purpose and parameters would help future maintainers. As per coding guidelines: "Add JSDoc comments to abstractions for additional details."Also applies to: 88-88, 123-123
| const convertFileToObject = async (file) => { | ||
| const text = await file.text(); | ||
|
|
||
| // Handle WSDL files - return as plain text | ||
| if (file.name.endsWith('.wsdl') || file.type === 'text/xml' || file.type === 'application/xml') { | ||
| return text; | ||
| } | ||
|
|
||
| try { | ||
| if (file.type === 'application/json' || file.name.endsWith('.json')) { | ||
| return JSON.parse(text); | ||
| } | ||
|
|
||
| const parsed = jsyaml.load(text); | ||
| if (typeof parsed !== 'object' || parsed === null) { | ||
| throw new Error(); | ||
| } | ||
| return parsed; | ||
| } catch { | ||
| throw new Error('Failed to parse the file – ensure it is valid JSON or YAML'); | ||
| } | ||
| }; |
There was a problem hiding this comment.
MIME-type matching for WSDL is overly broad.
Line 17 catches text/xml and application/xml, meaning any XML file (not just WSDL) gets returned as raw text. This won't cause a crash—processFile will correctly reject it as unsupported at line 112—but the error message the user sees will be "Unsupported collection format" instead of a more helpful "Failed to parse the file". It's a minor UX gap.
Also, consider what happens when a valid JSON file is served with an XML MIME type (rare, but possible in browser drag-and-drop): the XML branch short-circuits and the JSON is never parsed.
🤖 Prompt for AI Agents
In `@packages/bruno-app/src/components/Sidebar/ImportCollection/FileTab.js` around
lines 13 - 34, The current convertFileToObject function treats any XML MIME as
WSDL which short-circuits JSON/YAML parsing; change the WSDL detection to be
specific: keep filename check (file.name.endsWith('.wsdl')) and accept the
official MIME 'application/wsdl+xml', and otherwise detect WSDL by inspecting
the file text for WSDL-specific root elements (e.g. '<definitions' or
'wsdl:definitions') before returning raw text; if none of those are present,
allow the JSON/YAML parsing branches (JSON.parse and jsyaml.load) to run so
valid JSON served with an XML MIME still gets parsed; update convertFileToObject
accordingly and leave processFile behavior unchanged.
| const processZipFile = async (zipFile) => { | ||
| setIsLoading(true); | ||
| try { | ||
| const filePath = window.ipcRenderer.getFilePath(zipFile); | ||
| const collectionName = zipFile.name.replace(/\.zip$/i, ''); | ||
| await handleSubmit({ rawData: { zipFilePath: filePath, collectionName }, type: 'bruno-zip' }); | ||
| } catch (err) { | ||
| toastError(err, 'Import ZIP file failed'); | ||
| } finally { | ||
| setIsLoading(false); | ||
| } | ||
| }; |
There was a problem hiding this comment.
Edge case: a file literally named .zip produces an empty collectionName.
Line 79's regex replacement yields '' for a file named .zip. Downstream code likely uses this as a directory name, which could cause unexpected behavior or failures.
Proposed guard
const processZipFile = async (zipFile) => {
setIsLoading(true);
try {
const filePath = window.ipcRenderer.getFilePath(zipFile);
const collectionName = zipFile.name.replace(/\.zip$/i, '');
+ if (!collectionName) {
+ throw new Error('ZIP file name is invalid');
+ }
await handleSubmit({ rawData: { zipFilePath: filePath, collectionName }, type: 'bruno-zip' });🤖 Prompt for AI Agents
In `@packages/bruno-app/src/components/Sidebar/ImportCollection/FileTab.js` around
lines 75 - 86, processZipFile can produce an empty collectionName for a file
literally named ".zip"; after computing collectionName from zipFile.name in
processZipFile, add a guard: if collectionName is empty, derive a safe fallback
(for example use the file basename from filePath without extension, or
zipFile.name if non-empty, or a deterministic default like
"imported-zip-<timestamp>"). Update the logic inside processZipFile (the
collectionName assignment and immediately before calling handleSubmit) to ensure
collectionName is never an empty string.
* feat: add ZIP file import for collections
Description
Contribution Checklist:
Note: Keeping the PR small and focused helps make it easier to review and merge. If you have multiple changes you want to make, please consider submitting them as separate pull requests.
Publishing to New Package Managers
Please see here for more information.
Summary by CodeRabbit
New Features
UX Improvements