Skip to content

Commit ea00e2b

Browse files
committed
fix(web): scope persisted terminals to active ssh project
1 parent 3e95e3f commit ea00e2b

12 files changed

Lines changed: 383 additions & 91 deletions

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,8 +152,11 @@ const useReadySideEffects = (args: ReadySideEffectsArgs) => {
152152
useReadyResetEffects(args)
153153
useSshLink({
154154
actionContext: args.actionContext,
155+
activeTerminalSessionId: args.state.activeTerminalSessionId,
155156
busyLabel: args.state.busyLabel,
156-
dashboard: args.dashboard
157+
dashboard: args.dashboard,
158+
selectTerminalSession: args.state.selectTerminalSession,
159+
terminalSessions: args.state.terminalSessions
157160
})
158161
useReadyAutoloadEffects(args)
159162
useReadyShortcutEffects(args)

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

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { MainPanels } from "./app-ready-main-panels.js"
1919
import { Box, Text } from "./elements.js"
2020
import type { BrowserMenuTag } from "./menu.js"
2121
import type { BrowserScreen } from "./screen.js"
22+
import { visibleTerminalWorkspaceState } from "./terminal-state.js"
2223
import type { ActiveTerminalSession } from "./terminal.js"
2324
import type { ViewportLayout } from "./viewport-layout.js"
2425

@@ -132,6 +133,13 @@ const HeaderMessage = ({ message }: Pick<ReadyLayoutProps, "message">): JSX.Elem
132133
? null
133134
: <Text fg="#f6d27b" marginTop="4px" wrap="truncate">message: {message}</Text>
134135

136+
const hasVisibleTerminalWorkspace = (
137+
{ activeTerminalSessionId, terminalSessions }: Pick<
138+
ReadyLayoutProps,
139+
"activeTerminalSessionId" | "terminalSessions"
140+
>
141+
): boolean => visibleTerminalWorkspaceState({ activeTerminalSessionId, terminalSessions }).terminalSessions.length > 0
142+
135143
const StatusHeader = (
136144
{ busyLabel, dashboard, message, viewportLayout }: Pick<
137145
ReadyLayoutProps,
@@ -155,8 +163,13 @@ const StatusHeader = (
155163
)
156164

157165
export const ReadyLayout = ({ busyLabel, message, ...props }: ReadyLayoutProps): JSX.Element => (
158-
props.terminalSessions.length === 0
166+
hasVisibleTerminalWorkspace(props)
159167
? (
168+
<Box flexDirection="column" height="100%" minHeight={0} overflow="hidden" padding={1} width="100%">
169+
<MainPanels {...props} />
170+
</Box>
171+
)
172+
: (
160173
<Box flexDirection="column" height="100%" minHeight={0} overflow="hidden" padding={1} width="100%">
161174
<StatusHeader
162175
busyLabel={busyLabel}
@@ -167,9 +180,4 @@ export const ReadyLayout = ({ busyLabel, message, ...props }: ReadyLayoutProps):
167180
<MainPanels {...props} />
168181
</Box>
169182
)
170-
: (
171-
<Box flexDirection="column" height="100%" minHeight={0} overflow="hidden" padding={1} width="100%">
172-
<MainPanels {...props} />
173-
</Box>
174-
)
175183
)

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

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { PortForwardPanel } from "./panel-port-forwards.js"
1414
import { ProjectDetailsPanel } from "./panel-project-details.js"
1515
import { TaskPanel } from "./panel-tasks.js"
1616
import { OutputPanel, ProjectListPanel } from "./panels.js"
17+
import { visibleTerminalWorkspaceState } from "./terminal-state.js"
1718

1819
export type MainPanelsProps = Omit<ReadyLayoutProps, "busyLabel" | "message">
1920

@@ -273,8 +274,18 @@ const OutputScreen = (props: MainPanelsProps): JSX.Element => (
273274
)
274275

275276
export const MainPanels = (props: MainPanelsProps): JSX.Element => {
276-
if (props.terminalSessions.length > 0) {
277-
return <TerminalScreen {...props} />
277+
const visibleTerminalWorkspace = visibleTerminalWorkspaceState({
278+
activeTerminalSessionId: props.activeTerminalSessionId,
279+
terminalSessions: props.terminalSessions
280+
})
281+
if (visibleTerminalWorkspace.terminalSessions.length > 0) {
282+
return (
283+
<TerminalScreen
284+
{...props}
285+
activeTerminalSessionId={visibleTerminalWorkspace.activeTerminalSessionId}
286+
terminalSessions={visibleTerminalWorkspace.terminalSessions}
287+
/>
288+
)
278289
}
279290
if (props.activeScreen.tag === "Menu") {
280291
return <MainMenuRoute {...props} />

packages/app/src/web/app-ready-ssh-link-hook.ts

Lines changed: 107 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,34 @@ import type { BrowserActionContext } from "./actions-shared.js"
55
import type { DashboardData } from "./api.js"
66
import { browserMenuIndex } from "./menu.js"
77
import { projectPickerScreen } from "./screen.js"
8+
import { reusableProjectTerminalSessionId } from "./terminal-state.js"
9+
import type { ActiveTerminalSession } from "./terminal.js"
810

911
type SshLinkArgs = {
1012
readonly actionContext: BrowserActionContext
13+
readonly activeTerminalSessionId: string | null
1114
readonly busyLabel: string | null
1215
readonly dashboard: DashboardData
16+
readonly selectTerminalSession: (sessionId: string) => void
17+
readonly terminalSessions: ReadonlyArray<ActiveTerminalSession>
1318
}
1419

1520
const sshPathPrefix = "/ssh/"
21+
type ConnectTimerRef = { current: ReturnType<typeof globalThis.setTimeout> | null }
22+
type SshTokenRef = { current: string | null }
23+
type DashboardProject = DashboardData["projects"][number]
24+
type SshLinkEffectArgs = Omit<SshLinkArgs, "dashboard"> & {
25+
readonly connectTimerRef: ConnectTimerRef
26+
readonly handledTokenRef: SshTokenRef
27+
readonly projects: DashboardData["projects"]
28+
}
29+
30+
const clearConnectTimer = (connectTimerRef: ConnectTimerRef): void => {
31+
if (connectTimerRef.current !== null) {
32+
globalThis.clearTimeout(connectTimerRef.current)
33+
connectTimerRef.current = null
34+
}
35+
}
1636

1737
const readSshLinkToken = (): string | null => {
1838
const url = new URL(globalThis.location.href)
@@ -25,48 +45,99 @@ const readSshLinkToken = (): string | null => {
2545
return queryToken.length === 0 ? null : queryToken
2646
}
2747

28-
export const useSshLink = ({ actionContext, busyLabel, dashboard }: SshLinkArgs) => {
48+
const findProjectBySshToken = (
49+
projects: DashboardData["projects"],
50+
token: string
51+
): DashboardProject | undefined =>
52+
projects.find((candidate) => candidate.projectKey === token || candidate.id === token)
53+
54+
const showProjectTerminalScreen = (actionContext: BrowserActionContext, projectId: string): void => {
55+
actionContext.setSelectedMenuIndex(browserMenuIndex("Select"))
56+
actionContext.setActiveScreen(projectPickerScreen())
57+
actionContext.setSelectedProjectId(projectId)
58+
}
59+
60+
const selectReusableProjectTerminal = (args: SshLinkEffectArgs, project: DashboardProject): boolean => {
61+
const reusableSessionId = reusableProjectTerminalSessionId(
62+
args.terminalSessions,
63+
args.activeTerminalSessionId,
64+
project.id
65+
)
66+
if (reusableSessionId === null) {
67+
return false
68+
}
69+
clearConnectTimer(args.connectTimerRef)
70+
args.selectTerminalSession(reusableSessionId)
71+
args.actionContext.setMessage(`Opened existing SSH terminal for ${project.displayName}.`)
72+
return true
73+
}
74+
75+
const scheduleProjectTerminalConnect = (args: SshLinkEffectArgs, projectId: string): void => {
76+
clearConnectTimer(args.connectTimerRef)
77+
args.connectTimerRef.current = globalThis.setTimeout(() => {
78+
args.connectTimerRef.current = null
79+
connectProjectById(projectId, args.actionContext)
80+
}, 0)
81+
}
82+
83+
const handleSshLinkEffect = (args: SshLinkEffectArgs): void => {
84+
const token = readSshLinkToken()
85+
if (token === null) {
86+
clearConnectTimer(args.connectTimerRef)
87+
args.handledTokenRef.current = null
88+
return
89+
}
90+
if (args.busyLabel !== null || args.handledTokenRef.current === token) {
91+
return
92+
}
93+
94+
const project = findProjectBySshToken(args.projects, token)
95+
if (project === undefined) {
96+
args.actionContext.setMessage(`Project link was not found: ${token}.`)
97+
return
98+
}
99+
100+
args.handledTokenRef.current = token
101+
showProjectTerminalScreen(args.actionContext, project.id)
102+
if (!selectReusableProjectTerminal(args, project)) {
103+
scheduleProjectTerminalConnect(args, project.id)
104+
}
105+
}
106+
107+
export const useSshLink = ({
108+
actionContext,
109+
activeTerminalSessionId,
110+
busyLabel,
111+
dashboard,
112+
selectTerminalSession,
113+
terminalSessions
114+
}: SshLinkArgs) => {
29115
const connectTimerRef = useRef<ReturnType<typeof globalThis.setTimeout> | null>(null)
30116
const handledTokenRef = useRef<string | null>(null)
31117
const locationSignature = `${globalThis.location.pathname}${globalThis.location.search}`
32118

33119
useEffect(() => () => {
34-
if (connectTimerRef.current !== null) {
35-
globalThis.clearTimeout(connectTimerRef.current)
36-
connectTimerRef.current = null
37-
}
120+
clearConnectTimer(connectTimerRef)
38121
}, [])
39122

40123
useEffect(() => {
41-
const token = readSshLinkToken()
42-
if (token === null) {
43-
if (connectTimerRef.current !== null) {
44-
globalThis.clearTimeout(connectTimerRef.current)
45-
connectTimerRef.current = null
46-
}
47-
handledTokenRef.current = null
48-
return
49-
}
50-
if (busyLabel !== null || handledTokenRef.current === token) {
51-
return
52-
}
53-
54-
const project = dashboard.projects.find((candidate) => candidate.projectKey === token || candidate.id === token)
55-
if (project === undefined) {
56-
actionContext.setMessage(`Project link was not found: ${token}.`)
57-
return
58-
}
59-
60-
handledTokenRef.current = token
61-
actionContext.setSelectedMenuIndex(browserMenuIndex("Select"))
62-
actionContext.setActiveScreen(projectPickerScreen())
63-
actionContext.setSelectedProjectId(project.id)
64-
if (connectTimerRef.current !== null) {
65-
globalThis.clearTimeout(connectTimerRef.current)
66-
}
67-
connectTimerRef.current = globalThis.setTimeout(() => {
68-
connectTimerRef.current = null
69-
connectProjectById(project.id, actionContext)
70-
}, 0)
71-
}, [actionContext, busyLabel, dashboard.projects, locationSignature])
124+
handleSshLinkEffect({
125+
actionContext,
126+
activeTerminalSessionId,
127+
busyLabel,
128+
connectTimerRef,
129+
handledTokenRef,
130+
projects: dashboard.projects,
131+
selectTerminalSession,
132+
terminalSessions
133+
})
134+
}, [
135+
actionContext,
136+
activeTerminalSessionId,
137+
busyLabel,
138+
dashboard.projects,
139+
locationSignature,
140+
selectTerminalSession,
141+
terminalSessions
142+
])
72143
}

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

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ type TerminalPaneProps =
3434
| "projectBrowser"
3535
>
3636
& {
37-
readonly active: boolean
3837
readonly singleSession: boolean
3938
readonly terminalSession: ActiveTerminalSession
4039
}
@@ -50,19 +49,21 @@ const resolveActiveTerminalSessionId = (
5049
sessions: ReadonlyArray<ActiveTerminalSession>,
5150
activeTerminalSessionId: string | null
5251
): string | null => {
53-
if (sessions.some((session) => terminalSessionId(session) === activeTerminalSessionId)) {
52+
if (
53+
activeTerminalSessionId !== null &&
54+
sessions.some((session) => terminalSessionId(session) === activeTerminalSessionId)
55+
) {
5456
return activeTerminalSessionId
5557
}
56-
const first = sessions[0]
57-
return first === undefined ? null : terminalSessionId(first)
58+
return null
5859
}
5960

60-
const terminalPaneStyle = (active: boolean): CSSProperties => ({
61-
display: active ? "flex" : "none",
61+
const activeTerminalPaneStyle: CSSProperties = {
62+
display: "flex",
6263
flex: 1,
6364
minHeight: 0,
6465
overflow: "hidden"
65-
})
66+
}
6667

6768
const terminalTabLabel = (session: ActiveTerminalSession): string => session.browserProjectName ?? session.header
6869

@@ -140,7 +141,6 @@ const TerminalTabs = (
140141

141142
const TerminalPane = (
142143
{
143-
active,
144144
onOpenProjectBrowserById,
145145
onOpenProjectTerminalById,
146146
onSetActiveScreen,
@@ -155,8 +155,14 @@ const TerminalPane = (
155155
const browserProjectId = terminalSession.browserProjectId
156156
const canOpenBrowser = canOpenProjectBrowser(projectBrowser, browserProjectId)
157157
return (
158-
<div style={terminalPaneStyle(active)}>
158+
<div style={activeTerminalPaneStyle}>
159159
<TerminalPanel
160+
onAttachFailure={() => {
161+
onTerminalClose(sessionId)
162+
if (singleSession) {
163+
onSetActiveScreen(terminalReturnScreen(terminalSession))
164+
}
165+
}}
160166
onClose={() => {
161167
requestTerminalSessionClose(terminalSession.closePath)
162168
onTerminalClose(sessionId)
@@ -186,6 +192,7 @@ export const TerminalScreen = (props: TerminalScreenProps): JSX.Element | null =
186192
return null
187193
}
188194
const activeSessionId = resolveActiveTerminalSessionId(props.terminalSessions, props.activeTerminalSessionId)
195+
const activeSession = props.terminalSessions.find((session) => terminalSessionId(session) === activeSessionId)
189196
return (
190197
<Box flexDirection="column" flexGrow={1} gap={1} minHeight={0} overflow="hidden">
191198
<TerminalTabs
@@ -195,23 +202,21 @@ export const TerminalScreen = (props: TerminalScreenProps): JSX.Element | null =
195202
terminalSessions={props.terminalSessions}
196203
/>
197204
<Box flexDirection="column" flexGrow={1} minHeight={0} overflow="hidden">
198-
{props.terminalSessions.map((terminalSession) => {
199-
const sessionId = terminalSessionId(terminalSession)
200-
return (
205+
{activeSession === undefined
206+
? null
207+
: (
201208
<TerminalPane
202-
active={sessionId === activeSessionId}
203-
key={sessionId}
209+
key={terminalSessionId(activeSession)}
204210
onOpenProjectBrowserById={props.onOpenProjectBrowserById}
205211
onOpenProjectTerminalById={props.onOpenProjectTerminalById}
206212
onSetActiveScreen={props.onSetActiveScreen}
207213
onTerminalClose={props.onTerminalClose}
208214
onTerminalMessage={props.onTerminalMessage}
209215
projectBrowser={props.projectBrowser}
210216
singleSession={props.terminalSessions.length === 1}
211-
terminalSession={terminalSession}
217+
terminalSession={activeSession}
212218
/>
213-
)
214-
})}
219+
)}
215220
</Box>
216221
</Box>
217222
)

packages/app/src/web/app-ready-terminal-state-hook.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { JsonObject, JsonValue } from "../shared/json-schema.js"
88
import {
99
activeTerminalSession,
1010
addTerminalSessionState,
11+
deactivateTerminalWorkspaceState,
1112
emptyTerminalWorkspaceState,
1213
removeTerminalSessionState,
1314
selectTerminalSessionState,
@@ -207,7 +208,7 @@ const readStoredTerminalWorkspace = (): TerminalWorkspaceState => {
207208
}
208209
const parsed = Either.getOrNull(ParseResult.decodeUnknownEither(JsonValueFromStringSchema)(raw))
209210
const decoded = decodeStoredTerminalWorkspace(parsed ?? undefined)
210-
return decoded ?? emptyTerminalWorkspaceState
211+
return decoded === null ? emptyTerminalWorkspaceState : deactivateTerminalWorkspaceState(decoded)
211212
}
212213
})
213214
)
@@ -256,7 +257,7 @@ export const useTerminalWorkspaceState = (): TerminalWorkspaceReadyState => {
256257
setTerminalWorkspace((state) => addTerminalSessionState(state, session))
257258
}, [])
258259
const closeTerminalSession = useCallback((sessionId: string) => {
259-
setTerminalWorkspace((state) => removeTerminalSessionState(state, sessionId))
260+
setTerminalWorkspace((state) => removeTerminalSessionState(state, sessionId, { activateNeighbor: false }))
260261
}, [])
261262
const selectTerminalSession = useCallback((sessionId: string) => {
262263
setTerminalWorkspace((state) => selectTerminalSessionState(state, sessionId))

0 commit comments

Comments
 (0)