diff --git a/packages/bruno-app/src/components/AppPreviewKeepAlive/index.js b/packages/bruno-app/src/components/AppPreviewKeepAlive/index.js index a9eed2761..4e17fe839 100644 --- a/packages/bruno-app/src/components/AppPreviewKeepAlive/index.js +++ b/packages/bruno-app/src/components/AppPreviewKeepAlive/index.js @@ -43,9 +43,11 @@ const AppPreviewKeepAlive = () => { continue; } - const appEnabled = item.draft ? get(item, 'draft.app.enabled', false) : get(item, 'app.enabled', false); + const itemSource = item.draft ? item.draft : item; + const appEnabled = get(itemSource, 'app.enabled', false) === true + && tab.appPreview !== false; if (appEnabled) { - const code = item.draft ? get(item, 'draft.app.code', '') : get(item, 'app.code', ''); + const code = get(itemSource, 'app.code', ''); out.push({ tabUid: tab.uid, collection, item, kind: 'request', code }); } } diff --git a/packages/bruno-app/src/components/AppPreviewKeepAlive/index.spec.js b/packages/bruno-app/src/components/AppPreviewKeepAlive/index.spec.js index cf4fc449a..a0586554e 100644 --- a/packages/bruno-app/src/components/AppPreviewKeepAlive/index.spec.js +++ b/packages/bruno-app/src/components/AppPreviewKeepAlive/index.spec.js @@ -100,6 +100,32 @@ describe('AppPreviewKeepAlive', () => { expect(hiddenSlot).toHaveAttribute('aria-hidden', 'true'); }); + it('does not mount request apps when the app is not enabled', () => { + const gatedItem = { + ...requestAppItem, + uid: 'item-gated', + app: { enabled: false, code: '
hi
' } + }; + const gatedCollection = { uid: 'coll-1', items: [gatedItem] }; + const { store } = makeStore({ + tabs: [tabFor(gatedItem)], + activeTabUid: gatedItem.uid, + collections: [gatedCollection] + }); + render(); + expect(screen.queryByTestId('mock-app-view')).not.toBeInTheDocument(); + }); + + it('does not mount request apps whose tab has preview explicitly off', () => { + const { store } = makeStore({ + tabs: [{ ...tabFor(requestAppItem), appPreview: false }], + activeTabUid: requestAppItem.uid, + collections: [collection] + }); + render(); + expect(screen.queryByTestId('mock-app-view')).not.toBeInTheDocument(); + }); + it('does not mount app tabs that were never activated', () => { const { store } = makeStore({ tabs: [tabFor(requestAppItem), tabFor(standaloneAppItem)], diff --git a/packages/bruno-app/src/components/AppView/index.js b/packages/bruno-app/src/components/AppView/index.js index 521e58283..121c8c5f0 100644 --- a/packages/bruno-app/src/components/AppView/index.js +++ b/packages/bruno-app/src/components/AppView/index.js @@ -10,10 +10,9 @@ import { import { responseReceived, appSetRuntimeVariable, - toggleAppMode, initRunRequestEvent } from 'providers/ReduxStore/slices/collections'; -import { updateRequestPaneTab } from 'providers/ReduxStore/slices/tabs'; +import { updateRequestPaneTab, setTabAppPreview } from 'providers/ReduxStore/slices/tabs'; import { uuid } from 'utils/common'; import { useTheme } from 'providers/Theme'; import Button from 'ui/Button'; @@ -286,13 +285,13 @@ const AppView = ({ item, collection, code }) => { }, [variables, pushToGuest]); const disableApp = useCallback(() => { - dispatch(toggleAppMode({ enabled: false, itemUid: item.uid, collectionUid: collection.uid })); - }, [dispatch, item.uid, collection.uid]); + dispatch(setTabAppPreview({ uid: item.uid, appPreview: false })); + }, [dispatch, item.uid]); const goToAppTab = useCallback(() => { dispatch(updateRequestPaneTab({ uid: item.uid, requestPaneTab: 'app' })); - dispatch(toggleAppMode({ enabled: false, itemUid: item.uid, collectionUid: collection.uid })); - }, [dispatch, item.uid, collection.uid]); + dispatch(setTabAppPreview({ uid: item.uid, appPreview: false })); + }, [dispatch, item.uid]); const openAppsDocs = useCallback(() => { window?.ipcRenderer?.openExternal('https://link.usebruno.com/apps'); diff --git a/packages/bruno-app/src/components/RequestPane/AppCodeEditor/StyledWrapper.js b/packages/bruno-app/src/components/RequestPane/AppCodeEditor/StyledWrapper.js index 59ff15653..440f8d1ef 100644 --- a/packages/bruno-app/src/components/RequestPane/AppCodeEditor/StyledWrapper.js +++ b/packages/bruno-app/src/components/RequestPane/AppCodeEditor/StyledWrapper.js @@ -1,7 +1,7 @@ import styled from 'styled-components'; const StyledWrapper = styled.div` - .app-toggle-row { + .app-toolbar { border-bottom: 1px solid ${(props) => props.theme.border.border1}; } diff --git a/packages/bruno-app/src/components/RequestPane/AppCodeEditor/index.js b/packages/bruno-app/src/components/RequestPane/AppCodeEditor/index.js index b8b70addd..e666009f1 100644 --- a/packages/bruno-app/src/components/RequestPane/AppCodeEditor/index.js +++ b/packages/bruno-app/src/components/RequestPane/AppCodeEditor/index.js @@ -1,13 +1,15 @@ import React, { useMemo } from 'react'; import get from 'lodash/get'; import { useDispatch, useSelector } from 'react-redux'; +import { IconAppWindow } from '@tabler/icons'; import CodeEditor from 'components/CodeEditor'; -import ToggleSwitch from 'components/ToggleSwitch'; import AIAssist from 'components/AIAssist'; import { buildAiContextPayload } from 'utils/ai'; -import { updateAppCode, toggleAppMode } from 'providers/ReduxStore/slices/collections'; +import { updateAppCode } from 'providers/ReduxStore/slices/collections'; +import { setTabAppPreview } from 'providers/ReduxStore/slices/tabs'; import { saveRequest } from 'providers/ReduxStore/slices/collections/actions'; import { useTheme } from 'providers/Theme'; +import Button from 'ui/Button'; import StyledWrapper from './StyledWrapper'; const AppCodeEditor = ({ item, collection }) => { @@ -16,13 +18,12 @@ const AppCodeEditor = ({ item, collection }) => { const preferences = useSelector((state) => state.app.preferences); const code = item.draft ? get(item, 'draft.app.code', '') : get(item, 'app.code', ''); - const enabled = item.draft ? get(item, 'draft.app.enabled', false) : get(item, 'app.enabled', false); const onEdit = (value) => dispatch(updateAppCode({ code: value, itemUid: item.uid, collectionUid: collection.uid })); - const onToggle = () => - dispatch(toggleAppMode({ enabled: !enabled, itemUid: item.uid, collectionUid: collection.uid })); + const onPreview = () => + dispatch(setTabAppPreview({ uid: item.uid, appPreview: true })); const onSave = () => dispatch(saveRequest(item.uid, collection.uid)); @@ -33,14 +34,19 @@ const AppCodeEditor = ({ item, collection }) => { return ( -
-
- -

- When enabled, replaces the request/response panes with the app view for this request. -

-
- +
+

+ The app view replaces the request/response panes for this request. +

+
diff --git a/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js b/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js index 596bd9e71..734a888b1 100644 --- a/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js +++ b/packages/bruno-app/src/components/RequestPane/HttpRequestPane/index.js @@ -59,7 +59,7 @@ const HttpRequestPane = ({ item, collection }) => { const focusedTab = find(tabs, (t) => t.uid === activeTabUid); const requestPaneTab = focusedTab?.requestPaneTab; const getProperty = useCallback( - (key) => (item.draft ? get(item, `draft.${key}`, []) : get(item, key, [])), + (key, defaultValue = []) => (item.draft ? get(item, `draft.${key}`, defaultValue) : get(item, key, defaultValue)), [item.draft, item] ); @@ -74,7 +74,10 @@ const HttpRequestPane = ({ item, collection }) => { const responseVars = getProperty('request.vars.res'); const auth = getProperty('request.auth'); const tags = getProperty('tags'); - const app = item.draft ? get(item, 'draft.app') : get(item, 'app'); + const app = getProperty('app', null); + const appTabEnabled = app?.enabled === true; + // A previously selected App tab may be restored while apps are disabled in settings. + const effectiveTab = requestPaneTab === 'app' && !appTabEnabled ? 'params' : requestPaneTab; const activeCounts = useMemo(() => ({ params: params.filter((p) => p.enabled).length, @@ -116,14 +119,16 @@ const HttpRequestPane = ({ item, collection }) => { }, [activeCounts, body.mode, hasAuth, script, item.preRequestScriptErrorMessage, item.postResponseScriptErrorMessage, item.testScriptErrorMessage, tests, docs, app, tags]); const allTabs = useMemo( - () => TAB_CONFIG.map(({ key, label }) => ({ key, label, indicator: indicators[key] })), - [indicators] + () => TAB_CONFIG + .filter(({ key }) => key !== 'app' || appTabEnabled) + .map(({ key, label }) => ({ key, label, indicator: indicators[key] })), + [indicators, appTabEnabled] ); const tabPanel = useMemo(() => { - const Component = TAB_PANELS[requestPaneTab]; + const Component = TAB_PANELS[effectiveTab]; return Component ? :
404 | Not found
; - }, [requestPaneTab, item, collection]); + }, [effectiveTab, item, collection]); if (!activeTabUid || !focusedTab?.uid || !requestPaneTab) { return
An error occurred!
; @@ -143,7 +148,7 @@ const HttpRequestPane = ({ item, collection }) => {
{ const rawSettings = getPropertyFromDraftOrRequest('settings'); const settings = { ...DEFAULT_SETTINGS, ...rawSettings }; const { encodeUrl, followRedirects, maxRedirects, timeout } = settings; + const enableApp = getPropertyFromDraftOrRequest('app.enabled') === true; // Reusable function to update settings const updateSetting = useCallback((settingUpdate) => { @@ -45,6 +47,14 @@ const Settings = ({ item, collection }) => { const onToggleFollowRedirects = useCallback(() => updateSetting({ followRedirects: !followRedirects }), [followRedirects, updateSetting]); + const onToggleEnableApp = useCallback(() => { + const next = !enableApp; + dispatch(toggleAppMode({ enabled: next, itemUid: item.uid, collectionUid: collection.uid })); + if (next) { + dispatch(setTabAppPreview({ uid: item.uid, appPreview: false })); + } + }, [enableApp, dispatch, item.uid, collection.uid]); + const onMaxRedirectsChange = useCallback((e) => { const value = e.target.value; // Only allow empty string or digits @@ -131,6 +141,17 @@ const Settings = ({ item, collection }) => { />
+
+ +
+ { ); } + const itemSource = item.draft ? item.draft : item; + // Preview state is runtime-only, kept on the tab; unset means "preview on" so + // an app-enabled request opens in preview mode by default. const appEnabled = item.type !== 'app' - && (item.draft ? get(item, 'draft.app.enabled', false) : get(item, 'app.enabled', false)); + && get(itemSource, 'app.enabled', false) === true + && focusedTab.appPreview !== false; if (item.type === 'app' || appEnabled) { return ; } diff --git a/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js b/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js index efac933a2..1af8bf0b7 100644 --- a/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js +++ b/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js @@ -24,13 +24,13 @@ import OpenAPISyncIcon from 'components/Icons/OpenAPISync'; import { switchWorkspace, renameWorkspaceAction, exportWorkspaceAction, confirmWorkspaceCreation, cancelWorkspaceCreation } from 'providers/ReduxStore/slices/workspaces/actions'; import { updateWorkspace } from 'providers/ReduxStore/slices/workspaces'; import { showInFolder } from 'providers/ReduxStore/slices/collections/actions'; -import { toggleCollectionFileMode, toggleAppMode } from 'providers/ReduxStore/slices/collections'; +import { toggleCollectionFileMode } from 'providers/ReduxStore/slices/collections'; import { toggleAiSidebar } from 'providers/ReduxStore/slices/chat'; import MigrateToYmlModal from 'components/CollectionSettings/Overview/Migration/MigrateToYmlModal'; import { findItemInCollection, findItemInCollectionByPathname } from 'utils/collections'; import find from 'lodash/find'; import get from 'lodash/get'; -import { addTab, focusTab } from 'providers/ReduxStore/slices/tabs'; +import { addTab, focusTab, setTabAppPreview } from 'providers/ReduxStore/slices/tabs'; import { uuid } from 'utils/common'; import toast from 'react-hot-toast'; import Dropdown from 'components/Dropdown'; @@ -82,13 +82,15 @@ const CollectionHeader = ({ collection, isScratchCollection }) => { || (focusedTab.pathname ? findItemInCollectionByPathname(collection, focusedTab.pathname) : null)) : null; const isHttpRequestActive = activeItem?.type === 'http-request'; - const appEnabled = activeItem - ? (activeItem.draft ? get(activeItem, 'draft.app.enabled', false) : get(activeItem, 'app.enabled', false)) - : false; + const activeItemSource = activeItem ? activeItem.draft || activeItem : null; + // The "Enable App" request setting (persisted as app.enabled) gates the whole + // Request/App/File mode toggle. + const appAvailable = isHttpRequestActive && get(activeItemSource, 'app.enabled', false) === true; + const appEnabled = appAvailable && focusedTab?.appPreview !== false; const handleToggleAppMode = (enabled) => { if (isHttpRequestActive) { - dispatch(toggleAppMode({ enabled, itemUid: activeItem.uid, collectionUid: collection.uid })); + dispatch(setTabAppPreview({ uid: focusedTab.uid, appPreview: enabled })); } }; @@ -296,9 +298,9 @@ const CollectionHeader = ({ collection, isScratchCollection }) => { // Build overflow menu items for the "..." dropdown const overflowMenuItems = [ { id: 'variables', label: 'Variables', leftSection: IconEye, onClick: viewVariables }, - // File mode is exposed via the Request/App/File view-mode toggle when a request is active; - // keep it in the overflow as a fallback for non-request contexts. - ...(!isHttpRequestActive + // File mode is exposed via the Request/App/File view-mode toggle when the active + // request has apps enabled; keep it in the overflow as a fallback everywhere else. + ...(!appAvailable ? [{ id: 'file-mode', label: collection.fileMode ? 'Switch to Code Mode' : 'Switch to File Mode', leftSection: collection.fileMode ? IconFileOff : IconFileCode, onClick: handleFileModeClick }] : []), ...(!hasOpenApiSyncConfigured @@ -653,7 +655,7 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
{!isScratchCollection && ( <> - {isHttpRequestActive && ( + {appAvailable && (