Skip to content
Source
Sign inSign up

terminalSessions.ts

240 lines8.8 KB

1import { useAtomValue } from "@effect/atom-react";
2import {
3 combineTerminalSessionState,
4 EMPTY_TERMINAL_BUFFER_STATE,
5 EMPTY_TERMINAL_SESSION_STATE,
6 selectRunningSubprocessTerminalIds,
7 type KnownTerminalSession,
8 type TerminalSessionState,
9} from "@t3tools/client-runtime/state/terminal";
10import {
11 EnvironmentId,
12 ThreadId,
13 AuthTerminalReadScope,
14 AuthTerminalOperateScope,
15 TerminalAttachInput,
16 type TerminalSummary,
17} from "@t3tools/contracts";
18​
19import * as Option from "effect/Option";
20import * as Schema from "effect/Schema";
21import { AsyncResult, Atom } from "effect/reactivity";
22​
23import { formatEnvironmentQueryError } from "./query";
24import { terminalEnvironment } from "./terminal";
25import { environmentSession, sessionHasScope, useEnvironmentScope } from "./session";
26​
27const EMPTY_KNOWN_TERMINAL_SESSIONS = Object.freeze<ReadonlyArray<KnownTerminalSession>>([]);
28​
29interface TerminalMetadataIndex {
30 readonly all: ReadonlyArray<TerminalSummary>;
31 readonly byThreadId: ReadonlyMap<string, ReadonlyArray<TerminalSummary>>;
32 readonly views: Map<EnvironmentId, Map<ThreadId | null, ReadonlyArray<KnownTerminalSession>>>;
34​
35const metadataIndexes = new WeakMap<ReadonlyArray<TerminalSummary>, TerminalMetadataIndex>();
36const sessionsBySummary = new WeakMap<TerminalSummary, Map<EnvironmentId, KnownTerminalSession>>();
37// Reuse held groups without retaining old members when their anchor survives in newer snapshots.
38const groupsByAnchor = new WeakMap<
39 TerminalSummary,
40 Map<
41 EnvironmentId,
42 {
43 readonly all?: WeakRef<ReadonlyArray<KnownTerminalSession>>;
44 readonly thread?: WeakRef<ReadonlyArray<KnownTerminalSession>>;
45 }
46 >
47>();
48​
49function knownSession(
50 summary: TerminalSummary,
51 environmentId: EnvironmentId,
52): KnownTerminalSession {
53 let byEnvironment = sessionsBySummary.get(summary);
54 const previous = byEnvironment?.get(environmentId);
55 if (previous) return previous;
56 const session = {
57 target: {
58 environmentId,
59 threadId: ThreadId.make(summary.threadId),
60 terminalId: summary.terminalId,
61 },
62 state: combineTerminalSessionState(summary, EMPTY_TERMINAL_BUFFER_STATE),
63 };
64 if (!byEnvironment) {
65 byEnvironment = new Map();
66 sessionsBySummary.set(summary, byEnvironment);
67 }
68 byEnvironment.set(environmentId, session);
69 return session;
71​
72function terminalMetadataIndex(metadata: ReadonlyArray<TerminalSummary>): TerminalMetadataIndex {
73 let index = metadataIndexes.get(metadata);
74 if (!index) {
75 const compare = new Intl.Collator(undefined, { numeric: true }).compare;
76 const all = metadata.toSorted((left, right) => compare(left.terminalId, right.terminalId));
77 const byThreadId = new Map<string, TerminalSummary[]>();
78 for (const summary of all) {
79 const group = byThreadId.get(summary.threadId);
80 if (group) group.push(summary);
81 else byThreadId.set(summary.threadId, [summary]);
82 }
83 index = { all, byThreadId, views: new Map() };
84 metadataIndexes.set(metadata, index);
85 }
86 return index;
88​
89export function selectKnownTerminalSessions(
90 metadata: ReadonlyArray<TerminalSummary> | null,
91 environmentId: EnvironmentId | null,
92 threadId: ThreadId | null,
93): ReadonlyArray<KnownTerminalSession> {
94 if (environmentId === null || metadata === null || metadata.length === 0) {
95 return EMPTY_KNOWN_TERMINAL_SESSIONS;
96 }
97 const index = terminalMetadataIndex(metadata);
98 let views = index.views.get(environmentId);
99 const cached = views?.get(threadId);
100 if (cached) return cached;
101 const summaries = threadId === null ? index.all : index.byThreadId.get(threadId);
102 if (!summaries || summaries.length === 0) return EMPTY_KNOWN_TERMINAL_SESSIONS;
103​
104 const anchor = summaries[0]!;
105 const kind = threadId === null ? "all" : "thread";
106 let groups = groupsByAnchor.get(anchor);
107 const previousGroups = groups?.get(environmentId);
108 const previous = previousGroups?.[kind]?.deref();
109 const sessions =
110 previous?.length === summaries.length &&
111 previous.every((session, index) => session.state.summary === summaries[index])
112 ? previous
113 : summaries.map((summary) => knownSession(summary, environmentId));
114 if (!groups) {
115 groups = new Map();
116 groupsByAnchor.set(anchor, groups);
117 }
118 if (sessions !== previous) {
119 groups.set(environmentId, { ...previousGroups, [kind]: new WeakRef(sessions) });
120 }
121 if (!views) {
122 views = new Map();
123 index.views.set(environmentId, views);
124 }
125 views.set(threadId, sessions);
126 return sessions;
128​
129const EMPTY_TERMINAL_SESSION_ATOM = Atom.make(EMPTY_TERMINAL_SESSION_STATE).pipe(
130 Atom.withLabel("web-terminal-session:empty"),
131);
132​
133const decodeAttachKey = Schema.decodeUnknownOption(
134 Schema.fromJsonString(Schema.Tuple([EnvironmentId, TerminalAttachInput])),
135);
136​
137// The family key carries the whole launch so an idle atom leaves nothing behind.
138const attachedTerminalSessionFamily = Atom.family((key: string) => {
139 const decoded = decodeAttachKey(key);
140 if (Option.isNone(decoded)) {
141 return Atom.make<TerminalSessionState>({
142 ...EMPTY_TERMINAL_SESSION_STATE,
143 error: "Invalid terminal launch input",
144 status: "error",
145 }).pipe(Atom.withLabel(`web-terminal-session:${key}`));
146 }
147 const [environmentId, terminal] = decoded.value;
148 return Atom.make((get): TerminalSessionState => {
149 const session = get(environmentSession.sessionStateAtom(environmentId));
150 const canRead = sessionHasScope(session, AuthTerminalReadScope);
151 const canOperate = sessionHasScope(session, AuthTerminalOperateScope);
152 const attach = canOperate
153 ? get(terminalEnvironment.attach({ environmentId, input: terminal }))
154 : canRead
155 ? get(
156 terminalEnvironment.observe({
157 environmentId,
158 input: { threadId: terminal.threadId, terminalId: terminal.terminalId },
159 }),
160 )
161 : null;
162 const metadata = canRead
163 ? AsyncResult.getOrElse(
164 get(terminalEnvironment.metadata({ environmentId, input: null })),
165 () => null,
166 )
167 : null;
168 const summary =
169 (metadata === null
170 ? null
171 : terminalMetadataIndex(metadata)
172 .byThreadId.get(terminal.threadId)
173 ?.find((candidate) => candidate.terminalId === terminal.terminalId)) ?? null;
174 const buffer =
175 attach === null
176 ? EMPTY_TERMINAL_BUFFER_STATE
177 : AsyncResult.getOrElse(attach, () => EMPTY_TERMINAL_BUFFER_STATE);
178 const state = combineTerminalSessionState(summary, buffer);
179 return attach?._tag === "Failure"
180 ? { ...state, error: formatEnvironmentQueryError(attach.cause), status: "error" }
181 : state;
182 }).pipe(Atom.withLabel(`web-terminal-session:${key}`));
183});
184​
185/**
186 * One shown terminal's session: its attach (or read-only observe) buffer
187 * combined with the metadata summary. Surfaces subscribe to it imperatively so
188 * output chunks never re-render React.
189 */
190export function attachedTerminalSessionAtom(
191 environmentId: EnvironmentId | null,
192 terminal: TerminalAttachInput | null,
193): Atom.Atom<TerminalSessionState> {
194 if (environmentId === null || terminal === null) return EMPTY_TERMINAL_SESSION_ATOM;
195 return attachedTerminalSessionFamily(JSON.stringify([environmentId, terminal]));
197​
198const NO_KNOWN_TERMINAL_SESSIONS_ATOM = Atom.make<ReadonlyArray<KnownTerminalSession> | null>(null);
199​
200const decodeKnownSessionsKey = Schema.decodeUnknownOption(
201 Schema.fromJsonString(Schema.Tuple([EnvironmentId, Schema.NullOr(ThreadId)])),
202);
203​
204// Derived per thread, so a terminal update elsewhere in the environment notifies no one here.
205const knownTerminalSessionsFamily = Atom.family((key: string) => {
206 const [environmentId, threadId] = Option.getOrThrow(decodeKnownSessionsKey(key));
207 return Atom.make((get) => {
208 const metadata = get(terminalEnvironment.metadata({ environmentId, input: null }));
209 return metadata._tag === "Success"
210 ? selectKnownTerminalSessions(metadata.value, environmentId, threadId)
211 : null;
212 }).pipe(Atom.withLabel(`web-known-terminal-sessions:${key}`));
213});
214​
215export function knownTerminalSessionsAtom(
216 environmentId: EnvironmentId,
217 threadId: ThreadId | null,
218): Atom.Atom<ReadonlyArray<KnownTerminalSession> | null> {
219 return knownTerminalSessionsFamily(JSON.stringify([environmentId, threadId]));
221​
222export function useKnownTerminalSessions(input: {
223 readonly environmentId: EnvironmentId | null;
224 readonly threadId: ThreadId | null;
225}): ReadonlyArray<KnownTerminalSession> | null {
226 const canRead = useEnvironmentScope(input.environmentId, AuthTerminalReadScope);
227 return useAtomValue(
228 input.environmentId === null || !canRead
229 ? NO_KNOWN_TERMINAL_SESSIONS_ATOM
230 : knownTerminalSessionsAtom(input.environmentId, input.threadId),
231 );
233​
234export function useThreadRunningTerminalIds(input: {
235 readonly environmentId: EnvironmentId | null;
236 readonly threadId: ThreadId | null;
237}): ReadonlyArray<string> {
238 const sessions = useKnownTerminalSessions(input);
239 return selectRunningSubprocessTerminalIds(sessions ?? []);