1import {
2 combineTerminalSessionState,
3 EMPTY_TERMINAL_BUFFER_STATE,
4 EMPTY_TERMINAL_SESSION_STATE,
5 selectRunningSubprocessTerminalIds,
6 type KnownTerminalSession,
7 type TerminalSessionState,
8} from "@t3tools/client-runtime/state/terminal";
9import {
10 ThreadId,
11 AuthTerminalReadScope,
12 AuthTerminalOperateScope,
13 type EnvironmentId,
14 type TerminalAttachInput,
15 type TerminalSummary,
16} from "@t3tools/contracts";
17import { useMemo } from "react";
18
19import { useEnvironmentQuery } from "./query";
20import { terminalEnvironment } from "./terminal";
21import { useEnvironmentScope } from "./session";
22
23const EMPTY_KNOWN_TERMINAL_SESSIONS = Object.freeze<ReadonlyArray<KnownTerminalSession>>([]);
24
25interface TerminalMetadataIndex {
26 readonly all: ReadonlyArray<TerminalSummary>;
27 readonly byThreadId: ReadonlyMap<string, ReadonlyArray<TerminalSummary>>;
28 readonly views: Map<EnvironmentId, Map<ThreadId | null, ReadonlyArray<KnownTerminalSession>>>;
29}
30
31const metadataIndexes = new WeakMap<ReadonlyArray<TerminalSummary>, TerminalMetadataIndex>();
32const sessionsBySummary = new WeakMap<TerminalSummary, Map<EnvironmentId, KnownTerminalSession>>();
33// Reuse groups a consumer still holds without keeping old group members alive
34// merely because their first summary remains in a newer metadata snapshot.
35const groupsByAnchor = new WeakMap<
36 TerminalSummary,
37 Map<
38 EnvironmentId,
39 {
40 readonly all?: WeakRef<ReadonlyArray<KnownTerminalSession>>;
41 readonly thread?: WeakRef<ReadonlyArray<KnownTerminalSession>>;
42 }
43 >
44>();
45
46function knownSession(
47 summary: TerminalSummary,
48 environmentId: EnvironmentId,
49): KnownTerminalSession {
50 let byEnvironment = sessionsBySummary.get(summary);
51 const previous = byEnvironment?.get(environmentId);
52 if (previous) return previous;
53 const session = {
54 target: {
55 environmentId,
56 threadId: ThreadId.make(summary.threadId),
57 terminalId: summary.terminalId,
58 },
59 state: combineTerminalSessionState(summary, EMPTY_TERMINAL_BUFFER_STATE),
60 };
61 if (!byEnvironment) {
62 byEnvironment = new Map();
63 sessionsBySummary.set(summary, byEnvironment);
64 }
65 byEnvironment.set(environmentId, session);
66 return session;
67}
68
69function terminalMetadataIndex(metadata: ReadonlyArray<TerminalSummary>): TerminalMetadataIndex {
70 let index = metadataIndexes.get(metadata);
71 if (!index) {
72 const compare = new Intl.Collator(undefined, { numeric: true }).compare;
73 const all = metadata.toSorted((left, right) => compare(left.terminalId, right.terminalId));
74 const byThreadId = new Map<string, TerminalSummary[]>();
75 for (const summary of all) {
76 const group = byThreadId.get(summary.threadId);
77 if (group) group.push(summary);
78 else byThreadId.set(summary.threadId, [summary]);
79 }
80 index = { all, byThreadId, views: new Map() };
81 metadataIndexes.set(metadata, index);
82 }
83 return index;
84}
85
86/** Share one ordered index per immutable snapshot without changing metadata subscriptions. */
87export function selectKnownTerminalSessions(
88 metadata: ReadonlyArray<TerminalSummary> | null,
89 environmentId: EnvironmentId | null,
90 threadId: ThreadId | null,
91): ReadonlyArray<KnownTerminalSession> {
92 if (environmentId === null || metadata === null || metadata.length === 0) {
93 return EMPTY_KNOWN_TERMINAL_SESSIONS;
94 }
95 const index = terminalMetadataIndex(metadata);
96 let views = index.views.get(environmentId);
97 const cached = views?.get(threadId);
98 if (cached) return cached;
99 const summaries = threadId === null ? index.all : index.byThreadId.get(threadId);
100 if (!summaries || summaries.length === 0) return EMPTY_KNOWN_TERMINAL_SESSIONS;
101
102 const anchor = summaries[0]!;
103 const kind = threadId === null ? "all" : "thread";
104 let groups = groupsByAnchor.get(anchor);
105 const previousGroups = groups?.get(environmentId);
106 const previous = previousGroups?.[kind]?.deref();
107 const sessions =
108 previous?.length === summaries.length &&
109 previous.every((session, index) => session.state.summary === summaries[index])
110 ? previous
111 : summaries.map((summary) => knownSession(summary, environmentId));
112 if (!groups) {
113 groups = new Map();
114 groupsByAnchor.set(anchor, groups);
115 }
116 if (sessions !== previous) {
117 groups.set(environmentId, { ...previousGroups, [kind]: new WeakRef(sessions) });
118 }
119 if (!views) {
120 views = new Map();
121 index.views.set(environmentId, views);
122 }
123 views.set(threadId, sessions);
124 return sessions;
125}
126
127export function useAttachedTerminalSession(input: {
128 readonly environmentId: EnvironmentId | null;
129 readonly terminal: TerminalAttachInput | null;
130}): TerminalSessionState {
131 const canRead = useEnvironmentScope(input.environmentId, AuthTerminalReadScope);
132 const canOperate = useEnvironmentScope(input.environmentId, AuthTerminalOperateScope);
133 const attach = useEnvironmentQuery(
134 input.environmentId !== null && input.terminal !== null
135 ? canOperate
136 ? terminalEnvironment.attach({
137 environmentId: input.environmentId,
138 input: input.terminal,
139 })
140 : canRead
141 ? terminalEnvironment.observe({
142 environmentId: input.environmentId,
143 input: { threadId: input.terminal.threadId, terminalId: input.terminal.terminalId },
144 })
145 : null
146 : null,
147 );
148 const metadata = useEnvironmentQuery(
149 input.environmentId === null || !canRead
150 ? null
151 : terminalEnvironment.metadata({
152 environmentId: input.environmentId,
153 input: null,
154 }),
155 );
156
157 return useMemo(() => {
158 if (input.environmentId === null || input.terminal === null) {
159 return EMPTY_TERMINAL_SESSION_STATE;
160 }
161 const summary =
162 (metadata.data === null
163 ? null
164 : terminalMetadataIndex(metadata.data)
165 .byThreadId.get(input.terminal.threadId)
166 ?.find((terminal) => terminal.terminalId === input.terminal?.terminalId)) ?? null;
167 const state = combineTerminalSessionState(summary, attach.data ?? EMPTY_TERMINAL_BUFFER_STATE);
168 return attach.error === null ? state : { ...state, error: attach.error, status: "error" };
169 }, [attach.data, attach.error, input.environmentId, input.terminal, metadata.data]);
170}
171
172export function useKnownTerminalSessions(input: {
173 readonly environmentId: EnvironmentId | null;
174 readonly threadId: ThreadId | null;
175}): ReadonlyArray<KnownTerminalSession> | null {
176 const canRead = useEnvironmentScope(input.environmentId, AuthTerminalReadScope);
177 const metadata = useEnvironmentQuery(
178 input.environmentId === null || !canRead
179 ? null
180 : terminalEnvironment.metadata({
181 environmentId: input.environmentId,
182 input: null,
183 }),
184 );
185 return useMemo(
186 () =>
187 metadata.data === null || metadata.error !== null
188 ? null
189 : selectKnownTerminalSessions(metadata.data, input.environmentId, input.threadId),
190 [input.environmentId, input.threadId, metadata.data, metadata.error],
191 );
192}
193
194export function useThreadRunningTerminalIds(input: {
195 readonly environmentId: EnvironmentId | null;
196 readonly threadId: ThreadId | null;
197}): ReadonlyArray<string> {
198 const sessions = useKnownTerminalSessions(input);
199 return useMemo(() => selectRunningSubprocessTerminalIds(sessions ?? []), [sessions]);
200}