feat: Add default sample collection on first app launch (#5536)

* feat: Add Default Sample Collection On First Launch
* feat(initial-load): add  attribute for app readiness

---------

Co-authored-by: Bijin Bruno <bijin@usebruno.com>
This commit is contained in:
Sanjai Kumar
2025-09-15 15:00:39 +05:30
committed by GitHub
parent 9fc885839f
commit 5393e3b496
9 changed files with 464 additions and 6 deletions

View File

@@ -7,6 +7,7 @@ import Sidebar from 'components/Sidebar';
import StatusBar from 'components/StatusBar';
// import ErrorCapture from 'components/ErrorCapture';
import { useSelector } from 'react-redux';
import { isElectron } from 'utils/common/platform';
import StyledWrapper from './StyledWrapper';
import 'codemirror/theme/material.css';
import 'codemirror/theme/monokai.css';
@@ -56,12 +57,31 @@ export default function Main() {
'is-dragging': isDragging
});
useEffect(() => {
if (!isElectron()) {
return;
}
const { ipcRenderer } = window;
const removeAppLoadedListener = ipcRenderer.on('main:app-loaded', () => {
if (mainSectionRef.current) {
mainSectionRef.current.setAttribute('data-app-state', 'loaded');
}
});
return () => {
removeAppLoadedListener();
};
}, []);
return (
// <ErrorCapture>
<div className="flex flex-col h-screen max-h-screen overflow-hidden">
<div
<div id="main-container" className="flex flex-col h-screen max-h-screen overflow-hidden">
<div
ref={mainSectionRef}
className="flex-1 min-h-0 flex"
data-app-state="loading"
style={{
height: isConsoleOpen ? `calc(100vh - 22px - ${isConsoleOpen ? '300px' : '0px'})` : 'calc(100vh - 22px)'
}}
@@ -80,7 +100,7 @@ export default function Main() {
</section>
</StyledWrapper>
</div>
<Devtools mainSectionRef={mainSectionRef} />
<StatusBar />
</div>

View File

@@ -0,0 +1,97 @@
const fs = require('node:fs');
const path = require('node:path');
const { app } = require('electron');
const { preferencesUtil } = require('../store/preferences');
const { importCollection, findUniqueFolderName } = require('../utils/collection-import');
/**
* Get the default location for collections
* Tries documents first, then desktop, then userData as fallback
*/
function getDefaultCollectionLocation() {
const preferredPaths = ['documents', 'desktop', 'userData'];
for (const pathType of preferredPaths) {
try {
return app.getPath(pathType);
} catch (error) {
console.warn(`Failed to get ${pathType} path:`, error.message);
// Continue to next path
}
}
// This should never happen since userData should always be available
throw new Error('No valid collection location found');
}
/**
* Import sample collection for new users
*/
async function importSampleCollection(collectionLocation, mainWindow, lastOpenedCollections) {
// Handle both development and production paths
const sampleCollectionPath = app.isPackaged
? path.join(process.resourcesPath, 'sample-collection.json')
: path.join(app.getAppPath(), 'src/assets/sample-collection.json');
if (!fs.existsSync(sampleCollectionPath)) {
throw new Error(`Sample collection file not found at: ${sampleCollectionPath}`);
}
const sampleCollectionData = fs.readFileSync(sampleCollectionPath, 'utf8');
const sampleCollection = JSON.parse(sampleCollectionData);
const collectionName = await findUniqueFolderName('Sample API Collection', collectionLocation);
const collectionToImport = {
...sampleCollection,
name: collectionName
};
try {
const {
collectionPath: createdPath,
uid,
brunoConfig
} = await importCollection(
collectionToImport,
collectionLocation,
mainWindow,
lastOpenedCollections,
collectionName
);
return { collectionPath: createdPath, uid, brunoConfig };
} catch (error) {
console.error('Failed to import sample collection:', error);
throw error;
}
}
/**
* Onboard new users by creating a sample collection
*/
async function onboardUser(mainWindow, lastOpenedCollections) {
try {
if (preferencesUtil.hasLaunchedBefore()) {
return;
}
// Onboarding was added later;
// if a collection already exists, user is old → skip onboarding
const collections = await lastOpenedCollections.getAll();
if (collections.length > 0) {
preferencesUtil.markAsLaunched();
return;
}
const collectionLocation = getDefaultCollectionLocation();
await importSampleCollection(collectionLocation, mainWindow, lastOpenedCollections);
preferencesUtil.markAsLaunched();
} catch (error) {
console.error('Failed to handle onboarding:', error);
// Still mark as launched to prevent retry on next startup
preferencesUtil.markAsLaunched();
}
}
module.exports = onboardUser;

View File

@@ -0,0 +1,55 @@
{
"version": "1",
"uid": "1igyn4u00000000000232",
"name": "Sample API Collection",
"items": [
{
"uid": "1igyn4u00000000000001",
"type": "http-request",
"name": "Get Users",
"seq": 1,
"request": {
"url": "https://jsonplaceholder.typicode.com/users",
"method": "GET",
"headers": [],
"params": [],
"body": {
"mode": "none"
},
"auth": {
"mode": "none"
},
"script": {
"req": "",
"res": ""
},
"vars": {
"req": [],
"res": []
},
"assertions": [],
"tests": "",
"docs": "This request retrieves a list of users from the JSONPlaceholder API."
}
}
],
"environments": [],
"activeEnvironmentUid": null,
"root": {
"request": {
"headers": [],
"auth": {
"mode": "none"
},
"script": {
"req": "",
"res": ""
},
"vars": {
"req": [],
"res": []
},
"tests": ""
}
}
}

View File

@@ -35,6 +35,7 @@ const registerGlobalEnvironmentsIpc = require('./ipc/global-environments');
const { safeParseJSON, safeStringifyJSON } = require('./utils/common');
const { getDomainsWithCookies } = require('./utils/cookies');
const { cookiesStore } = require('./store/cookies');
const onboardUser = require('./app/onboarding');
const lastOpenedCollections = new LastOpenedCollections();
@@ -178,6 +179,10 @@ app.on('ready', async () => {
return safeParseJSON(safeStringifyJSON(_));
})]);
}
// Handle onboarding
await onboardUser(mainWindow, lastOpenedCollections);
// Send cookies list after renderer is ready
try {
cookiesStore.initializeCookies();
@@ -186,6 +191,8 @@ app.on('ready', async () => {
} catch (err) {
console.error('Failed to load cookies for renderer', err);
}
mainWindow.webContents.send('main:app-loaded');
});
// register all ipc handlers

View File

@@ -44,6 +44,9 @@ const defaultPreferences = {
beta: {
grpc: false,
nodevm: false
},
onboarding: {
hasLaunchedBefore: false
}
};
@@ -83,6 +86,9 @@ const preferencesSchema = Yup.object().shape({
beta: Yup.object({
grpc: Yup.boolean(),
nodevm: Yup.boolean()
}),
onboarding: Yup.object({
hasLaunchedBefore: Yup.boolean()
})
});
@@ -176,6 +182,14 @@ const preferencesUtil = {
},
isBetaFeatureEnabled: (featureName) => {
return get(getPreferences(), `beta.${featureName}`, false);
},
hasLaunchedBefore: () => {
return get(getPreferences(), 'onboarding.hasLaunchedBefore', false);
},
markAsLaunched: () => {
const preferences = getPreferences();
preferences.onboarding.hasLaunchedBefore = true;
preferencesStore.savePreferences(preferences);
}
};

View File

@@ -0,0 +1,128 @@
const fs = require('node:fs');
const path = require('node:path');
const { ipcMain } = require('electron');
const { sanitizeName, createDirectory, writeFile, safeWriteFileSync, getCollectionStats } = require('./filesystem');
const { generateUidBasedOnHash, stringifyJson } = require('./common');
const { stringifyRequestViaWorker, stringifyCollection, stringifyEnvironment, stringifyFolder } = require('@usebruno/filestore');
/**
* Recursively find a unique folder name by appending incremental numbers
*/
async function findUniqueFolderName(baseName, collectionLocation, counter = 0) {
const folderName = counter === 0 ? baseName : `${baseName} - ${counter}`;
const collectionPath = path.join(collectionLocation, sanitizeName(folderName));
if (fs.existsSync(collectionPath)) {
return findUniqueFolderName(baseName, collectionLocation, counter + 1);
}
return folderName;
}
/**
* Import a collection - shared logic used by both IPC handler and onboarding service
*/
async function importCollection(collection, collectionLocation, mainWindow, lastOpenedCollections, uniqueFolderName = null) {
// Use provided unique folder name or use collection name
let folderName = uniqueFolderName ? sanitizeName(uniqueFolderName) : sanitizeName(collection.name);
let collectionPath = path.join(collectionLocation, folderName);
if (fs.existsSync(collectionPath)) {
throw new Error(`collection: ${collectionPath} already exists`);
}
// Recursive function to parse the collection items and create files/folders
const parseCollectionItems = async (items = [], currentPath) => {
for (const item of items) {
if (['http-request', 'graphql-request', 'grpc-request'].includes(item.type)) {
let sanitizedFilename = sanitizeName(item.filename || `${item.name}.bru`);
const content = await stringifyRequestViaWorker(item);
const filePath = path.join(currentPath, sanitizedFilename);
safeWriteFileSync(filePath, content);
}
if (item.type === 'folder') {
let sanitizedFolderName = sanitizeName(item.filename || item.name);
const folderPath = path.join(currentPath, sanitizedFolderName);
fs.mkdirSync(folderPath);
if (item.root?.meta?.name) {
const folderBruFilePath = path.join(folderPath, 'folder.bru');
item.root.meta.seq = item.seq;
const folderContent = await stringifyFolder(item.root);
safeWriteFileSync(folderBruFilePath, folderContent);
}
if (item.items && item.items.length) {
await parseCollectionItems(item.items, folderPath);
}
}
// Handle items of type 'js'
if (item.type === 'js') {
let sanitizedFilename = sanitizeName(item.filename || `${item.name}.js`);
const filePath = path.join(currentPath, sanitizedFilename);
safeWriteFileSync(filePath, item.fileContent);
}
}
};
const parseEnvironments = async (environments = [], collectionPath) => {
const envDirPath = path.join(collectionPath, 'environments');
if (!fs.existsSync(envDirPath)) {
fs.mkdirSync(envDirPath);
}
for (const env of environments) {
const content = await stringifyEnvironment(env);
let sanitizedEnvFilename = sanitizeName(`${env.name}.bru`);
const filePath = path.join(envDirPath, sanitizedEnvFilename);
safeWriteFileSync(filePath, content);
}
};
const getBrunoJsonConfig = (collection) => {
let brunoConfig = collection.brunoConfig;
if (!brunoConfig) {
brunoConfig = {
version: '1',
name: collection.name,
type: 'collection',
ignore: ['node_modules', '.git']
};
}
return brunoConfig;
};
await createDirectory(collectionPath);
const uid = generateUidBasedOnHash(collectionPath);
let brunoConfig = getBrunoJsonConfig(collection);
const stringifiedBrunoConfig = await stringifyJson(brunoConfig);
// Write the Bruno configuration to a file
await writeFile(path.join(collectionPath, 'bruno.json'), stringifiedBrunoConfig);
const collectionContent = await stringifyCollection(collection.root);
await writeFile(path.join(collectionPath, 'collection.bru'), collectionContent);
const { size, filesCount } = await getCollectionStats(collectionPath);
brunoConfig.size = size;
brunoConfig.filesCount = filesCount;
mainWindow.webContents.send('main:collection-opened', collectionPath, uid, brunoConfig);
ipcMain.emit('main:collection-opened', mainWindow, collectionPath, uid, brunoConfig);
lastOpenedCollections.add(collectionPath);
// create folder and files based on collection
await parseCollectionItems(collection.items, collectionPath);
await parseEnvironments(collection.environments, collectionPath);
return { collectionPath, uid, brunoConfig };
}
module.exports = {
importCollection,
findUniqueFolderName
};

View File

@@ -16,9 +16,10 @@ test.describe('Cross-Collection Drag and Drop for folder', () => {
await page.getByRole('button', { name: 'Save' }).click();
// Create a folder in the first collection
// Look for the collection menu button (usually three dots or similar)
await page.locator('.collection-actions').hover();
await page.locator('.collection-actions .icon').click();
// Look for the collection menu button for the source collection specifically
const sourceCollectionContainer1 = page.locator('.collection-name').filter({ hasText: 'source-collection' });
await sourceCollectionContainer1.locator('.collection-actions').hover();
await sourceCollectionContainer1.locator('.collection-actions .icon').click();
await page.locator('.dropdown-item').filter({ hasText: 'New Folder' }).click();
// Fill folder name in the modal

View File

@@ -0,0 +1,10 @@
{
"lastOpenedCollections": [
"{{projectRoot}}/packages/bruno-tests/collection"
],
"preferences": {
"onboarding": {
"hasLaunchedBefore": true
}
}
}

View File

@@ -0,0 +1,126 @@
import { test, expect, errors } from '../../playwright';
test.describe('Onboarding', () => {
test('should create sample collection on first launch', async ({ launchElectronApp, createTmpDir }) => {
// Use a fresh app instance to avoid contamination from previous tests
const userDataPath = await createTmpDir('onboarding-fresh');
const app = await launchElectronApp({ userDataPath });
const page = await app.firstWindow();
// Verify sample collection appears in sidebar
const sampleCollection = page.locator('#sidebar-collection-name').getByText('Sample API Collection');
await expect(sampleCollection).toBeVisible();
// Click on the sample collection to open it
await sampleCollection.click();
const modeSaveButton = page.getByRole('button', { name: 'Save' });
await expect(modeSaveButton).toBeVisible();
await modeSaveButton.click();
// Verify the sample request is visible and clickable
const request = page.locator('.collection-item-name').getByText('Get Users');
await expect(request).toBeVisible();
await request.click();
// Verify the URL is set correctly
await expect(page.locator('#request-url')).toContainText('https://jsonplaceholder.typicode.com/users');
// Clean up
await app.close();
});
test('should not create duplicate collections on subsequent launches', async ({ launchElectronApp, createTmpDir }) => {
// Use a fresh app instance to avoid contamination from previous tests
const userDataPath = await createTmpDir('duplicate-collections');
const app = await launchElectronApp({ userDataPath });
const page = await app.firstWindow();
// First launch - verify sample collection is created
const sampleCollection = page.locator('#sidebar-collection-name').getByText('Sample API Collection');
await expect(sampleCollection).toBeVisible();
await sampleCollection.click();
const modeSaveButton = page.getByRole('button', { name: 'Save' });
await expect(modeSaveButton).toBeVisible();
await modeSaveButton.click();
// Verify the sample request
const request = page.locator('.collection-item-name').getByText('Get Users');
await expect(request).toBeVisible();
await request.click();
// Verify the URL is set correctly
await expect(page.locator('#request-url')).toContainText('https://jsonplaceholder.typicode.com/users');
// Close the first app instance
await app.close();
// Restart app - should not create sample collection again
const newApp = await launchElectronApp({ userDataPath });
const newPage = await newApp.firstWindow();
// Verify only one sample collection exists
const sampleCollections = newPage.locator('#sidebar-collection-name').getByText('Sample API Collection');
await expect(sampleCollections).toHaveCount(1);
// Verify the collection still works after restart
await sampleCollections.click();
const request2 = newPage.locator('.collection-item-name').getByText('Get Users');
await expect(request2).toBeVisible();
await request2.click();
// Verify the URL is still correct after restart
await expect(newPage.locator('#request-url')).toContainText('https://jsonplaceholder.typicode.com/users');
// Clean up
await newApp.close();
});
test('should not recreate sample collection after user deletes it', async ({ launchElectronApp, reuseOrLaunchElectronApp, createTmpDir }) => {
const userDataPath = await createTmpDir('first-launch');
const app = await launchElectronApp({ userDataPath });
const page = await app.firstWindow();
// First launch - sample collection should be created
const sampleCollection = page.locator('#sidebar-collection-name').getByText('Sample API Collection');
await expect(sampleCollection).toBeVisible();
// User closes the sample collection (right-click to open context menu)
await sampleCollection.click({ button: 'right' });
// Close the sample collection
const closeOption = page.locator('.dropdown-item').getByText('Close');
await expect(closeOption).toBeVisible();
await closeOption.click();
// Handle the confirmation dialog - click the 'Close' button to confirm
const confirmCloseButton = page.getByRole('button', { name: 'Close' });
await expect(confirmCloseButton).toBeVisible();
await confirmCloseButton.click();
// Verify collection is closed (no longer visible in sidebar)
await expect(sampleCollection).not.toBeVisible();
// Restart app - sample collection should NOT be recreated
const newApp = await reuseOrLaunchElectronApp({ userDataPath });
const newPage = await newApp.firstWindow();
// Wait for the app to be loaded / onboarding to be completed
await newPage.locator('[data-app-state="loaded"]').waitFor();
// Sample collection should not appear since it's no longer first launch
const sampleCollections = newPage.locator('#sidebar-collection-name').getByText('Sample API Collection');
await expect(sampleCollections).not.toBeVisible();
});
test('should not create sample collection if user has already opened a collection', async ({ pageWithUserData: page }) => {
// Wait for the app to be loaded / onboarding to be completed
await page.locator('[data-app-state="loaded"]').waitFor();
// This test simulates old users who already have a collection opened
const brunoTestbench = page.locator('#sidebar-collection-name').getByText('bruno-testbench');
await expect(brunoTestbench).toBeVisible();
const sampleCollection = page.locator('#sidebar-collection-name').getByText('Sample API Collection');
await expect(sampleCollection).not.toBeVisible();
});
});