From 84c53b65c242a3a5fd7a2a61f6a8e1f3c45c650c Mon Sep 17 00:00:00 2001 From: naman-bruno Date: Wed, 24 Jun 2026 13:21:05 +0530 Subject: [PATCH] feat(app): introduce standalone app functionality (#8338) --- .../src/components/AIAssist/index.js | 20 +- .../src/components/AppView/EmptyAppState.js | 48 +++ .../bruno-app/src/components/AppView/index.js | 204 +++------ .../src/components/AppView/webview-bridge.js | 200 +++++++++ .../components/CollectionApp/StyledWrapper.js | 84 ++++ .../src/components/CollectionApp/index.js | 394 ++++++++++++++++++ .../RequestPane/AppCodeEditor/index.js | 14 +- .../src/components/RequestTabPanel/index.js | 13 + .../RequestTabs/RequestTab/index.js | 13 +- .../CollectionItemIcon/index.js | 6 +- .../Collection/CollectionItem/index.js | 24 +- .../Sidebar/Collections/Collection/index.js | 23 +- .../src/components/Sidebar/NewApp/index.js | 87 ++++ .../middlewares/draft/middleware.js | 1 + .../ReduxStore/slices/collections/actions.js | 95 +++++ .../ReduxStore/slices/collections/index.js | 3 +- .../bruno-app/src/utils/collections/index.js | 13 + .../src/ipc/ai/script-prompts.js | 90 +++- packages/bruno-electron/src/ipc/collection.js | 9 +- .../bruno-filestore/src/formats/bru/index.ts | 30 ++ .../src/formats/yml/items/parseApp.ts | 37 ++ .../src/formats/yml/items/stringifyApp.ts | 45 ++ .../src/formats/yml/parseItem.ts | 4 + .../src/formats/yml/stringifyItem.ts | 4 + .../bruno-schema-types/src/collection/item.ts | 1 + .../bruno-schema/src/collections/index.js | 2 +- tests/apps/collection-apps.spec.ts | 216 ++++++++++ tests/utils/page/actions.ts | 51 +++ 28 files changed, 1563 insertions(+), 168 deletions(-) create mode 100644 packages/bruno-app/src/components/AppView/EmptyAppState.js create mode 100644 packages/bruno-app/src/components/AppView/webview-bridge.js create mode 100644 packages/bruno-app/src/components/CollectionApp/StyledWrapper.js create mode 100644 packages/bruno-app/src/components/CollectionApp/index.js create mode 100644 packages/bruno-app/src/components/Sidebar/NewApp/index.js create mode 100644 packages/bruno-filestore/src/formats/yml/items/parseApp.ts create mode 100644 packages/bruno-filestore/src/formats/yml/items/stringifyApp.ts create mode 100644 tests/apps/collection-apps.spec.ts diff --git a/packages/bruno-app/src/components/AIAssist/index.js b/packages/bruno-app/src/components/AIAssist/index.js index 0238dac98..b3917ed9b 100644 --- a/packages/bruno-app/src/components/AIAssist/index.js +++ b/packages/bruno-app/src/components/AIAssist/index.js @@ -27,6 +27,18 @@ const SUGGESTIONS = { { label: 'Request', prompt: 'Document the request method, URL, headers, parameters, and body' }, { label: 'Examples', prompt: 'Add request and response examples with sample JSON' }, { label: 'Errors', prompt: 'Document common error responses and status codes' } + ], + 'app-request': [ + { label: 'Send button', prompt: 'Add a button that calls ctx.sendRequest() and displays the response status, headers, and pretty-printed body' }, + { label: 'Form for body', prompt: 'Build a form whose fields override the request body, then send it with ctx.sendRequest({ variables }) and show the result' }, + { label: 'Response viewer', prompt: 'Render ctx.response with collapsible JSON and a banner showing status and response time; update on ctx.onResponseUpdate' }, + { label: 'Test results', prompt: 'List ctx.testResults and ctx.assertionResults with pass/fail badges; refresh on ctx.onResultsUpdate' } + ], + 'app-collection': [ + { label: 'Request list', prompt: 'List all requests from ctx.listRequests() with their method and url, and a Run button next to each that calls ctx.runRequest(pathname)' }, + { label: 'Dashboard', prompt: 'Build a small dashboard that runs every request from ctx.listRequests() on load and shows status code, response time, and a pass/fail dot for each' }, + { label: 'Form runner', prompt: 'Render a form, and on submit call ctx.runRequest(pathname, { variables }) for a chosen request and display the response' }, + { label: 'Variables panel', prompt: 'Show ctx.variables in a table and allow editing values via ctx.setRuntimeVariable(key, value); react to ctx.onVariablesUpdate' } ] }; @@ -34,11 +46,15 @@ const TITLES = { 'tests': 'Generate Tests', 'pre-request': 'Generate Pre-Request Script', 'post-response': 'Generate Post-Response Script', - 'docs': 'Generate Documentation' + 'docs': 'Generate Documentation', + 'app-request': 'Generate App', + 'app-collection': 'Generate App' }; const PREVIEW_LABELS = { - docs: 'Preview · replaces current documentation' + 'docs': 'Preview · replaces current documentation', + 'app-request': 'Preview · replaces current app', + 'app-collection': 'Preview · replaces current app' }; const isValidType = (t) => SUGGESTIONS[t] !== undefined; diff --git a/packages/bruno-app/src/components/AppView/EmptyAppState.js b/packages/bruno-app/src/components/AppView/EmptyAppState.js new file mode 100644 index 000000000..4df795f8e --- /dev/null +++ b/packages/bruno-app/src/components/AppView/EmptyAppState.js @@ -0,0 +1,48 @@ +import React from 'react'; +import styled from 'styled-components'; +import { IconApps } from '@tabler/icons'; + +const Wrapper = styled.div` + flex: 1 1 0; + min-height: 0; + display: flex; + align-items: center; + justify-content: center; + border: 1px dashed ${(props) => props.theme.border.border1}; + border-radius: 4px; + background: ${(props) => props.theme.background.surface0}; + color: ${(props) => props.theme.colors.text.muted}; + + .empty-app-inner { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + padding: 2rem; + text-align: center; + max-width: 360px; + } + + .empty-app-title { + font-size: 13px; + font-weight: 500; + color: ${(props) => props.theme.text}; + } + + .empty-app-hint { + font-size: 12px; + line-height: 1.4; + } +`; + +const EmptyAppState = ({ title = 'No app yet', hint }) => ( + +
+ +
{title}
+ {hint ?
{hint}
: null} +
+
+); + +export default EmptyAppState; diff --git a/packages/bruno-app/src/components/AppView/index.js b/packages/bruno-app/src/components/AppView/index.js index af9c682fe..5fe05e119 100644 --- a/packages/bruno-app/src/components/AppView/index.js +++ b/packages/bruno-app/src/components/AppView/index.js @@ -1,4 +1,4 @@ -import React, { useRef, useEffect, useCallback, useMemo, useState } from 'react'; +import React, { useRef, useEffect, useCallback, useMemo } from 'react'; import cloneDeep from 'lodash/cloneDeep'; import { useDispatch } from 'react-redux'; import { sendNetworkRequest } from 'utils/network/index'; @@ -7,31 +7,28 @@ import { getEnvironmentVariables, getGlobalEnvironmentVariables } from 'utils/collections'; -import { responseReceived, appSetRuntimeVariable, toggleAppMode, initRunRequestEvent } from 'providers/ReduxStore/slices/collections'; +import { + responseReceived, + appSetRuntimeVariable, + toggleAppMode, + initRunRequestEvent +} from 'providers/ReduxStore/slices/collections'; import { uuid } from 'utils/common'; import { useTheme } from 'providers/Theme'; import StyledWrapper from './StyledWrapper'; +import EmptyAppState from './EmptyAppState'; +import { + SENTINEL, + wrapHtml, + toDataUrl, + serializeTimeline, + projectResponse, + useAppWebview +} from './webview-bridge'; -/* - * App content runs inside an Electron , which is an out-of-process guest - * with its own document, so it does NOT inherit the app's strict CSP (script-src 'self') - * This mirrors the HtmlPreview component used for HTML response previews. - * - * Messaging (no node integration in the guest, so postMessage/ipc aren't available): - * host -> guest : webview.executeJavaScript(`window.__brunoReceive()`) - * guest -> host : console.log(SENTINEL + json), read via the 'console-message' event - */ -const SENTINEL = '__BRUNO_APP_MSG__'; - -// Encode a value for safe inlining into an executeJavaScript() string as a JS object literal. -const toJsArg = (value) => - JSON.stringify(value === undefined ? null : value) - .replace(/ +// Request-level ctx bootstrap. Injected into the guest so window.ctx exists +// before user scripts run. +const REQUEST_CTX_BOOTSTRAP = ``; -const FRAGMENT_STYLES = ``; - -// User code may be a full HTML document or a fragment. For a full document we inject -// the bootstrap into it (avoids producing a malformed nested document); a fragment is -// wrapped in a minimal shell. -const generateAppHtml = (userCode) => { - const code = userCode || ''; - const isFullDocument = /]/i.test(code) || /]*>/i.test(code)) { - return code.replace(/]*>/i, (m) => `${m}${BOOTSTRAP_SCRIPT}`); - } - if (/]*>/i.test(code)) { - return code.replace(/]*>/i, (m) => `${m}${BOOTSTRAP_SCRIPT}`); - } - return `${BOOTSTRAP_SCRIPT}${code}`; - } - - return ` - - - - - ${FRAGMENT_STYLES} - ${BOOTSTRAP_SCRIPT} - - -${code} - -`; -}; - -const serializeTimeline = (timeline) => { - if (!Array.isArray(timeline)) return timeline; - return timeline.map((entry) => ({ - ...entry, - timestamp: entry.timestamp instanceof Date ? entry.timestamp.getTime() : entry.timestamp - })); -}; - -const projectResponse = (r) => ({ - status: r?.status ?? null, - statusText: r?.statusText ?? null, - data: r?.data ?? null, - headers: r?.headers ?? null, - duration: r?.duration ?? null, - size: r?.size ?? null -}); - const buildVariables = (collection) => { const env = getEnvironmentVariables(collection); const global = getGlobalEnvironmentVariables({ @@ -207,13 +143,7 @@ const buildVariables = (collection) => { const AppView = ({ item, collection, code }) => { const dispatch = useDispatch(); const { displayedTheme } = useTheme(); - const webviewRef = useRef(null); - const [domReady, setDomReady] = useState(false); - - const src = useMemo( - () => `data:text/html;charset=utf-8,${encodeURIComponent(generateAppHtml(code || ''))}`, - [code] - ); + const src = useMemo(() => toDataUrl(wrapHtml(REQUEST_CTX_BOOTSTRAP, code || '')), [code]); const environment = useMemo( () => findEnvironmentInCollection(collection, collection.activeEnvironmentUid), @@ -224,28 +154,26 @@ const AppView = ({ item, collection, code }) => { const assertionResults = useMemo(() => item.assertionResults || [], [item.assertionResults]); const testResults = useMemo(() => item.testResults || [], [item.testResults]); - // Push a message into the guest. Safe to call before dom-ready (no-op until then). - const pushToGuest = useCallback((msg) => { - const webview = webviewRef.current; - if (!webview || !domReady) return; - try { - webview.executeJavaScript(`window.__brunoReceive && window.__brunoReceive(${toJsArg(msg)})`).catch(() => {}); - } catch (_) { - /* webview not attached yet */ - } - }, [domReady]); + // pushToGuest is produced by useAppWebview, which itself needs handleGuestMessage — + // routing through a ref lets the callbacks call the *latest* pushToGuest without + // creating a circular useCallback dependency. Without this, the request-id reply + // (and error reply) close over the first-render no-op pushToGuest and the guest's + // ctx.sendRequest() promise never resolves. + const pushToGuestRef = useRef(() => {}); const handleSendRequest = useCallback( async (requestId, overrides) => { + const push = pushToGuestRef.current; try { // Mint a requestUid and register the run so the main process emits its - // test/assertion/script events against an id the store recognises — this is - // what makes ctx.testResults / ctx.assertionResults populate (same as Send). + // test/assertion/script events against an id the store recognises — this + // is what makes ctx.testResults / ctx.assertionResults populate. const requestUid = uuid(); const requestItem = cloneDeep(item.draft || item); requestItem.requestUid = requestUid; dispatch(initRunRequestEvent({ requestUid, itemUid: item.uid, collectionUid: collection.uid })); + // Variable overrides: accept flat keys or { variables: {...} }. const flatOverrides = overrides && typeof overrides === 'object' ? { ...overrides } : {}; const explicitVars = flatOverrides.variables; delete flatOverrides.variables; @@ -257,13 +185,13 @@ const AppView = ({ item, collection, code }) => { const result = await sendNetworkRequest(requestItem, collection, environment, mergedRuntime); - // sendNetworkRequest resolves (rather than rejects) on network/request - // errors with an `error` payload — surface that to the guest as a rejection. + // sendNetworkRequest resolves on network/request errors with `error` set — + // surface as a guest-side promise rejection rather than a fake success. if (result?.error) { const errorMessage = typeof result.error === 'string' ? result.error : result.error?.message || 'Request failed'; - pushToGuest({ type: 'response', requestId, error: errorMessage }); + push({ type: 'response', requestId, error: errorMessage }); return; } @@ -284,19 +212,18 @@ const AppView = ({ item, collection, code }) => { }) ); - pushToGuest({ type: 'response', requestId, response: projectResponse(result) }); + push({ type: 'response', requestId, response: projectResponse(result) }); } catch (err) { - pushToGuest({ type: 'response', requestId, error: err?.message || 'Request failed' }); + push({ type: 'response', requestId, error: err?.message || 'Request failed' }); } }, - [item, collection, environment, dispatch, pushToGuest] + [item, collection, environment, dispatch] ); const handleGuestMessage = useCallback( (data) => { switch (data?.type) { case 'ready': - // Readiness is tracked via the webview 'dom-ready' event; nothing to do here. break; case 'sendRequest': handleSendRequest(data.requestId, data.overrides); @@ -316,38 +243,12 @@ const AppView = ({ item, collection, code }) => { [handleSendRequest, dispatch, collection.uid] ); - useEffect(() => { - const webview = webviewRef.current; - if (!webview) return; + const { domReady, pushToGuest, webviewRef } = useAppWebview(handleGuestMessage); + pushToGuestRef.current = pushToGuest; - const onConsoleMessage = (e) => { - const text = e?.message; - if (typeof text !== 'string' || !text.startsWith(SENTINEL)) return; - try { - handleGuestMessage(JSON.parse(text.slice(SENTINEL.length))); - } catch (_) { - /* not our message */ - } - }; - // executeJavaScript() is only valid after Electron's 'dom-ready'; gate on that. - // A reload (e.g. code change) tears the guest down, so reset readiness then. - const onDomReady = () => setDomReady(true); - const onStartLoading = () => setDomReady(false); - - webview.addEventListener('console-message', onConsoleMessage); - webview.addEventListener('dom-ready', onDomReady); - webview.addEventListener('did-start-loading', onStartLoading); - - return () => { - webview.removeEventListener('console-message', onConsoleMessage); - webview.removeEventListener('dom-ready', onDomReady); - webview.removeEventListener('did-start-loading', onStartLoading); - }; - }, [handleGuestMessage]); - - // Push initial state once the guest signals ready (also after a reload). - // Push a full state snapshot on the readiness transition (initial load and after reloads). - // Subsequent changes are handled by the granular effects below. + // Push a full state snapshot on each readiness transition. Subsequent changes + // are handled by the granular effects below; using a ref avoids re-firing + // this effect (which would be a needless full re-broadcast). const stateRef = useRef(); stateRef.current = { theme: displayedTheme, response, assertionResults, testResults, variables }; useEffect(() => { @@ -383,15 +284,22 @@ const AppView = ({ item, collection, code }) => { Exit to editor -
- + +
+ ) : ( + - + )} ); }; diff --git a/packages/bruno-app/src/components/AppView/webview-bridge.js b/packages/bruno-app/src/components/AppView/webview-bridge.js new file mode 100644 index 000000000..d895c13bb --- /dev/null +++ b/packages/bruno-app/src/components/AppView/webview-bridge.js @@ -0,0 +1,200 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +/* + * Shared transport for Bruno apps that run inside an Electron : + * host -> guest : webview.executeJavaScript(`window.__brunoReceive()`) + * guest -> host : console.log(SENTINEL + json), surfaced via 'console-message' + * + * Both the request-level AppView and the standalone CollectionApp use this — they + * differ only in the bootstrap script (which builds window.ctx) and the message + * handler the host registers. + */ +export const SENTINEL = '__BRUNO_APP_MSG__'; + +// JSON-encode for safe inlining into an executeJavaScript() string literal. +// U+2028/U+2029 are legal in JSON strings but illegal as raw JS source. +export const toJsArg = (value) => + JSON.stringify(value === undefined ? null : value) + .replace(/ + * { box-sizing: border-box; } + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + margin: 0; + background: #ffffff; + color: #1e1e1e; + transition: background-color 0.15s, color 0.15s; + } + body.dark { background: #1e1e1e; color: #e0e0e0; } +`; + +/** + * Wrap user code into a guest document, injecting the host-supplied bootstrap + * script as early as possible (right after ) so window.ctx exists before + * any user script runs. Full HTML documents have the bootstrap injected; bare + * fragments are placed inside a minimal shell. + */ +export const wrapHtml = (bootstrap, userCode) => { + const code = userCode || ''; + const isFullDocument = /]/i.test(code) || /]*>/i.test(code)) { + return code.replace(/]*>/i, (m) => `${m}${bootstrap}`); + } + if (/]*>/i.test(code)) { + return code.replace(/]*>/i, (m) => `${m}${bootstrap}`); + } + return `${bootstrap}${code}`; + } + + return ` + + + + + ${FRAGMENT_STYLES} + ${bootstrap} + + +${code} + +`; +}; + +export const toDataUrl = (html) => + `data:text/html;charset=utf-8,${encodeURIComponent(html)}`; + +export const serializeTimeline = (timeline) => { + if (!Array.isArray(timeline)) return timeline; + return timeline.map((entry) => ({ + ...entry, + timestamp: entry.timestamp instanceof Date ? entry.timestamp.getTime() : entry.timestamp + })); +}; + +export const projectResponse = (r) => ({ + status: r?.status ?? null, + statusText: r?.statusText ?? null, + data: r?.data ?? null, + headers: r?.headers ?? null, + duration: r?.duration ?? null, + size: r?.size ?? null +}); + +/** + * useAppWebview — manages an Electron guest and provides a typed + * messaging channel back to the host. + * + * const { domReady, pushToGuest, webviewRef } = useAppWebview(handleGuestMessage); + * … + * + * + * `webviewRef` is a **callback ref** (not an object ref). React invokes it with + * the element on mount and with `null` on unmount, which is the only way to + * reliably re-attach listeners when the is unmounted and remounted — + * e.g. when CollectionApp's user toggles between Code and Preview views. An + * object-ref + useEffect approach would not re-fire on remount because the ref + * object's identity is stable across mounts. + * + * pushToGuest({…}) is a no-op until the guest's dom-ready fires (and after a + * reload, until it fires again). Safe to call eagerly from effects. + */ +export const useAppWebview = (onGuestMessage) => { + const [domReady, setDomReady] = useState(false); + + // Latest DOM element (for pushToGuest) and latest message handler (so the + // listener captures fresh state without needing to be re-bound). + const webviewElRef = useRef(null); + const onGuestMessageRef = useRef(onGuestMessage); + onGuestMessageRef.current = onGuestMessage; + + // Outgoing messages sent before the guest is ready are queued and flushed by + // the dom-ready effect below. This is critical for guest scripts that call + // promise-returning ctx APIs (e.g. ctx.listRequests) at parse time — the host + // receives the request via console-message before Electron's `dom-ready` + // fires, and without a queue the reply gets dropped and the promise never + // resolves. + const pendingOutbox = useRef([]); + + const sendToWebview = (webview, msg) => { + try { + webview.executeJavaScript( + `window.__brunoReceive && window.__brunoReceive(${toJsArg(msg)})` + ).catch(() => {}); + } catch (_) { + /* webview not yet attached */ + } + }; + + const pushToGuest = useCallback( + (msg) => { + const webview = webviewElRef.current; + if (!webview || !domReady) { + pendingOutbox.current.push(msg); + return; + } + sendToWebview(webview, msg); + }, + [domReady] + ); + + // Flush whatever piled up while the guest was still loading. + useEffect(() => { + if (!domReady) return; + const webview = webviewElRef.current; + if (!webview) return; + const queue = pendingOutbox.current; + if (!queue.length) return; + pendingOutbox.current = []; + for (const msg of queue) sendToWebview(webview, msg); + }, [domReady]); + + // Stable callback ref. We stash the per-element listener bag on the element + // itself so we can clean up exactly the right listeners on unmount or replace. + const webviewRef = useCallback((element) => { + const prev = webviewElRef.current; + if (prev && prev !== element) { + const h = prev.__brunoHandlers; + if (h) { + prev.removeEventListener('console-message', h.onConsoleMessage); + prev.removeEventListener('dom-ready', h.onDomReady); + prev.removeEventListener('did-start-loading', h.onStartLoading); + prev.__brunoHandlers = null; + } + } + + // Queued messages belong to the prior guest; drop them on element replace. + pendingOutbox.current = []; + + webviewElRef.current = element || null; + // dom-ready will fire fresh on the new element; until then pushToGuest no-ops. + setDomReady(false); + + if (!element) return; + + const onConsoleMessage = (e) => { + const text = e?.message; + if (typeof text !== 'string' || !text.startsWith(SENTINEL)) return; + try { + onGuestMessageRef.current(JSON.parse(text.slice(SENTINEL.length))); + } catch (_) { + /* not our message */ + } + }; + const onDomReady = () => setDomReady(true); + // A reload (code edit) tears down the guest; reset readiness so the next + // dom-ready can flip us back to true. + const onStartLoading = () => setDomReady(false); + + element.__brunoHandlers = { onConsoleMessage, onDomReady, onStartLoading }; + element.addEventListener('console-message', onConsoleMessage); + element.addEventListener('dom-ready', onDomReady); + element.addEventListener('did-start-loading', onStartLoading); + }, []); + + return { domReady, pushToGuest, webviewRef }; +}; diff --git a/packages/bruno-app/src/components/CollectionApp/StyledWrapper.js b/packages/bruno-app/src/components/CollectionApp/StyledWrapper.js new file mode 100644 index 000000000..d7bcddd79 --- /dev/null +++ b/packages/bruno-app/src/components/CollectionApp/StyledWrapper.js @@ -0,0 +1,84 @@ +import styled from 'styled-components'; + +const StyledWrapper = styled.div` + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + padding: 0.5rem; + + .app-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 0.25rem 0.5rem; + font-size: 11px; + color: ${(props) => props.theme.colors.text.muted}; + } + + .app-toolbar .view-toggle { + display: flex; + align-items: center; + height: 24px; + border: 1px solid ${(props) => props.theme.input.border}; + border-radius: ${(props) => props.theme.border.radius.base}; + overflow: hidden; + } + + .app-toolbar .view-btn { + display: flex; + align-items: center; + justify-content: center; + padding: 0 10px; + height: 100%; + border: none; + border-right: 1px solid ${(props) => props.theme.input.border}; + background: transparent; + color: ${(props) => props.theme.colors.text.muted}; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; + font-size: 11px; + + &:last-child { border-right: none; } + + &:hover:not(.active) { + background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; + color: ${(props) => props.theme.text}; + } + + &.active { + background: ${(props) => props.theme.sidebar.collection.item.hoverBg}; + color: ${(props) => props.theme.primary.text}; + } + } + + .app-pane { + flex: 1 1 0; + min-height: 0; + display: flex; + flex-direction: column; + } + + .app-pane.code div.CodeMirror { + height: 100%; + } + + .app-webview-container { + flex: 1 1 0; + min-height: 0; + display: flex; + border: 1px solid ${(props) => props.theme.border.border1}; + border-radius: 4px; + overflow: hidden; + background: ${(props) => props.theme.background.surface0}; + } + + .app-webview { + width: 100%; + height: 100%; + flex: 1 1 0; + border: 0; + } +`; + +export default StyledWrapper; diff --git a/packages/bruno-app/src/components/CollectionApp/index.js b/packages/bruno-app/src/components/CollectionApp/index.js new file mode 100644 index 000000000..9d6aeb0f4 --- /dev/null +++ b/packages/bruno-app/src/components/CollectionApp/index.js @@ -0,0 +1,394 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import classnames from 'classnames'; +import cloneDeep from 'lodash/cloneDeep'; +import get from 'lodash/get'; +import { useDispatch, useSelector } from 'react-redux'; +import { sendNetworkRequest } from 'utils/network/index'; +import { + findEnvironmentInCollection, + findItemInCollectionByPathname, + flattenItems, + getEnvironmentVariables, + getGlobalEnvironmentVariables, + isItemARequest +} from 'utils/collections'; +import { uuid } from 'utils/common'; +import { + appSetRuntimeVariable, + initRunRequestEvent, + responseReceived, + updateAppCode +} from 'providers/ReduxStore/slices/collections'; +import { saveRequest } from 'providers/ReduxStore/slices/collections/actions'; +import { useTheme } from 'providers/Theme'; +import CodeEditor from 'components/CodeEditor'; +import AIAssist from 'components/AIAssist'; +import { buildDocsContextFromCollection } from 'utils/ai'; +import StyledWrapper from './StyledWrapper'; +import EmptyAppState from '../AppView/EmptyAppState'; +import { + SENTINEL, + wrapHtml, + toDataUrl, + serializeTimeline, + projectResponse, + useAppWebview +} from '../AppView/webview-bridge'; + +/* + * Standalone collection-/folder-level app — a file (.bru/.yml) of type 'app' + * that lives in the sidebar and opens as its own tab. The user toggles between + * Code (CodeEditor) and Preview (sandboxed ); preview re-runs whenever + * the code prop changes. + * + * Collection ctx surface differs from the request-level AppView: + * shared: theme, log, variables, setRuntimeVariable, onThemeChange, onVariablesUpdate + * added: collection, listRequests(), runRequest(pathname, overrides?) + * dropped: sendRequest, response, assertionResults, testResults + * (and their on* hooks — they only make sense for one request) + */ + +const COLLECTION_CTX_BOOTSTRAP = ``; + +const buildVariables = (collection) => { + const env = getEnvironmentVariables(collection); + const global = getGlobalEnvironmentVariables({ + globalEnvironments: collection?.globalEnvironments || [], + activeGlobalEnvironmentUid: collection?.activeGlobalEnvironmentUid + }); + return { + ...global, + ...env, + ...(collection?.collectionVariables || {}), + ...(collection?.runtimeVariables || {}) + }; +}; + +const listRequestSummaries = (collection) => + flattenItems(collection?.items || []) + .filter(isItemARequest) + .map((it) => ({ + uid: it.uid, + name: it.name, + pathname: it.pathname, + type: it.type, + method: it.request?.method || null, + url: it.request?.url || null + })); + +const CollectionApp = ({ item, collection }) => { + const dispatch = useDispatch(); + const { displayedTheme } = useTheme(); + const preferences = useSelector((state) => state.app.preferences); + const [view, setView] = useState('preview'); + + const code = item.draft ? get(item, 'draft.app.code', '') : get(item, 'app.code', ''); + + // Preview HTML is keyed on the *saved* code so typing doesn't reload the guest + // on every keystroke. The user toggles to Preview after saving to see updates. + const src = useMemo( + () => toDataUrl(wrapHtml(COLLECTION_CTX_BOOTSTRAP, code || '')), + [code] + ); + + const environment = useMemo( + () => findEnvironmentInCollection(collection, collection.activeEnvironmentUid), + [collection] + ); + const variables = useMemo(() => buildVariables(collection), [collection]); + const collectionInfo = useMemo( + () => ({ name: collection?.name || null, pathname: collection?.pathname || null }), + [collection?.name, collection?.pathname] + ); + const docsContext = useMemo(() => buildDocsContextFromCollection(collection), [collection]); + + const onEdit = useCallback( + (value) => dispatch(updateAppCode({ code: value, itemUid: item.uid, collectionUid: collection.uid })), + [dispatch, item.uid, collection.uid] + ); + const onSave = useCallback( + () => dispatch(saveRequest(item.uid, collection.uid)), + [dispatch, item.uid, collection.uid] + ); + + // Execute a single request by its pathname (returned earlier from listRequests). + // Mirrors AppView.handleSendRequest: mints a requestUid, registers the run, merges + // overrides into runtime variables, sends, and dispatches responseReceived so the + // request's normal Response pane updates too. + const runRequestByPath = useCallback( + async (pathname, overrides) => { + const target = findItemInCollectionByPathname(collection, pathname); + if (!target) { + throw new Error(`Request not found: ${pathname}`); + } + if (!isItemARequest(target)) { + throw new Error(`Item is not a request: ${pathname}`); + } + + const requestUid = uuid(); + const requestItem = cloneDeep(target.draft || target); + requestItem.requestUid = requestUid; + dispatch( + initRunRequestEvent({ requestUid, itemUid: target.uid, collectionUid: collection.uid }) + ); + + const flat = overrides && typeof overrides === 'object' ? { ...overrides } : {}; + const explicit = flat.variables; + delete flat.variables; + const mergedRuntime = { + ...(collection.runtimeVariables || {}), + ...flat, + ...(explicit && typeof explicit === 'object' ? explicit : {}) + }; + + const result = await sendNetworkRequest(requestItem, collection, environment, mergedRuntime); + + if (result?.error) { + const errorMessage = typeof result.error === 'string' + ? result.error + : result.error?.message || 'Request failed'; + throw new Error(errorMessage); + } + + dispatch( + responseReceived({ + itemUid: target.uid, + collectionUid: collection.uid, + response: { + status: result.status, + statusText: result.statusText, + headers: result.headers, + data: result.data, + dataBuffer: result.dataBuffer, + size: result.size, + duration: result.duration, + timeline: serializeTimeline(result.timeline) + } + }) + ); + + return projectResponse(result); + }, + [collection, environment, dispatch] + ); + + // pushToGuest is produced by useAppWebview, which itself needs handleGuestMessage — + // so we can't put it in handleGuestMessage's useCallback deps (circular). Instead + // route guest replies through a ref that always points at the latest pushToGuest. + // Without this, the callback closes over the first-render pushToGuest (which is a + // no-op until dom-ready) and reply messages never reach the guest. + const pushToGuestRef = useRef(() => {}); + + const handleGuestMessage = useCallback( + async (data) => { + const push = pushToGuestRef.current; + switch (data?.type) { + case 'ready': + break; + case 'log': + console.log('[app]', ...(data.args || [])); + break; + case 'setRuntimeVariable': + if (typeof data.key === 'string' && data.key.length) { + dispatch( + appSetRuntimeVariable({ collectionUid: collection.uid, key: data.key, value: data.value }) + ); + } + break; + case 'listRequests': { + push({ type: 'reply', replyId: data.replyId, result: listRequestSummaries(collection) }); + break; + } + case 'runRequest': { + try { + const res = await runRequestByPath(data.pathname, data.overrides); + push({ type: 'reply', replyId: data.replyId, result: res }); + } catch (err) { + push({ type: 'reply', replyId: data.replyId, error: err?.message || 'runRequest failed' }); + } + break; + } + default: + break; + } + }, + [dispatch, collection, runRequestByPath] + ); + + const { domReady, pushToGuest, webviewRef } = useAppWebview(handleGuestMessage); + pushToGuestRef.current = pushToGuest; + + const stateRef = useRef(); + stateRef.current = { theme: displayedTheme, variables, collection: collectionInfo }; + useEffect(() => { + if (!domReady) return; + pushToGuest({ type: 'state', ...stateRef.current }); + }, [domReady, pushToGuest]); + + useEffect(() => { + pushToGuest({ type: 'theme', theme: displayedTheme }); + }, [displayedTheme, pushToGuest]); + + useEffect(() => { + pushToGuest({ type: 'variables', variables }); + }, [variables, pushToGuest]); + + useEffect(() => { + pushToGuest({ type: 'collection', collection: collectionInfo }); + }, [collectionInfo, pushToGuest]); + + return ( + +
+ App - {item.name} +
+ + +
+
+ + {view === 'code' ? ( +
+ + +
+ ) : code && code.trim().length ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+ ); +}; + +export default CollectionApp; diff --git a/packages/bruno-app/src/components/RequestPane/AppCodeEditor/index.js b/packages/bruno-app/src/components/RequestPane/AppCodeEditor/index.js index 320d72761..266f5892d 100644 --- a/packages/bruno-app/src/components/RequestPane/AppCodeEditor/index.js +++ b/packages/bruno-app/src/components/RequestPane/AppCodeEditor/index.js @@ -1,8 +1,10 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import get from 'lodash/get'; import { useDispatch, useSelector } from 'react-redux'; import CodeEditor from 'components/CodeEditor'; import ToggleSwitch from 'components/ToggleSwitch'; +import AIAssist from 'components/AIAssist'; +import { buildRequestContextFromItem } from 'utils/ai'; import { updateAppCode, toggleAppMode } from 'providers/ReduxStore/slices/collections'; import { saveRequest } from 'providers/ReduxStore/slices/collections/actions'; import { useTheme } from 'providers/Theme'; @@ -24,6 +26,8 @@ const AppCodeEditor = ({ item, collection }) => { const onSave = () => dispatch(saveRequest(item.uid, collection.uid)); + const requestContext = useMemo(() => buildRequestContextFromItem(item), [item]); + return (
@@ -36,7 +40,7 @@ const AppCodeEditor = ({ item, collection }) => {
-
+
{ onSave={onSave} mode="javascript" /> +
); diff --git a/packages/bruno-app/src/components/RequestTabPanel/index.js b/packages/bruno-app/src/components/RequestTabPanel/index.js index 359e962be..458464ce1 100644 --- a/packages/bruno-app/src/components/RequestTabPanel/index.js +++ b/packages/bruno-app/src/components/RequestTabPanel/index.js @@ -22,6 +22,7 @@ import { DocExplorer } from '@usebruno/graphql-docs'; import FileEditor from 'components/FileEditor'; import AppView from 'components/AppView'; +import CollectionApp from 'components/CollectionApp'; import StyledWrapper from './StyledWrapper'; import FolderSettings from 'components/FolderSettings'; import { getGlobalEnvironmentVariables, getGlobalEnvironmentVariablesMasked } from 'utils/collections/index'; @@ -495,6 +496,18 @@ const RequestTabPanel = () => { ); } + // Standalone app item (collection- or folder-level). Renders as its own tab + // with a Code/Preview toggle and its own ctx API surface. + if (item.type === 'app') { + return ( + + + + + + ); + } + const appEnabled = item.draft ? get(item, 'draft.app.enabled', false) : get(item, 'app.enabled', false); if (appEnabled) { const appCode = item.draft ? get(item, 'draft.app.code', '') : get(item, 'app.code', ''); diff --git a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js index e79066250..15df5e719 100644 --- a/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js +++ b/packages/bruno-app/src/components/RequestTabs/RequestTab/index.js @@ -16,6 +16,7 @@ import ConfirmCloseEnvironment from 'components/Environments/ConfirmCloseEnviron import RequestTabNotFound from './RequestTabNotFound'; import RequestTabLoading from './RequestTabLoading'; import SpecialTab from './SpecialTab'; +import { IconApps } from '@tabler/icons'; import StyledWrapper from './StyledWrapper'; import MenuDropdown from 'ui/MenuDropdown'; import CloneCollectionItem from 'components/Sidebar/Collections/Collection/CollectionItem/CloneCollectionItem/index'; @@ -576,9 +577,15 @@ const RequestTab = ({ tab, collection, tabIndex, collectionRequestTabs, folderUi } }} > - - {method} - + {item.type === 'app' ? ( + + + + ) : ( + + {method} + + )} {item.name} diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemIcon/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemIcon/index.js index 30927cfc4..251b1fb82 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemIcon/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/CollectionItemIcon/index.js @@ -1,5 +1,5 @@ import RequestMethod from '../RequestMethod'; -import { IconLoader2, IconAlertTriangle, IconAlertCircle } from '@tabler/icons'; +import { IconLoader2, IconAlertTriangle, IconAlertCircle, IconApps } from '@tabler/icons'; import StyledWrapper from './StyledWrapper'; const CollectionItemIcon = ({ item }) => { @@ -15,6 +15,10 @@ const CollectionItemIcon = ({ item }) => { return ; } + if (item?.type === 'app') { + return ; + } + return ; }; diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js index 62b2b32c4..0ca06312b 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/CollectionItem/index.js @@ -18,7 +18,8 @@ import { IconTrash, IconSettings, IconInfoCircle, - IconTerminal2 + IconTerminal2, + IconApps } from '@tabler/icons'; import { useSelector, useDispatch } from 'react-redux'; import { addTab, focusTab, makeTabPermanent } from 'providers/ReduxStore/slices/tabs'; @@ -29,6 +30,7 @@ import { uuid } from 'utils/common'; import { copyRequest, setFocusedSidebarPath } from 'providers/ReduxStore/slices/app'; import NewRequest from 'components/Sidebar/NewRequest'; import NewFolder from 'components/Sidebar/NewFolder'; +import NewApp from 'components/Sidebar/NewApp'; import RenameCollectionItem from './RenameCollectionItem'; import CloneCollectionItem from './CloneCollectionItem'; import DeleteCollectionItem from './DeleteCollectionItem'; @@ -95,6 +97,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) const [generateCodeItemModalOpen, setGenerateCodeItemModalOpen] = useState(false); const [newRequestModalOpen, setNewRequestModalOpen] = useState(false); const [newFolderModalOpen, setNewFolderModalOpen] = useState(false); + const [newAppModalOpen, setNewAppModalOpen] = useState(false); const [runCollectionModalOpen, setRunCollectionModalOpen] = useState(false); const [itemInfoModalOpen, setItemInfoModalOpen] = useState(false); const [examplesExpanded, setExamplesExpanded] = useState(false); @@ -257,7 +260,8 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) // scroll to the active tab setTimeout(scrollToTheActiveTab, 50); const isRequest = isItemARequest(item); - if (isRequest) { + const isApp = item.type === 'app'; + if (isRequest || isApp) { if (isTabForItemPresent) { dispatch( focusTab({ @@ -270,7 +274,7 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) addTab({ uid: item.uid, collectionUid: collectionUid, - requestPaneTab: getDefaultRequestPaneTab(item), + ...(isRequest ? { requestPaneTab: getDefaultRequestPaneTab(item) } : {}), type: item.type, pathname: item.pathname }) @@ -351,6 +355,12 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) label: 'New Folder', onClick: () => setNewFolderModalOpen(true) }, + { + id: 'new-app', + leftSection: IconApps, + label: 'New App', + onClick: () => setNewAppModalOpen(true) + }, { id: 'run', leftSection: IconPlayerPlay, @@ -547,7 +557,10 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) }; const folderItems = sortByNameThenSequence(filter(item.items, (i) => isItemAFolder(i) && !i.isTransient)); - const requestItems = sortItemsBySequence(filter(item.items, (i) => isItemARequest(i) && !i.isTransient)); + // Standalone 'app' items live alongside requests in the folder listing. + const requestItems = sortItemsBySequence( + filter(item.items, (i) => (isItemARequest(i) || i.type === 'app') && !i.isTransient) + ); const showEmptyFolderMessage = isFolder && !hasSearchText && !folderItems?.length && !requestItems?.length; const emptyFolderMenuItems = createEmptyStateMenuItems({ dispatch, collection, itemUid: item.uid }); @@ -631,6 +644,9 @@ const CollectionItem = ({ item, collectionUid, collectionPathname, searchText }) {newFolderModalOpen && ( setNewFolderModalOpen(false)} /> )} + {newAppModalOpen && ( + setNewAppModalOpen(false)} /> + )} {runCollectionModalOpen && ( setRunCollectionModalOpen(false)} /> )} diff --git a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js index cf40e970f..8674a8c02 100644 --- a/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js +++ b/packages/bruno-app/src/components/Sidebar/Collections/Collection/index.js @@ -21,7 +21,8 @@ import { IconTerminal2, IconFolder, IconBook, - IconFileArrowRight + IconFileArrowRight, + IconApps } from '@tabler/icons'; import OpenAPISyncIcon from 'components/Icons/OpenAPISync'; import { toggleCollection, collapseFullCollection } from 'providers/ReduxStore/slices/collections'; @@ -32,6 +33,7 @@ import { setFocusedSidebarPath } from 'providers/ReduxStore/slices/app'; import toast from 'react-hot-toast'; import NewRequest from 'components/Sidebar/NewRequest'; import NewFolder from 'components/Sidebar/NewFolder'; +import NewApp from 'components/Sidebar/NewApp'; import CollectionItem from './CollectionItem'; import RemoveCollection from './RemoveCollection'; import MoveToWorkspace from './MoveToWorkspace'; @@ -64,6 +66,7 @@ const Collection = ({ collection, searchText }) => { const { dropdownContainerRef } = useSidebarAccordion(); const [showNewFolderModal, setShowNewFolderModal] = useState(false); const [showNewRequestModal, setShowNewRequestModal] = useState(false); + const [showNewAppModal, setShowNewAppModal] = useState(false); const [showRenameCollectionModal, setShowRenameCollectionModal] = useState(false); const [showCloneCollectionModalOpen, setShowCloneCollectionModalOpen] = useState(false); const [showShareCollectionModal, setShowShareCollectionModal] = useState(false); @@ -78,7 +81,7 @@ const Collection = ({ collection, searchText }) => { const collectionRef = useRef(null); // Only count persisted requests and folders; transients and file items // (bruno.json, .js scripts) don't affect empty state - const itemCount = collection.items?.filter((i) => !i.isTransient && (isItemARequest(i) || isItemAFolder(i))).length || 0; + const itemCount = collection.items?.filter((i) => !i.isTransient && (isItemARequest(i) || isItemAFolder(i) || i.type === 'app')).length || 0; const isCollectionFocused = useSelector(isTabForItemActive({ itemUid: collection.uid })); const { hasCopiedItems } = useSelector((state) => state.app.clipboard); @@ -334,7 +337,11 @@ const Collection = ({ collection, searchText }) => { return items.sort((a, b) => a.seq - b.seq); }; - const requestItems = sortItemsBySequence(filter(collection.items, (i) => isItemARequest(i) && !i.isTransient)); + // Standalone 'app' items sit alongside requests in the listing — both are + // file leaves that share the seq-based ordering. + const requestItems = sortItemsBySequence( + filter(collection.items, (i) => (isItemARequest(i) || i.type === 'app') && !i.isTransient) + ); const folderItems = sortByNameThenSequence(filter(collection.items, (i) => isItemAFolder(i) && !i.isTransient)); const showEmptyCollectionMessage = showEmptyState && !hasSearchText; @@ -359,6 +366,15 @@ const Collection = ({ collection, searchText }) => { setShowNewFolderModal(true); } }, + { + id: 'new-app', + leftSection: IconApps, + label: 'New App', + onClick: () => { + ensureCollectionIsMounted(); + setShowNewAppModal(true); + } + }, { id: 'run', leftSection: IconPlayerPlay, @@ -477,6 +493,7 @@ const Collection = ({ collection, searchText }) => { {showNewRequestModal && setShowNewRequestModal(false)} />} {showNewFolderModal && setShowNewFolderModal(false)} />} + {showNewAppModal && setShowNewAppModal(false)} />} {showRenameCollectionModal && ( setShowRenameCollectionModal(false)} /> )} diff --git a/packages/bruno-app/src/components/Sidebar/NewApp/index.js b/packages/bruno-app/src/components/Sidebar/NewApp/index.js new file mode 100644 index 000000000..250f3ca7b --- /dev/null +++ b/packages/bruno-app/src/components/Sidebar/NewApp/index.js @@ -0,0 +1,87 @@ +import React from 'react'; +import { useFormik } from 'formik'; +import * as Yup from 'yup'; +import toast from 'react-hot-toast'; +import { useDispatch, useSelector } from 'react-redux'; +import Modal from 'components/Modal'; +import { newApp } from 'providers/ReduxStore/slices/collections/actions'; +import { sanitizeName, validateName, validateNameError } from 'utils/common/regex'; + +const NewApp = ({ collectionUid, item, onClose }) => { + const dispatch = useDispatch(); + + const collection = useSelector((state) => + state.collections.collections?.find((c) => c.uid === collectionUid) + ); + + const formik = useFormik({ + enableReinitialize: true, + initialValues: { appName: '' }, + validationSchema: Yup.object({ + appName: Yup.string() + .trim() + .min(1, 'App name is required') + .max(255, 'Must be 255 characters or less') + .test('valid-name', validateNameError, (value) => validateName(value || '')) + .required('App name is required') + }), + onSubmit: (values) => { + const name = values.appName.trim(); + dispatch( + newApp({ + appName: name, + filename: sanitizeName(name), + collectionUid, + itemUid: item ? item.uid : null + }) + ) + .then(() => { + toast.success('App created'); + onClose(); + }) + .catch((err) => toast.error(err?.message || 'Failed to create app')); + } + }); + + const onSubmit = () => formik.handleSubmit(); + + return ( + +
+ + + {formik.touched.appName && formik.errors.appName ? ( +
{formik.errors.appName}
+ ) : ( +
+ Creates a standalone app file in {item ? 'this folder' : `collection "${collection?.name || ''}"`}. +
+ )} +
+
+ ); +}; + +export default NewApp; diff --git a/packages/bruno-app/src/providers/ReduxStore/middlewares/draft/middleware.js b/packages/bruno-app/src/providers/ReduxStore/middlewares/draft/middleware.js index 2ee601716..6942f3a42 100644 --- a/packages/bruno-app/src/providers/ReduxStore/middlewares/draft/middleware.js +++ b/packages/bruno-app/src/providers/ReduxStore/middlewares/draft/middleware.js @@ -43,6 +43,7 @@ const actionsToIntercept = [ 'collections/deleteVar', 'collections/moveVar', 'collections/updateRequestDocs', + 'collections/updateAppCode', 'collections/runRequestEvent', // TODO: This doesn't necessarily related to a draft state, need to rethink. // Folder-level actions diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js index db0da6353..843ffa5ef 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/actions.js @@ -1767,6 +1767,101 @@ export const newWsRequest = (params) => (dispatch, getState) => { }); }; +const DEFAULT_APP_STARTER = ` + + + + + App + + + +

Hello from a Bruno app

+

This app can list request in the collection.

+ +
click "List requests"
+ + + +`; + +export const newApp = (params) => (dispatch, getState) => { + const { appName, filename, collectionUid, itemUid } = params; + + return new Promise((resolve, reject) => { + const state = getState(); + const collection = findCollectionByUid(state.collections.collections, collectionUid); + if (!collection) { + return reject(new Error('Collection not found')); + } + + const item = { + uid: uuid(), + type: 'app', + name: appName, + filename, + app: { code: DEFAULT_APP_STARTER }, + settings: {} + }; + + const resolvedFilename = resolveRequestFilename(filename, collection.format); + + const selectedItem = itemUid ? findItemInCollection(collection, itemUid) : null; + let parent = collection; + if (selectedItem) { + parent = isItemAFolder(selectedItem) + ? selectedItem + : (findParentItemInCollection(collection, selectedItem.uid) || collection); + } + const parentPath = parent.pathname; + const siblings = parent.items || []; + + const dupe = find( + siblings, + (i) => i.type !== 'folder' && trim(i.filename) === trim(resolvedFilename) + ); + if (dupe) { + return reject(new Error('An item with this name already exists in this folder')); + } + + const orderableSiblings = filter( + siblings, + (i) => isItemAFolder(i) || isItemARequest(i) || i.type === 'app' + ); + item.seq = orderableSiblings.length + 1; + + const fullName = path.join(parentPath, resolvedFilename); + const { ipcRenderer } = window; + + ipcRenderer + .invoke('renderer:new-request', fullName, item) + .then(() => { + dispatch( + insertTaskIntoQueue({ + uid: uuid(), + type: 'OPEN_REQUEST', + collectionUid, + itemPathname: fullName + }) + ); + resolve(); + }) + .catch(reject); + }); +}; + export const loadGrpcMethodsFromReflection = (item, collectionUid, url) => async (dispatch, getState) => { const state = getState(); const collection = findCollectionByUid(state.collections.collections, collectionUid); diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js index 0818b5e26..a65dbdb54 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js @@ -3390,7 +3390,8 @@ export const collectionsSlice = createSlice({ if (!collection) return; const item = findItemInCollection(collection, action.payload.itemUid); - if (item && isItemARequest(item)) { + // Accept both request-attached apps and standalone 'app' items. + if (item && (isItemARequest(item) || item.type === 'app')) { if (!item.draft) { item.draft = cloneDeep(item); } diff --git a/packages/bruno-app/src/utils/collections/index.js b/packages/bruno-app/src/utils/collections/index.js index 73eeba8af..20c8ea776 100644 --- a/packages/bruno-app/src/utils/collections/index.js +++ b/packages/bruno-app/src/utils/collections/index.js @@ -691,6 +691,19 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {} export const transformRequestToSaveToFilesystem = (item) => { const _item = item.draft ? item.draft : item; + // Standalone app items have no request, emit only what the filestore needs. + if (_item.type === 'app') { + return { + uid: _item.uid, + type: 'app', + name: _item.name, + seq: _item.seq, + tags: _item.tags, + settings: _item.settings, + app: { code: _item.app?.code || '' } + }; + } + // Transform examples to ensure status is a number const transformExamples = (examples = []) => { return map(examples, (example) => ({ diff --git a/packages/bruno-electron/src/ipc/ai/script-prompts.js b/packages/bruno-electron/src/ipc/ai/script-prompts.js index debcd81f0..df6a92a4f 100644 --- a/packages/bruno-electron/src/ipc/ai/script-prompts.js +++ b/packages/bruno-electron/src/ipc/ai/script-prompts.js @@ -142,6 +142,85 @@ Do NOT use \`test()\` or \`expect()\` — those belong in the Tests tab. ${COMMON_OUTPUT_RULES}`, + 'app-request': `You are an AI assistant that writes Bruno App code attached to an HTTP request. + +## App Context + +A Bruno App is a self-contained UI that runs inside a sandboxed . The user's code is injected into the body of a generated HTML document at runtime — it must be fully independent. Plain HTML, CSS, and JavaScript only. No bundler, no module imports, no JSX, no React import statements (React is allowed only if loaded inline via `; + +const ECHO_JSON_URL = 'http://localhost:8081/api/echo/json'; + +test.describe('Collection apps', () => { + test('Create from collection menu → appears in sidebar → opens as own tab with Code/Preview', async ({ page, createTmpDir }) => { + const collectionPath = await createTmpDir('collection-apps-create'); + await createCollection(page, 'col-apps-create', collectionPath); + + await createApp(page, 'My App', { collectionName: 'col-apps-create' }); + + await test.step('Sidebar item with app icon appears', async () => { + await expect(page.locator('.collection-item-name').filter({ hasText: 'My App' })).toBeVisible(); + }); + + await test.step('Tab opens, Code/Preview toggle works', async () => { + await expect(page.getByTestId('collection-app')).toBeVisible({ timeout: 5000 }); + await expect(page.getByTestId('collection-app-view-preview')).toHaveClass(/active/); + await selectAppView(page, 'code'); + await expect(page.getByTestId('collection-app-code')).toBeVisible(); + await expect(page.getByTestId('collection-app-view-code')).toHaveClass(/active/); + await selectAppView(page, 'preview'); + await expect(page.getByTestId('collection-app-preview').locator('webview')).toBeVisible(); + }); + }); + + test('ctx.listRequests sees every request in the collection', async ({ page, electronApp, createTmpDir }) => { + const collectionPath = await createTmpDir('collection-apps-list'); + await createCollection(page, 'col-apps-list', collectionPath); + await createRequest(page, 'alpha', 'col-apps-list', { url: 'http://localhost:8081/ping' }); + await createRequest(page, 'beta', 'col-apps-list', { url: 'http://localhost:8081/ping' }); + + await createApp(page, 'List App', { collectionName: 'col-apps-list' }); + await setCollectionAppCode(page, CTX_APP); + await saveRequest(page); + + await selectAppView(page, 'preview'); + await waitForGuestReady(electronApp, 'col-apps-list'); + + await guestEval(electronApp, 'void window.__listRequests()', 'col-apps-list'); + await expect + .poll(() => guestEval(electronApp, `document.getElementById('out') && document.getElementById('out').getAttribute('data-result')`, 'col-apps-list'), { timeout: 15000 }) + .toBe(JSON.stringify(['alpha', 'beta'])); + }); + + test('ctx.runRequest executes a request by pathname and reflects the response', async ({ page, electronApp, createTmpDir }) => { + const collectionPath = await createTmpDir('collection-apps-run'); + await createCollection(page, 'col-apps-run', collectionPath); + await createRequest(page, 'echo', 'col-apps-run', { method: 'POST', url: ECHO_JSON_URL }); + + // Body referencing {{q}} so the override turns into the response payload. + await page.locator('.collection-item-name').filter({ hasText: 'echo' }).click(); + await selectRequestBodyMode(page, 'JSON'); + const bodyEditor = page.getByTestId('request-body-editor').locator('.CodeMirror').first(); + await bodyEditor.waitFor({ state: 'visible' }); + await bodyEditor.evaluate((el) => { + const cm = (el as any).CodeMirror; + if (cm) cm.setValue('{"q":"{{q}}"}'); + }); + await saveRequest(page); + + await createApp(page, 'Runner App', { collectionName: 'col-apps-run' }); + await setCollectionAppCode(page, CTX_APP); + await saveRequest(page); + + await selectAppView(page, 'preview'); + await waitForGuestReady(electronApp, 'col-apps-run'); + + // Resolve the pathname of the 'echo' request via ctx.listRequests, then run it. + await guestEval( + electronApp, + `(async () => { + const requests = await ctx.listRequests(); + const echo = requests.find(r => r.name === 'echo'); + await window.__runEcho(echo.pathname); + })()`, + 'col-apps-run' + ); + + await expect + .poll(() => guestEval(electronApp, `document.getElementById('out') && document.getElementById('out').getAttribute('data-result')`, 'col-apps-run'), { timeout: 20000 }) + .toBe(JSON.stringify({ status: 200, q: 'echoed' })); + }); + + test('ctx.setRuntimeVariable persists into ctx.variables', async ({ page, electronApp, createTmpDir }) => { + const collectionPath = await createTmpDir('collection-apps-vars'); + await createCollection(page, 'col-apps-vars', collectionPath); + + await createApp(page, 'Vars App', { collectionName: 'col-apps-vars' }); + await setCollectionAppCode(page, CTX_APP); + await saveRequest(page); + + await selectAppView(page, 'preview'); + await waitForGuestReady(electronApp, 'col-apps-vars'); + + await guestEval(electronApp, `ctx.setRuntimeVariable('hello', 'world')`, 'col-apps-vars'); + await expect + .poll(() => guestEval(electronApp, `ctx.variables && ctx.variables.hello`, 'col-apps-vars'), { timeout: 15000 }) + .toBe('world'); + }); + + test('ctx.collection exposes the active collection', async ({ page, electronApp, createTmpDir }) => { + const collectionPath = await createTmpDir('collection-apps-meta'); + await createCollection(page, 'col-apps-meta', collectionPath); + + await createApp(page, 'Meta App', { collectionName: 'col-apps-meta' }); + await setCollectionAppCode(page, CTX_APP); + await saveRequest(page); + + await selectAppView(page, 'preview'); + await waitForGuestReady(electronApp, 'col-apps-meta'); + + await guestEval(electronApp, 'void window.__readCollectionName()', 'col-apps-meta'); + await expect + .poll(() => guestEval(electronApp, `document.getElementById('out') && document.getElementById('out').getAttribute('data-result')`, 'col-apps-meta'), { timeout: 15000 }) + .toBe('col-apps-meta'); + }); +}); diff --git a/tests/utils/page/actions.ts b/tests/utils/page/actions.ts index 0d1cf0184..a3a9f5342 100644 --- a/tests/utils/page/actions.ts +++ b/tests/utils/page/actions.ts @@ -2027,6 +2027,55 @@ const getAppWebviewHtml = async (page: Page): Promise => { return decodeURIComponent(src.slice(comma + 1)); }; +/** + * Create a standalone (collection-level or folder-level) app via the sidebar + * context menu. Opens the new tab once created. + * @param page - The page object + * @param appName - Name to give the new app + * @param parent - Either `{ collectionName }` for a collection-level app, + * or `{ collectionName, folderName }` for a folder-level app. + */ +const createApp = async ( + page: Page, + appName: string, + parent: { collectionName: string; folderName?: string } +) => { + await test.step(`Create app "${appName}" in ${parent.folderName ? `folder "${parent.folderName}"` : `collection "${parent.collectionName}"`}`, async () => { + const locators = buildCommonLocators(page); + + if (parent.folderName) { + const collectionScope = locators.sidebar.collectionScope(parent.collectionName); + const folderRow = collectionScope.locator('.collection-item-name').filter({ hasText: parent.folderName }); + await folderRow.hover(); + await folderRow.locator('.menu-icon').click(); + } else { + await locators.sidebar.collection(parent.collectionName).hover(); + const collectionAction = locators.actions.collectionActions(parent.collectionName); + await expect(collectionAction).toBeVisible({ timeout: 2000 }); + await collectionAction.click(); + } + + await page.locator('.tippy-box:visible .dropdown-item').filter({ hasText: 'New App' }).click(); + + const modal = page.locator('.bruno-modal').filter({ hasText: 'New App' }); + await expect(modal).toBeVisible({ timeout: 5000 }); + await modal.locator('input[name="appName"]').fill(appName); + await modal.getByRole('button', { name: 'Create', exact: true }).click(); + await expect(modal).toBeHidden({ timeout: 5000 }); + }); +}; + +/** + * Switch the CollectionApp tab between Code and Preview views. + * @param page - The page object + * @param view - 'code' | 'preview' + */ +const selectAppView = async (page: Page, view: 'code' | 'preview') => { + await test.step(`Switch collection app to "${view}"`, async () => { + await page.getByTestId(`collection-app-view-${view}`).click(); + }); +}; + /** * Rename a websocket message by double-clicking its label and typing a new name. * @param page - The page object @@ -2168,6 +2217,8 @@ export { exitApp, selectViewMode, getAppWebviewHtml, + createApp, + selectAppView, renameWsMessage };