add: change log tab (#8289)

This commit is contained in:
naman-bruno
2026-06-19 15:10:20 +05:30
committed by GitHub
parent 6711ccdda2
commit 82ee8e1331
17 changed files with 337 additions and 8 deletions

View File

@@ -38,6 +38,9 @@ export default defineConfig({
dynamicImportMode: "eager",
},
},
rules: [
{ test: /\.md$/, type: 'asset/source' }
]
},
ignoreWarnings: [
(warning) => warning.message.includes('Critical dependency: the request of a dependency is an expression') && warning?.moduleDescriptor?.name?.includes('flow-parser')

View File

@@ -0,0 +1,7 @@
# What's New in Bruno
- Various stability and performance improvements.
---
For the full release history, see the [Bruno releases page](https://github.com/usebruno/bruno/releases).

View File

@@ -0,0 +1,31 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
.changelog-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 1rem 1.5rem;
border-bottom: 1px solid ${(props) => props.theme.requestTabs?.border || props.theme.sidebar?.border || 'transparent'};
color: ${(props) => props.theme.text};
.header-version {
font-size: ${(props) => props.theme.font?.size?.sm || '0.85em'};
color: ${(props) => props.theme.colors?.text?.muted || props.theme.text};
opacity: 0.7;
}
}
.changelog-body {
flex: 1;
overflow-y: auto;
padding: 1rem 1.5rem 2rem 1.5rem;
}
`;
export default StyledWrapper;

View File

@@ -0,0 +1,23 @@
import React from 'react';
import { IconConfetti } from '@tabler/icons';
import Markdown from 'components/MarkDown';
import { version } from '../../../package.json';
import changelogContent from './CHANGELOG.md';
import StyledWrapper from './StyledWrapper';
const ChangelogTab = () => {
return (
<StyledWrapper>
<div className="changelog-header">
<IconConfetti size={18} strokeWidth={1.5} />
<span>What's New</span>
<span className="header-version">v{version}</span>
</div>
<div className="changelog-body">
<Markdown content={changelogContent} onDoubleClick={() => {}} />
</div>
</StyledWrapper>
);
};
export default ChangelogTab;

View File

@@ -43,6 +43,7 @@ import EnvironmentSettings from 'components/Environments/EnvironmentSettings';
import GlobalEnvironmentSettings from 'components/Environments/GlobalEnvironmentSettings';
import OpenAPISyncTab from 'components/OpenAPISyncTab';
import OpenAPISpecTab from 'components/OpenAPISpecTab';
import ChangelogTab from 'components/ChangelogTab';
import CollapsedPanelIndicator from './CollapsedPanelIndicator';
import { clampRequestHeightForResponse } from './paneSize';
import { IconLoader2 } from '@tabler/icons';
@@ -335,6 +336,10 @@ const RequestTabPanel = () => {
return <Preferences />;
}
if (focusedTab.type === 'changelog') {
return <ChangelogTab />;
}
if (focusedTab.type === 'workspaceOverview') {
return activeWorkspace ? <WorkspaceOverview workspace={activeWorkspace} /> : null;
}

View File

@@ -1,6 +1,6 @@
import React from 'react';
import GradientCloseButton from './GradientCloseButton';
import { IconVariable, IconSettings, IconRun, IconFolder, IconDatabase, IconWorld, IconHome, IconFileCode } from '@tabler/icons';
import { IconVariable, IconSettings, IconRun, IconFolder, IconDatabase, IconWorld, IconHome, IconFileCode, IconConfetti } from '@tabler/icons';
import OpenAPISyncIcon from 'components/Icons/OpenAPISync';
const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDraft }) => {
@@ -102,6 +102,14 @@ const SpecialTab = ({ handleCloseClick, type, tabName, handleDoubleClick, hasDra
</>
);
}
case 'changelog': {
return (
<>
<IconConfetti size={14} strokeWidth={1.5} className="special-tab-icon flex-shrink-0" />
<span className="ml-1 tab-name">What's New</span>
</>
);
}
}
};

View File

@@ -193,7 +193,8 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi
'workspaceOverview',
'workspaceEnvironments',
'openapi-sync',
'openapi-spec'
'openapi-spec',
'changelog'
];
const hasDraft = tab.type === 'collection-settings' && collection?.draft;

View File

@@ -7,6 +7,7 @@ import useIpcEvents from './useIpcEvents';
import useTelemetry from './useTelemetry';
import StyledWrapper from './StyledWrapper';
import useOpenAPISyncPolling from './useOpenAPISyncPolling';
import useChangelogOnUpdate from './useChangelogOnUpdate';
import { version } from '../../../package.json';
export const AppContext = React.createContext();
@@ -15,6 +16,7 @@ export const AppProvider = (props) => {
useTelemetry({ version });
useIpcEvents();
useOpenAPISyncPolling();
useChangelogOnUpdate();
const dispatch = useDispatch();
useEffect(() => {

View File

@@ -0,0 +1,58 @@
import { useEffect, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import semver from 'semver';
import { addTab } from 'providers/ReduxStore/slices/tabs';
import { savePreferences } from 'providers/ReduxStore/slices/app';
import { version as currentVersion } from '../../../package.json';
const useChangelogOnUpdate = () => {
const dispatch = useDispatch();
const preferences = useSelector((state) => state.app.preferences);
const snapshotReady = useSelector((state) => state.app.snapshotReady);
const activeWorkspace = useSelector((state) => {
const { workspaces, activeWorkspaceUid } = state.workspaces;
return workspaces.find((w) => w.uid === activeWorkspaceUid);
});
const activeTabCollectionUid = useSelector((state) => {
const activeTab = state.tabs.tabs.find((t) => t.uid === state.tabs.activeTabUid);
return activeTab?.collectionUid;
});
const hasRunRef = useRef(false);
useEffect(() => {
if (hasRunRef.current) return;
// hasLaunchedBefore is set by electron-side onboarding before the renderer
// receives preferences via main:load-preferences. Until that flips true,
// we're still on the renderer's default state and shouldn't act yet.
const hasLaunchedBefore = preferences?.onboarding?.hasLaunchedBefore;
if (!hasLaunchedBefore) return;
// Wait until snapshot hydration finishes, otherwise the workspace's
// overview/restored tabs are added after ours and steal active focus.
if (!snapshotReady) return;
// Need a collection context to dock the tab onto an existing tab strip.
const collectionUid = activeTabCollectionUid || activeWorkspace?.scratchCollectionUid;
if (!collectionUid) return;
hasRunRef.current = true;
const onboarding = preferences.onboarding || {};
const { lastSeenVersion } = onboarding;
if (lastSeenVersion && semver.valid(lastSeenVersion) && semver.gte(lastSeenVersion, currentVersion)) return;
dispatch(addTab({
type: 'changelog',
uid: `${collectionUid}-changelog`,
collectionUid
}));
dispatch(savePreferences({
...preferences,
onboarding: { ...onboarding, lastSeenVersion: currentVersion }
})).catch(() => {});
}, [preferences, snapshotReady, activeWorkspace, activeTabCollectionUid, dispatch]);
};
export default useChangelogOnUpdate;

View File

@@ -49,7 +49,8 @@ const initialState = {
},
onboarding: {
hasLaunchedBefore: false,
hasSeenWelcomeModal: true
hasSeenWelcomeModal: true,
lastSeenVersion: null
},
autoSave: {
enabled: false,

View File

@@ -62,7 +62,8 @@ export const tabsSlice = createSlice({
'workspaceOverview',
'workspaceEnvironments',
'openapi-sync',
'openapi-spec'
'openapi-spec',
'changelog'
];
const existingTab = find(state.tabs, (tab) => tab.uid === uid);

View File

@@ -101,12 +101,16 @@ async function onboardUser(mainWindow, lastOpenedCollections) {
pendingSampleCollection = { mainWindow, ...collectionInfo };
}
// Mark as launched and explicitly enable the welcome modal for new users
// Mark as launched and explicitly enable the welcome modal for new users.
// lastSeenVersion is set here (not in the renderer) so it lands in the same
// write as hasSeenWelcomeModal, avoids a race with the welcome-modal
// dismissal save. New users only see future changelogs, not the current one.
const preferences = getPreferences();
preferences.onboarding = {
...preferences.onboarding,
hasLaunchedBefore: true,
hasSeenWelcomeModal: false
hasSeenWelcomeModal: false,
lastSeenVersion: app.getVersion()
};
await savePreferences(preferences);
} catch (error) {

View File

@@ -51,7 +51,8 @@ const defaultPreferences = {
},
onboarding: {
hasLaunchedBefore: false,
hasSeenWelcomeModal: true
hasSeenWelcomeModal: true,
lastSeenVersion: null
},
general: {
defaultLocation: '',
@@ -127,7 +128,8 @@ const preferencesSchema = Yup.object().shape({
}),
onboarding: Yup.object({
hasLaunchedBefore: Yup.boolean(),
hasSeenWelcomeModal: Yup.boolean()
hasSeenWelcomeModal: Yup.boolean(),
lastSeenVersion: Yup.string().nullable()
}),
general: Yup.object({
defaultLocation: Yup.string().max(1024).nullable(),

View File

@@ -0,0 +1,152 @@
import path from 'path';
import fs from 'fs';
import { ElectronApplication } from '@playwright/test';
import { test, expect, closeElectronApp } from '../../playwright';
import { waitForReadyPage } from '../utils/page';
import { buildCommonLocators } from '../utils/page/locators';
const initUserDataFresh = path.join(__dirname, 'init-user-data-fresh');
const initUserDataExisting = path.join(__dirname, 'init-user-data-existing');
const initUserDataCurrent = path.join(__dirname, 'init-user-data-current');
// app.getVersion() reads from bruno-electron's package.json — match that here
const currentVersion = require('../../packages/bruno-electron/package.json').version;
test.describe('Changelog ("What\'s New") Tab', () => {
test('should NOT show the changelog tab to brand-new users', async ({ launchElectronApp }) => {
let app: ElectronApplication | undefined;
try {
app = await launchElectronApp({ initUserDataPath: initUserDataFresh });
const page = await waitForReadyPage(app);
const locators = buildCommonLocators(page);
// New users see the welcome modal — that's the established flow.
await expect(page.getByTestId('welcome-modal')).toBeVisible();
// The changelog tab must not appear alongside it.
await expect(locators.tabs.requestTab('What\'s New')).toHaveCount(0);
} finally {
if (app) {
await closeElectronApp(app);
}
}
});
test('should show the changelog tab for existing users upgrading to a new version', async ({ launchElectronApp }) => {
let app: ElectronApplication | undefined;
try {
app = await launchElectronApp({ initUserDataPath: initUserDataExisting });
const page = await waitForReadyPage(app);
const locators = buildCommonLocators(page);
// Tab appears in the active workspace's tab strip and becomes active.
await expect(locators.tabs.requestTab('What\'s New')).toHaveCount(1, { timeout: 15000 });
await expect(locators.tabs.activeRequestTab()).toContainText('What\'s New');
// Welcome modal must NOT show — this user already onboarded.
await expect(page.getByTestId('welcome-modal')).not.toBeVisible();
// Content sanity: header + a piece of the bundled markdown.
await expect(page.getByText('What\'s New in Bruno')).toBeVisible();
} finally {
if (app) {
await closeElectronApp(app);
}
}
});
test('should NOT show the changelog tab when lastSeenVersion matches the current version', async ({ launchElectronApp }) => {
let app: ElectronApplication | undefined;
try {
app = await launchElectronApp({
initUserDataPath: initUserDataCurrent,
templateVars: { currentVersion }
});
const page = await waitForReadyPage(app);
const locators = buildCommonLocators(page);
// Give the hook time to run — snapshotReady + activeWorkspace must both settle.
await page.waitForTimeout(2000);
await expect(locators.tabs.requestTab('What\'s New')).toHaveCount(0);
} finally {
if (app) {
await closeElectronApp(app);
}
}
});
test('should persist lastSeenVersion on open and not re-open on next launch', async ({ launchElectronApp, createTmpDir }) => {
const userDataPath = await createTmpDir('changelog-persist');
let app: ElectronApplication | undefined;
try {
app = await launchElectronApp({ userDataPath, initUserDataPath: initUserDataExisting });
let page = await waitForReadyPage(app);
let locators = buildCommonLocators(page);
await expect(locators.tabs.requestTab('What\'s New')).toHaveCount(1, { timeout: 15000 });
// The hook saves lastSeenVersion right after addTab. Poll the prefs file
// until the write lands — electron-store writes synchronously, but the
// dispatch chain is async.
await expect.poll(
async () => {
try {
const prefs = JSON.parse(await fs.promises.readFile(path.join(userDataPath, 'preferences.json'), 'utf8'));
return prefs.preferences?.onboarding?.lastSeenVersion;
} catch {
return null;
}
},
{ timeout: 10000 }
).toBe(currentVersion);
await closeElectronApp(app);
app = undefined;
// Restart against the same user data — tab must NOT reappear for this version.
app = await launchElectronApp({ userDataPath });
page = await waitForReadyPage(app);
locators = buildCommonLocators(page);
// Settle window for snapshot hydration + hook evaluation.
await page.waitForTimeout(2000);
await expect(locators.tabs.requestTab('What\'s New')).toHaveCount(0);
} finally {
if (app) {
await closeElectronApp(app);
}
}
});
test('should close the changelog tab when the user closes it', async ({ launchElectronApp }) => {
let app: ElectronApplication | undefined;
try {
app = await launchElectronApp({ initUserDataPath: initUserDataExisting });
const page = await waitForReadyPage(app);
const locators = buildCommonLocators(page);
const changelogTab = locators.tabs.requestTab('What\'s New');
await expect(changelogTab).toHaveCount(1, { timeout: 15000 });
// Hover to reveal the close button, then click it.
await changelogTab.hover();
await page.locator('.request-tab')
.filter({ hasText: 'What\'s New' })
.getByTestId('request-tab-close-icon')
.click();
await expect(changelogTab).toHaveCount(0);
} finally {
if (app) {
await closeElectronApp(app);
}
}
});
});

View File

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

View File

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

View File

@@ -0,0 +1,8 @@
{
"preferences": {
"onboarding": {
"hasLaunchedBefore": false,
"hasSeenWelcomeModal": false
}
}
}