Skip to content

Commit 0807dd8

Browse files
committed
feat(web): open project browser from ssh terminal
1 parent 8afe156 commit 0807dd8

12 files changed

Lines changed: 226 additions & 64 deletions

packages/app/src/web/actions-browser.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@ import {
66
} from "./actions-shared.js"
77
import { loadProjectBrowser, projectBrowserCdpUrl, projectBrowserNoVncUrl } from "./api.js"
88

9-
const openUrl = (url: string): void => {
9+
const openUrl = (url: string): boolean => {
1010
if (typeof globalThis.open === "function") {
11-
globalThis.open(url, "_blank", "noopener")
11+
const openedWindow = globalThis.open(url, "_blank", "noopener")
12+
return openedWindow !== null
1213
}
14+
return false
1315
}
1416

1517
export const loadSelectedProjectBrowser = (
@@ -41,6 +43,10 @@ export const openSelectedProjectBrowser = (context: BrowserActionContext) => {
4143
if (projectId === null) {
4244
return
4345
}
46+
openProjectBrowserById(projectId, context)
47+
}
48+
49+
export const openProjectBrowserById = (projectId: string, context: BrowserActionContext) => {
4450
withBusy({
4551
context,
4652
effect: loadProjectBrowser(projectId),
@@ -51,8 +57,12 @@ export const openSelectedProjectBrowser = (context: BrowserActionContext) => {
5157
context.setMessage(`Browser sidecar is ${browser.status}. Enable Playwright MCP and start the project first.`)
5258
return
5359
}
54-
openUrl(projectBrowserNoVncUrl(browser))
55-
context.setMessage(`Browser opened. CDP endpoint: ${projectBrowserCdpUrl(browser)}.`)
60+
const noVncUrl = projectBrowserNoVncUrl(browser)
61+
context.setMessage(
62+
openUrl(noVncUrl)
63+
? `Browser opened. CDP endpoint: ${projectBrowserCdpUrl(browser)}.`
64+
: `Browser popup was blocked. Open ${noVncUrl} manually. CDP endpoint: ${projectBrowserCdpUrl(browser)}.`
65+
)
5666
}
5767
})
5868
}

packages/app/src/web/actions-projects.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,8 @@ export const connectProjectById = (
122122
const encodedProjectId = encodeURIComponent(project.id)
123123
const encodedSessionId = encodeURIComponent(session.id)
124124
context.setTerminalSession({
125+
browserProjectId: project.id,
126+
browserProjectName: project.displayName,
125127
closePath: `/projects/${encodedProjectId}/terminal-sessions/${encodedSessionId}`,
126128
exitMessage: "SSH session ended.",
127129
header: `SSH terminal: ${project.displayName}`,

packages/app/src/web/actions.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export {
1414
runBrowserProjectAuthAction,
1515
submitBrowserActionPrompt
1616
} from "./actions-auth.js"
17-
export { loadSelectedProjectBrowser, openSelectedProjectBrowser } from "./actions-browser.js"
17+
export { loadSelectedProjectBrowser, openProjectBrowserById, openSelectedProjectBrowser } from "./actions-browser.js"
1818
export { closeSelectedProjectPort, loadSelectedProjectPorts, openSelectedProjectPort } from "./actions-port-forwards.js"
1919
export { loadSelectedProjectInfo } from "./actions-projects.js"
2020

packages/app/src/web/app-ready-controller.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
closeSelectedProjectPort,
55
loadSelectedProjectBrowser,
66
loadSelectedProjectPorts,
7+
openProjectBrowserById,
78
openSelectedProjectBrowser,
89
openSelectedProjectPort,
910
runBrowserMenuAction,
@@ -201,6 +202,9 @@ const bindPortForwardActions = (
201202
const bindBrowserActions = (
202203
actionContext: ReturnType<typeof createActionContext>
203204
) => ({
205+
onOpenProjectBrowserById: (projectId: string) => {
206+
openProjectBrowserById(projectId, actionContext)
207+
},
204208
onOpenProjectBrowser: () => {
205209
openSelectedProjectBrowser(actionContext)
206210
},

packages/app/src/web/app-ready-layout.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export type ReadyLayoutProps = {
4141
readonly onRunAuthAction: (index: number) => void
4242
readonly onRunProjectAuthAction: (index: number) => void
4343
readonly onOpenMenuScreen: (index: number) => void
44+
readonly onOpenProjectBrowserById: (projectId: string) => void
4445
readonly onOpenProjectBrowser: () => void
4546
readonly onOpenProjectPortForward: () => void
4647
readonly onPortForwardInputChange: (value: string) => void

packages/app/src/web/app-ready-main-panels.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,11 +259,15 @@ const OutputScreen = (props: MainPanelsProps): JSX.Element => (
259259
)
260260

261261
const TerminalScreen = (
262-
props: Pick<MainPanelsProps, "onSetActiveScreen" | "onTerminalClose" | "onTerminalMessage" | "terminalSession">
262+
props: Pick<
263+
MainPanelsProps,
264+
"onOpenProjectBrowserById" | "onSetActiveScreen" | "onTerminalClose" | "onTerminalMessage" | "terminalSession"
265+
>
263266
): JSX.Element | null => {
264267
if (props.terminalSession === null) {
265268
return null
266269
}
270+
const browserProjectId = props.terminalSession.browserProjectId
267271
const returnScreen: BrowserScreen = props.terminalSession.closePath.startsWith("/auth/")
268272
? { tag: "Auth" }
269273
: projectPickerScreen()
@@ -275,6 +279,11 @@ const TerminalScreen = (
275279
props.onTerminalClose()
276280
props.onSetActiveScreen(returnScreen)
277281
}}
282+
onOpenBrowser={browserProjectId === undefined
283+
? undefined
284+
: () => {
285+
props.onOpenProjectBrowserById(browserProjectId)
286+
}}
278287
onMessage={props.onTerminalMessage}
279288
session={props.terminalSession}
280289
/>

packages/app/src/web/app-ready.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ type ReadyLayoutRenderArgs = {
2222
readonly onCreateCancel: () => void
2323
readonly onCreateSubmit: (forceWizard?: boolean) => void
2424
readonly onOpenMenuScreen: (index: number) => void
25+
readonly onOpenProjectBrowserById: (projectId: string) => void
2526
readonly onOpenProjectBrowser: () => void
2627
readonly onCloseProjectPortForward: (targetPort: number) => void
2728
readonly onOpenProjectPortForward: () => void
@@ -49,6 +50,7 @@ const readyActionProps = (actions: ReadyLayoutRenderArgs["actions"]) => ({
4950
onCreateCancel: actions.onCreateCancel,
5051
onCreateSubmit: actions.onCreateSubmit,
5152
onOpenMenuScreen: actions.onOpenMenuScreen,
53+
onOpenProjectBrowserById: actions.onOpenProjectBrowserById,
5254
onOpenProjectBrowser: actions.onOpenProjectBrowser,
5355
onOpenProjectPortForward: actions.onOpenProjectPortForward,
5456
onPortForwardInputChange: actions.onPortForwardInputChange,

packages/app/src/web/panel-terminal.tsx

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type { ActiveTerminalSession } from "./terminal.js"
1212
type TerminalPanelProps = {
1313
readonly onClose: () => void
1414
readonly onMessage: (message: string) => void
15+
readonly onOpenBrowser?: (() => void) | undefined
1516
readonly session: ActiveTerminalSession
1617
}
1718

@@ -53,6 +54,15 @@ const closeButtonStyle: CSSProperties = {
5354
padding: "6px 10px"
5455
}
5556

57+
const headerActionsStyle: CSSProperties = {
58+
alignItems: "center",
59+
display: "flex",
60+
flexShrink: 0,
61+
flexWrap: "wrap",
62+
gap: "8px",
63+
justifyContent: "flex-end"
64+
}
65+
5666
const statusColor = (status: TerminalStatus): string => {
5767
if (status === "attached") {
5868
return "#56f39a"
@@ -69,9 +79,10 @@ const statusColor = (status: TerminalStatus): string => {
6979
const TerminalHeader = (
7080
{
7181
onClose,
82+
onOpenBrowser,
7283
session,
7384
status
74-
}: Pick<TerminalPanelProps, "onClose" | "session"> & { readonly status: TerminalStatus }
85+
}: Pick<TerminalPanelProps, "onClose" | "onOpenBrowser" | "session"> & { readonly status: TerminalStatus }
7586
): JSX.Element => (
7687
<div style={headerStyle}>
7788
<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
@@ -85,18 +96,31 @@ const TerminalHeader = (
8596
{session.subtitle}
8697
</div>
8798
</div>
88-
<button
89-
onClick={onClose}
90-
style={closeButtonStyle}
91-
type="button"
92-
>
93-
Close terminal
94-
</button>
99+
<div style={headerActionsStyle}>
100+
{session.browserProjectId === undefined || onOpenBrowser === undefined
101+
? null
102+
: (
103+
<button
104+
onClick={onOpenBrowser}
105+
style={closeButtonStyle}
106+
type="button"
107+
>
108+
Open browser
109+
</button>
110+
)}
111+
<button
112+
onClick={onClose}
113+
style={closeButtonStyle}
114+
type="button"
115+
>
116+
Close terminal
117+
</button>
118+
</div>
95119
</div>
96120
)
97121

98122
export const TerminalPanel = (
99-
{ onClose, onMessage, session }: TerminalPanelProps
123+
{ onClose, onMessage, onOpenBrowser, session }: TerminalPanelProps
100124
): JSX.Element => {
101125
const connectionRef = useRef<TerminalConnectionState>({ opened: false })
102126
const hostRef = useRef<HTMLDivElement | null>(null)
@@ -113,7 +137,7 @@ export const TerminalPanel = (
113137

114138
return (
115139
<div style={panelStyle}>
116-
<TerminalHeader onClose={onClose} session={session} status={status} />
140+
<TerminalHeader onClose={onClose} onOpenBrowser={onOpenBrowser} session={session} status={status} />
117141
<div ref={hostRef} style={bodyStyle} />
118142
</div>
119143
)

packages/app/src/web/terminal.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { resolveApiBaseUrl, trimTrailingSlash } from "./api-http.js"
77
import type { TerminalSession } from "./api-schema.js"
88

99
export type ActiveTerminalSession = {
10+
readonly browserProjectId?: string | undefined
11+
readonly browserProjectName?: string | undefined
1012
readonly closePath: string
1113
readonly exitMessage: string
1214
readonly header: string
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { describe, expect, it } from "@effect/vitest"
2+
import { Effect } from "effect"
3+
import { afterEach, beforeEach, vi } from "vitest"
4+
5+
import { openProjectBrowserById, openSelectedProjectBrowser } from "../../src/web/actions-browser.js"
6+
import type { ProjectBrowserSession } from "../../src/web/api.js"
7+
import { makeBrowserActionContext, waitForAssertion } from "./browser-action-context-fixture.js"
8+
9+
const loadProjectBrowserMock = vi.hoisted(() => vi.fn())
10+
11+
vi.mock("../../src/web/api.js", () => ({
12+
loadProjectBrowser: loadProjectBrowserMock,
13+
projectBrowserCdpUrl: (browser: { readonly cdpPath: string }) => browser.cdpPath,
14+
projectBrowserNoVncUrl: (browser: { readonly noVncPath: string }) => browser.noVncPath
15+
}))
16+
17+
const runningBrowser: ProjectBrowserSession = {
18+
cdpPath: "/api/projects/project-1/browser/cdp",
19+
cdpUrl: "ws://172.17.0.2:9222/devtools/browser/session",
20+
containerName: "dg-browser-project-1",
21+
noVncPath: "/api/projects/project-1/browser/novnc",
22+
noVncUrl: "https://172.17.0.2:6080/vnc.html",
23+
projectId: "project-1",
24+
projectKey: "octocat/hello-world",
25+
status: "running"
26+
}
27+
28+
const missingBrowser: ProjectBrowserSession = {
29+
...runningBrowser,
30+
cdpPath: "",
31+
cdpUrl: "",
32+
noVncPath: "",
33+
noVncUrl: "",
34+
status: "missing"
35+
}
36+
37+
describe("web browser actions", () => {
38+
beforeEach(() => {
39+
loadProjectBrowserMock.mockReset()
40+
})
41+
42+
afterEach(() => {
43+
vi.unstubAllGlobals()
44+
})
45+
46+
it.effect("opens a running project browser by id", () =>
47+
Effect.gen(function*(_) {
48+
const openMock = vi.fn<NonNullable<typeof globalThis.open>>(() => null)
49+
vi.stubGlobal("open", openMock)
50+
loadProjectBrowserMock.mockImplementation((projectId: string) => Effect.succeed({ ...runningBrowser, projectId }))
51+
52+
const { context, setMessage, setProjectBrowser } = makeBrowserActionContext({
53+
selectedProjectName: "octocat/hello-world"
54+
})
55+
56+
openProjectBrowserById("project-1", context)
57+
58+
yield* _(waitForAssertion(() => {
59+
expect(setProjectBrowser).toHaveBeenCalledWith(runningBrowser)
60+
}))
61+
62+
expect(openMock).toHaveBeenCalledWith("/api/projects/project-1/browser/novnc", "_blank", "noopener")
63+
expect(setMessage).toHaveBeenLastCalledWith(
64+
"Browser popup was blocked. Open /api/projects/project-1/browser/novnc manually. CDP endpoint: /api/projects/project-1/browser/cdp."
65+
)
66+
}))
67+
68+
it.effect("reports browser sidecar status instead of opening non-running browsers", () =>
69+
Effect.gen(function*(_) {
70+
const openMock = vi.fn<NonNullable<typeof globalThis.open>>(() => null)
71+
vi.stubGlobal("open", openMock)
72+
loadProjectBrowserMock.mockImplementation(() => Effect.succeed(missingBrowser))
73+
74+
const { context, setMessage, setProjectBrowser } = makeBrowserActionContext({
75+
selectedProjectId: "project-1",
76+
selectedProjectName: "octocat/hello-world"
77+
})
78+
79+
openSelectedProjectBrowser(context)
80+
81+
yield* _(waitForAssertion(() => {
82+
expect(setProjectBrowser).toHaveBeenCalledWith(missingBrowser)
83+
}))
84+
85+
expect(openMock).not.toHaveBeenCalled()
86+
expect(setMessage).toHaveBeenLastCalledWith(
87+
"Browser sidecar is missing. Enable Playwright MCP and start the project first."
88+
)
89+
}))
90+
91+
it("does not call the browser endpoint when no project is selected", () => {
92+
const { context, setMessage } = makeBrowserActionContext()
93+
94+
openSelectedProjectBrowser(context)
95+
96+
expect(loadProjectBrowserMock).not.toHaveBeenCalled()
97+
expect(setMessage).toHaveBeenLastCalledWith("No project selected.")
98+
})
99+
})

0 commit comments

Comments
 (0)