Skip to content

Commit a28bdac

Browse files
authored
Merge pull request #270 from konard/issue-269-2e25dfa54dec
fix(web): restore SSH session toolbar after page reload
2 parents d26735b + 3583293 commit a28bdac

7 files changed

Lines changed: 618 additions & 109 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@prover-coder-ai/docker-git": patch
3+
---
4+
5+
Restore the SSH session toolbar after a page reload on `/ssh/session/:id`. The standalone terminal view now wires Open browser, Apply, Task manager, and New terminal handlers in addition to Detach and Kill, matching the dashboard-launched terminal toolbar.
17.1 KB
Loading
9.1 KB
Loading
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
import { Effect } from "effect"
2+
import { type Dispatch, type SetStateAction, useCallback, useState } from "react"
3+
4+
import {
5+
applyProject,
6+
type ContainerTaskSnapshot,
7+
createProjectTerminalSession,
8+
loadProjectBrowser,
9+
loadProjectTaskLogs,
10+
loadProjectTasks,
11+
projectBrowserCdpUrl,
12+
projectBrowserNoVncUrl,
13+
type ProjectBrowserSession,
14+
stopProjectTask
15+
} from "./api.js"
16+
import { openUrl } from "./open-url.js"
17+
import { terminalSessionRoutePath } from "./terminal.js"
18+
19+
export type StateMessageUpdater = (message: string | null) => void
20+
21+
export type ProjectHandlers = {
22+
readonly onApplyProject: (() => void) | undefined
23+
readonly onOpenBrowser: (() => void) | undefined
24+
readonly onOpenTaskManager: (() => void) | undefined
25+
readonly onOpenTerminal: (() => void) | undefined
26+
}
27+
28+
export type TaskHandlers = {
29+
readonly logs: string
30+
readonly onIncludeDefaultChange: (include: boolean) => void
31+
readonly onLoadLogs: (pid: number) => void
32+
readonly onRefresh: () => void
33+
readonly onStopTask: (pid: number) => void
34+
readonly refreshTasks: (include: boolean) => void
35+
readonly snapshot: ContainerTaskSnapshot | null
36+
readonly taskIncludeDefault: boolean
37+
}
38+
39+
const confirmApplyProject = (label: string): boolean => {
40+
const dialog = globalThis.confirm
41+
return typeof dialog === "function"
42+
&& dialog(
43+
`Apply docker-git config to ${label}? This restarts the container and ends active SSH sessions and in-container browsers.`
44+
)
45+
}
46+
47+
const browserStatusMessage = (browser: ProjectBrowserSession): string => {
48+
if (browser.status !== "running") {
49+
return `Browser sidecar is ${browser.status}. Enable Playwright MCP and start the project first.`
50+
}
51+
const noVncUrl = projectBrowserNoVncUrl(browser)
52+
return openUrl(noVncUrl)
53+
? `Browser opened. CDP endpoint: ${projectBrowserCdpUrl(browser)}.`
54+
: `Browser popup was blocked. Open ${noVncUrl} manually. CDP endpoint: ${projectBrowserCdpUrl(browser)}.`
55+
}
56+
57+
const runOpenBrowser = (projectId: string, setMessage: StateMessageUpdater): void => {
58+
void Effect.runPromise(
59+
loadProjectBrowser(projectId).pipe(
60+
Effect.match({
61+
onFailure: (error) => {
62+
setMessage(`Failed to open browser: ${error}`)
63+
},
64+
onSuccess: (browser) => {
65+
setMessage(browserStatusMessage(browser))
66+
}
67+
})
68+
)
69+
)
70+
}
71+
72+
const runApplyProject = (
73+
projectId: string,
74+
projectLabel: string,
75+
setMessage: StateMessageUpdater
76+
): void => {
77+
if (!confirmApplyProject(projectLabel)) {
78+
return
79+
}
80+
void Effect.runPromise(
81+
applyProject(projectId).pipe(
82+
Effect.match({
83+
onFailure: (error) => {
84+
setMessage(`Apply failed: ${error}`)
85+
},
86+
onSuccess: (applied) => {
87+
setMessage(`Applied ${applied.displayName}.`)
88+
}
89+
})
90+
)
91+
)
92+
}
93+
94+
const handleTerminalCreated = (sessionId: string, setMessage: StateMessageUpdater): void => {
95+
const targetUrl = `${globalThis.location.origin}${terminalSessionRoutePath(sessionId)}`
96+
if (!openUrl(targetUrl)) {
97+
setMessage(`New terminal popup was blocked. Open ${targetUrl} manually.`)
98+
}
99+
}
100+
101+
const runOpenTerminal = (projectKey: string, setMessage: StateMessageUpdater): void => {
102+
void Effect.runPromise(
103+
createProjectTerminalSession(projectKey).pipe(
104+
Effect.match({
105+
onFailure: (error) => {
106+
setMessage(`Failed to open new terminal: ${error}`)
107+
},
108+
onSuccess: (created) => {
109+
handleTerminalCreated(created.session.id, setMessage)
110+
}
111+
})
112+
)
113+
)
114+
}
115+
116+
export type ProjectActionHandlersArgs = {
117+
readonly onOpenTaskManagerRequest: () => void
118+
readonly projectId: string | undefined
119+
readonly projectKey: string | undefined
120+
readonly projectLabel: string
121+
readonly setMessage: StateMessageUpdater
122+
}
123+
124+
export const useProjectActionHandlers = (
125+
{ onOpenTaskManagerRequest, projectId, projectKey, projectLabel, setMessage }: ProjectActionHandlersArgs
126+
): ProjectHandlers => ({
127+
onApplyProject: projectId === undefined ? undefined : () => {
128+
runApplyProject(projectId, projectLabel, setMessage)
129+
},
130+
onOpenBrowser: projectId === undefined ? undefined : () => {
131+
runOpenBrowser(projectId, setMessage)
132+
},
133+
onOpenTaskManager: projectId === undefined ? undefined : onOpenTaskManagerRequest,
134+
onOpenTerminal: projectId === undefined || projectKey === undefined
135+
? undefined
136+
: () => {
137+
runOpenTerminal(projectKey, setMessage)
138+
}
139+
})
140+
141+
const runRefreshTasks = (
142+
projectId: string,
143+
include: boolean,
144+
setSnapshot: Dispatch<SetStateAction<ContainerTaskSnapshot | null>>,
145+
setMessage: StateMessageUpdater
146+
): void => {
147+
void Effect.runPromise(
148+
loadProjectTasks(projectId, include).pipe(
149+
Effect.match({
150+
onFailure: (error) => {
151+
setMessage(`Failed to load tasks: ${error}`)
152+
},
153+
onSuccess: (next) => {
154+
setSnapshot(next)
155+
}
156+
})
157+
)
158+
)
159+
}
160+
161+
const runStopTask = (
162+
projectId: string,
163+
pid: number,
164+
setMessage: StateMessageUpdater,
165+
onAfterStop: () => void
166+
): void => {
167+
void Effect.runPromise(
168+
stopProjectTask(projectId, pid).pipe(
169+
Effect.match({
170+
onFailure: (error) => {
171+
setMessage(`Failed to stop task ${pid}: ${error}`)
172+
},
173+
onSuccess: () => {
174+
onAfterStop()
175+
}
176+
})
177+
)
178+
)
179+
}
180+
181+
const runLoadLogs = (
182+
projectId: string,
183+
pid: number,
184+
setLogs: Dispatch<SetStateAction<string>>,
185+
setMessage: StateMessageUpdater
186+
): void => {
187+
void Effect.runPromise(
188+
loadProjectTaskLogs(projectId, pid).pipe(
189+
Effect.match({
190+
onFailure: (error) => {
191+
setMessage(`Failed to load logs for ${pid}: ${error}`)
192+
},
193+
onSuccess: (output) => {
194+
setLogs(output)
195+
}
196+
})
197+
)
198+
)
199+
}
200+
201+
export type TaskManagerHandlersArgs = {
202+
readonly projectId: string | undefined
203+
readonly setMessage: StateMessageUpdater
204+
}
205+
206+
export const useTaskManagerHandlers = (
207+
{ projectId, setMessage }: TaskManagerHandlersArgs
208+
): TaskHandlers => {
209+
const [snapshot, setSnapshot] = useState<ContainerTaskSnapshot | null>(null)
210+
const [logs, setLogs] = useState<string>("")
211+
const [taskIncludeDefault, setTaskIncludeDefault] = useState(false)
212+
213+
const refreshTasks = useCallback((include: boolean) => {
214+
if (projectId !== undefined) {
215+
runRefreshTasks(projectId, include, setSnapshot, setMessage)
216+
}
217+
}, [projectId, setMessage])
218+
219+
const onStopTask = useCallback((pid: number) => {
220+
if (projectId !== undefined) {
221+
runStopTask(projectId, pid, setMessage, () => {
222+
refreshTasks(taskIncludeDefault)
223+
})
224+
}
225+
}, [projectId, refreshTasks, setMessage, taskIncludeDefault])
226+
227+
const onLoadLogs = useCallback((pid: number) => {
228+
if (projectId !== undefined) {
229+
runLoadLogs(projectId, pid, setLogs, setMessage)
230+
}
231+
}, [projectId, setMessage])
232+
233+
const onIncludeDefaultChange = useCallback((include: boolean) => {
234+
setTaskIncludeDefault(include)
235+
refreshTasks(include)
236+
}, [refreshTasks])
237+
238+
const onRefresh = useCallback(() => {
239+
refreshTasks(taskIncludeDefault)
240+
}, [refreshTasks, taskIncludeDefault])
241+
242+
return {
243+
logs,
244+
onIncludeDefaultChange,
245+
onLoadLogs,
246+
onRefresh,
247+
onStopTask,
248+
refreshTasks,
249+
snapshot,
250+
taskIncludeDefault
251+
}
252+
}

0 commit comments

Comments
 (0)