diff --git a/packages/bruno-app/rsbuild.config.mjs b/packages/bruno-app/rsbuild.config.mjs index f21f80666..bebd2680d 100644 --- a/packages/bruno-app/rsbuild.config.mjs +++ b/packages/bruno-app/rsbuild.config.mjs @@ -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') diff --git a/packages/bruno-app/src/components/ChangelogTab/CHANGELOG.md b/packages/bruno-app/src/components/ChangelogTab/CHANGELOG.md new file mode 100644 index 000000000..f667d7172 --- /dev/null +++ b/packages/bruno-app/src/components/ChangelogTab/CHANGELOG.md @@ -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). diff --git a/packages/bruno-app/src/components/ChangelogTab/StyledWrapper.js b/packages/bruno-app/src/components/ChangelogTab/StyledWrapper.js new file mode 100644 index 000000000..38c25d23b --- /dev/null +++ b/packages/bruno-app/src/components/ChangelogTab/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/components/ChangelogTab/index.js b/packages/bruno-app/src/components/ChangelogTab/index.js new file mode 100644 index 000000000..80e3b33d7 --- /dev/null +++ b/packages/bruno-app/src/components/ChangelogTab/index.js @@ -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 ( + +
+ + What's New + v{version} +
+
+ {}} /> +
+
+ ); +}; + +export default ChangelogTab; diff --git a/packages/bruno-app/src/components/RequestTabPanel/index.js b/packages/bruno-app/src/components/RequestTabPanel/index.js index 81683b86e..50af42aaa 100644 --- a/packages/bruno-app/src/components/RequestTabPanel/index.js +++ b/packages/bruno-app/src/components/RequestTabPanel/index.js @@ -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 ; } + if (focusedTab.type === 'changelog') { + return ; + } + if (focusedTab.type === 'workspaceOverview') { return activeWorkspace ? : null; } diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/SpecialTab.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/SpecialTab.js index 9081211fb..85dec2b5b 100644 --- a/packages/bruno-app/src/components/RequestTabs/RequestTab/SpecialTab.js +++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/SpecialTab.js @@ -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 ( + <> + + What's New + + ); + } } }; diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js index e0dfc31bb..e79066250 100644 --- a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js +++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js @@ -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; diff --git a/packages/bruno-app/src/providers/App/index.js b/packages/bruno-app/src/providers/App/index.js index 4fbb7a5e4..3d08648f4 100644 --- a/packages/bruno-app/src/providers/App/index.js +++ b/packages/bruno-app/src/providers/App/index.js @@ -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(() => { diff --git a/packages/bruno-app/src/providers/App/useChangelogOnUpdate.js b/packages/bruno-app/src/providers/App/useChangelogOnUpdate.js new file mode 100644 index 000000000..2848dbd20 --- /dev/null +++ b/packages/bruno-app/src/providers/App/useChangelogOnUpdate.js @@ -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; diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/app.js b/packages/bruno-app/src/providers/ReduxStore/slices/app.js index 7f7f0f739..b883f5049 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/app.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/app.js @@ -49,7 +49,8 @@ const initialState = { }, onboarding: { hasLaunchedBefore: false, - hasSeenWelcomeModal: true + hasSeenWelcomeModal: true, + lastSeenVersion: null }, autoSave: { enabled: false, diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js index e362e4dbe..c6965e6be 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/tabs.js @@ -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); diff --git a/packages/bruno-electron/src/app/onboarding.js b/packages/bruno-electron/src/app/onboarding.js index 43067df37..1cf50aadc 100644 --- a/packages/bruno-electron/src/app/onboarding.js +++ b/packages/bruno-electron/src/app/onboarding.js @@ -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) { diff --git a/packages/bruno-electron/src/store/preferences.js b/packages/bruno-electron/src/store/preferences.js index 260093023..235243629 100644 --- a/packages/bruno-electron/src/store/preferences.js +++ b/packages/bruno-electron/src/store/preferences.js @@ -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(), diff --git a/tests/changelog/changelog-tab.spec.ts b/tests/changelog/changelog-tab.spec.ts new file mode 100644 index 000000000..069cdb170 --- /dev/null +++ b/tests/changelog/changelog-tab.spec.ts @@ -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); + } + } + }); +}); diff --git a/tests/changelog/init-user-data-current/preferences.json b/tests/changelog/init-user-data-current/preferences.json new file mode 100644 index 000000000..7c5deebf3 --- /dev/null +++ b/tests/changelog/init-user-data-current/preferences.json @@ -0,0 +1,12 @@ +{ + "lastOpenedCollections": [ + "{{projectRoot}}/packages/bruno-tests/collection" + ], + "preferences": { + "onboarding": { + "hasLaunchedBefore": true, + "hasSeenWelcomeModal": true, + "lastSeenVersion": "{{currentVersion}}" + } + } +} diff --git a/tests/changelog/init-user-data-existing/preferences.json b/tests/changelog/init-user-data-existing/preferences.json new file mode 100644 index 000000000..daca1aaad --- /dev/null +++ b/tests/changelog/init-user-data-existing/preferences.json @@ -0,0 +1,11 @@ +{ + "lastOpenedCollections": [ + "{{projectRoot}}/packages/bruno-tests/collection" + ], + "preferences": { + "onboarding": { + "hasLaunchedBefore": true, + "hasSeenWelcomeModal": true + } + } +} diff --git a/tests/changelog/init-user-data-fresh/preferences.json b/tests/changelog/init-user-data-fresh/preferences.json new file mode 100644 index 000000000..c26448fa8 --- /dev/null +++ b/tests/changelog/init-user-data-fresh/preferences.json @@ -0,0 +1,8 @@ +{ + "preferences": { + "onboarding": { + "hasLaunchedBefore": false, + "hasSeenWelcomeModal": false + } + } +}