Skip to content

Commit d0d6112

Browse files
committed
fix: serialize active terminal persistence
1 parent b5b8cec commit d0d6112

2 files changed

Lines changed: 180 additions & 26 deletions

File tree

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

Lines changed: 91 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -23,60 +23,127 @@ export type TerminalWorkspaceReadyState = {
2323
readonly terminalSessions: ReadonlyArray<ActiveTerminalSession>
2424
}
2525

26-
type PersistedSelectionRef = {
27-
current: string | null
26+
type ProjectActiveTerminalSelection = {
27+
readonly projectKey: string
28+
readonly sessionId: string
2829
}
2930

31+
type ProjectActiveTerminalPersistenceRequest = ProjectActiveTerminalSelection & {
32+
readonly selectionKey: string
33+
}
34+
35+
type ProjectActiveTerminalPersistenceState = {
36+
readonly inFlightRequest: ProjectActiveTerminalPersistenceRequest | null
37+
readonly latestRequest: ProjectActiveTerminalPersistenceRequest | null
38+
readonly persistedSelectionKey: string | null
39+
}
40+
41+
type ProjectActiveTerminalPersistenceRef = {
42+
current: ProjectActiveTerminalPersistenceState
43+
}
44+
45+
type SetProjectActiveTerminalSessionEffect = ReturnType<typeof setProjectActiveTerminalSession>
46+
type ProjectActiveTerminalPersistResult = Either.Either<
47+
Effect.Effect.Success<SetProjectActiveTerminalSessionEffect>,
48+
Effect.Effect.Error<SetProjectActiveTerminalSessionEffect>
49+
>
50+
3051
export const projectActiveTerminalSelection = (
3152
active: ActiveTerminalSession | null
32-
): { readonly projectKey: string; readonly sessionId: string } | null =>
53+
): ProjectActiveTerminalSelection | null =>
3354
active?.browserProjectKey === undefined || active.pendingConnection !== undefined
3455
? null
3556
: { projectKey: active.browserProjectKey, sessionId: active.session.id }
3657

3758
const projectActiveTerminalSelectionKey = (
38-
selection: { readonly projectKey: string; readonly sessionId: string }
59+
selection: ProjectActiveTerminalSelection
3960
): string => `${selection.projectKey}\0${selection.sessionId}`
4061

41-
const clearFailedPersistedSelection = (
42-
persistedSelectionRef: PersistedSelectionRef,
43-
selectionKey: string
62+
const emptyProjectActiveTerminalPersistenceState = (): ProjectActiveTerminalPersistenceState => ({
63+
inFlightRequest: null,
64+
latestRequest: null,
65+
persistedSelectionKey: null
66+
})
67+
68+
export const createProjectActiveTerminalPersistenceRef = (): ProjectActiveTerminalPersistenceRef => ({
69+
current: emptyProjectActiveTerminalPersistenceState()
70+
})
71+
72+
const projectActiveTerminalPersistenceRequest = (
73+
selection: ProjectActiveTerminalSelection
74+
): ProjectActiveTerminalPersistenceRequest => ({
75+
...selection,
76+
selectionKey: projectActiveTerminalSelectionKey(selection)
77+
})
78+
79+
const completeProjectActiveTerminalPersistRequest = (
80+
persistedSelectionRef: ProjectActiveTerminalPersistenceRef,
81+
request: ProjectActiveTerminalPersistenceRequest,
82+
result: ProjectActiveTerminalPersistResult
4483
): Effect.Effect<void> =>
4584
Effect.sync(() => {
46-
if (persistedSelectionRef.current === selectionKey) {
47-
persistedSelectionRef.current = null
85+
if (persistedSelectionRef.current.inFlightRequest?.selectionKey !== request.selectionKey) {
86+
return
87+
}
88+
const persistedSelectionKey = Either.match(result, {
89+
onLeft: () => persistedSelectionRef.current.persistedSelectionKey,
90+
onRight: () => request.selectionKey
91+
})
92+
persistedSelectionRef.current = {
93+
...persistedSelectionRef.current,
94+
inFlightRequest: null,
95+
persistedSelectionKey
96+
}
97+
const latestRequest = persistedSelectionRef.current.latestRequest
98+
if (latestRequest !== null && latestRequest.selectionKey !== request.selectionKey) {
99+
runProjectActiveTerminalPersistRequest(persistedSelectionRef)
48100
}
49101
})
50102

51-
const persistProjectActiveTerminalSelection = (
52-
state: TerminalWorkspaceState,
53-
persistedSelectionRef: PersistedSelectionRef
103+
const runProjectActiveTerminalPersistRequest = (
104+
persistedSelectionRef: ProjectActiveTerminalPersistenceRef
54105
): void => {
55-
const active = projectActiveTerminalSelection(activeTerminalSession(state))
56-
if (active === null) {
106+
const { inFlightRequest, latestRequest, persistedSelectionKey } = persistedSelectionRef.current
107+
if (
108+
inFlightRequest !== null ||
109+
latestRequest === null ||
110+
latestRequest.selectionKey === persistedSelectionKey
111+
) {
57112
return
58113
}
59-
const selectionKey = projectActiveTerminalSelectionKey(active)
60-
if (persistedSelectionRef.current === selectionKey) {
61-
return
114+
persistedSelectionRef.current = {
115+
...persistedSelectionRef.current,
116+
inFlightRequest: latestRequest
62117
}
63-
persistedSelectionRef.current = selectionKey
64118
void Effect.runPromise(
65-
setProjectActiveTerminalSession(active.projectKey, active.sessionId).pipe(
119+
setProjectActiveTerminalSession(latestRequest.projectKey, latestRequest.sessionId).pipe(
66120
Effect.either,
67121
Effect.flatMap((result) =>
68-
Either.match(result, {
69-
onLeft: () => clearFailedPersistedSelection(persistedSelectionRef, selectionKey),
70-
onRight: () => Effect.void
71-
})
122+
completeProjectActiveTerminalPersistRequest(persistedSelectionRef, latestRequest, result)
72123
)
73124
)
74125
)
75126
}
76127

128+
export const persistProjectActiveTerminalSelection = (
129+
state: TerminalWorkspaceState,
130+
persistedSelectionRef: ProjectActiveTerminalPersistenceRef
131+
): void => {
132+
const active = projectActiveTerminalSelection(activeTerminalSession(state))
133+
if (active === null) {
134+
return
135+
}
136+
const latestRequest = projectActiveTerminalPersistenceRequest(active)
137+
persistedSelectionRef.current = {
138+
...persistedSelectionRef.current,
139+
latestRequest
140+
}
141+
runProjectActiveTerminalPersistRequest(persistedSelectionRef)
142+
}
143+
77144
export const useTerminalWorkspaceState = (): TerminalWorkspaceReadyState => {
78145
const [terminalWorkspace, setTerminalWorkspace] = useState<TerminalWorkspaceState>(readStoredTerminalWorkspace)
79-
const persistedSelectionRef = useRef<string | null>(null)
146+
const persistedSelectionRef = useRef(emptyProjectActiveTerminalPersistenceState())
80147
const addTerminalSession = useCallback((session: ActiveTerminalSession) => {
81148
setTerminalWorkspace((state) => addTerminalSessionState(state, session))
82149
}, [])

packages/app/tests/docker-git/app-ready-terminal-state-hook.test.ts

Lines changed: 89 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,32 @@
1+
import { it as effectIt } from "@effect/vitest"
2+
import { Effect } from "effect"
13
import * as fc from "fast-check"
2-
import { describe, expect, it } from "vitest"
4+
import { beforeEach, describe, expect, it, vi } from "vitest"
35

4-
import { projectActiveTerminalSelection } from "../../src/web/app-ready-terminal-state-hook.js"
6+
import {
7+
createProjectActiveTerminalPersistenceRef,
8+
persistProjectActiveTerminalSelection,
9+
projectActiveTerminalSelection
10+
} from "../../src/web/app-ready-terminal-state-hook.js"
11+
import type { TerminalWorkspaceState } from "../../src/web/terminal-state.js"
512
import type { ActiveTerminalSession } from "../../src/web/terminal.js"
613

14+
const apiMock = vi.hoisted(() => ({
15+
setProjectActiveTerminalSession: vi.fn()
16+
}))
17+
18+
vi.mock("../../src/web/api.js", () => ({
19+
setProjectActiveTerminalSession: apiMock.setProjectActiveTerminalSession
20+
}))
21+
22+
type PendingPersistCall = {
23+
readonly complete: () => void
24+
readonly projectKey: string
25+
readonly sessionId: string
26+
}
27+
28+
const pendingPersistCalls: Array<PendingPersistCall> = []
29+
730
const makeSession = (
831
overrides: Partial<ActiveTerminalSession> = {}
932
): ActiveTerminalSession => ({
@@ -45,7 +68,37 @@ const makeSessionWithId = (
4568
})
4669
}
4770

71+
const makeWorkspace = (session: ActiveTerminalSession): TerminalWorkspaceState => ({
72+
activeTerminalSessionId: session.session.id,
73+
terminalSessions: [session]
74+
})
75+
76+
type ProjectActiveTerminalPersistenceRef = ReturnType<typeof createProjectActiveTerminalPersistenceRef>
77+
78+
const persistSessionSelection = (
79+
persistenceRef: ProjectActiveTerminalPersistenceRef,
80+
sessionId: string
81+
): void => {
82+
persistProjectActiveTerminalSelection(makeWorkspace(makeSessionWithId(sessionId)), persistenceRef)
83+
}
84+
4885
describe("app-ready terminal state hook", () => {
86+
beforeEach(() => {
87+
pendingPersistCalls.length = 0
88+
apiMock.setProjectActiveTerminalSession.mockReset()
89+
apiMock.setProjectActiveTerminalSession.mockImplementation((projectKey: string, sessionId: string) =>
90+
Effect.async<boolean>((resume) => {
91+
pendingPersistCalls.push({
92+
complete: () => {
93+
resume(Effect.succeed(true))
94+
},
95+
projectKey,
96+
sessionId
97+
})
98+
})
99+
)
100+
})
101+
49102
it("persists active project terminal selection by project key and session id", () => {
50103
expect(projectActiveTerminalSelection(makeSession())).toEqual({
51104
projectKey: "octocat/hello-world",
@@ -105,4 +158,38 @@ describe("app-ready terminal state hook", () => {
105158
{ numRuns: 50 }
106159
)
107160
})
161+
162+
effectIt.effect(
163+
"serializes active session persistence so latest wins and superseded selections are skipped",
164+
() =>
165+
Effect.gen(function*(_) {
166+
const persistenceRef = createProjectActiveTerminalPersistenceRef()
167+
168+
persistSessionSelection(persistenceRef, "session-1")
169+
expect(apiMock.setProjectActiveTerminalSession).toHaveBeenCalledTimes(1)
170+
expect(apiMock.setProjectActiveTerminalSession).toHaveBeenLastCalledWith("octocat/hello-world", "session-1")
171+
172+
persistSessionSelection(persistenceRef, "session-2")
173+
expect(apiMock.setProjectActiveTerminalSession).toHaveBeenCalledTimes(1)
174+
175+
pendingPersistCalls[0]?.complete()
176+
yield* _(Effect.yieldNow())
177+
178+
expect(apiMock.setProjectActiveTerminalSession).toHaveBeenCalledTimes(2)
179+
expect(apiMock.setProjectActiveTerminalSession).toHaveBeenLastCalledWith("octocat/hello-world", "session-2")
180+
181+
pendingPersistCalls.length = 0
182+
apiMock.setProjectActiveTerminalSession.mockClear()
183+
const switchedBackPersistenceRef = createProjectActiveTerminalPersistenceRef()
184+
persistSessionSelection(switchedBackPersistenceRef, "session-1")
185+
persistSessionSelection(switchedBackPersistenceRef, "session-2")
186+
persistSessionSelection(switchedBackPersistenceRef, "session-1")
187+
188+
pendingPersistCalls[0]?.complete()
189+
yield* _(Effect.yieldNow())
190+
191+
expect(apiMock.setProjectActiveTerminalSession).toHaveBeenCalledTimes(1)
192+
expect(apiMock.setProjectActiveTerminalSession).toHaveBeenLastCalledWith("octocat/hello-world", "session-1")
193+
})
194+
)
108195
})

0 commit comments

Comments
 (0)