import { combineTerminalSessionState, EMPTY_TERMINAL_BUFFER_STATE, EMPTY_TERMINAL_SESSION_STATE, selectRunningSubprocessTerminalIds, type KnownTerminalSession, type TerminalSessionState,} from "@t3tools/client-runtime/state/terminal";import { ThreadId, type EnvironmentId, type TerminalAttachInput, type TerminalSummary,} from "@t3tools/contracts";import { useMemo } from "react"; import { useEnvironmentQuery } from "./query";import { terminalEnvironment } from "./terminal"; const EMPTY_KNOWN_TERMINAL_SESSIONS = Object.freeze<ReadonlyArray<KnownTerminalSession>>([]); interface TerminalMetadataIndex { readonly all: ReadonlyArray<TerminalSummary>; readonly byThreadId: ReadonlyMap<string, ReadonlyArray<TerminalSummary>>; readonly views: Map<EnvironmentId, Map<ThreadId | null, ReadonlyArray<KnownTerminalSession>>>;} const metadataIndexes = new WeakMap<ReadonlyArray<TerminalSummary>, TerminalMetadataIndex>();const sessionsBySummary = new WeakMap<TerminalSummary, Map<EnvironmentId, KnownTerminalSession>>();// Reuse groups a consumer still holds without keeping old group members alive// merely because their first summary remains in a newer metadata snapshot.const groupsByAnchor = new WeakMap< TerminalSummary, Map< EnvironmentId, { readonly all?: WeakRef<ReadonlyArray<KnownTerminalSession>>; readonly thread?: WeakRef<ReadonlyArray<KnownTerminalSession>>; } >>(); function knownSession( summary: TerminalSummary, environmentId: EnvironmentId,): KnownTerminalSession { let byEnvironment = sessionsBySummary.get(summary); const previous = byEnvironment?.get(environmentId); if (previous) return previous; const session = { target: { environmentId, threadId: ThreadId.make(summary.threadId), terminalId: summary.terminalId, }, state: combineTerminalSessionState(summary, EMPTY_TERMINAL_BUFFER_STATE), }; if (!byEnvironment) { byEnvironment = new Map(); sessionsBySummary.set(summary, byEnvironment); } byEnvironment.set(environmentId, session); return session;} function terminalMetadataIndex(metadata: ReadonlyArray<TerminalSummary>): TerminalMetadataIndex { let index = metadataIndexes.get(metadata); if (!index) { const compare = new Intl.Collator(undefined, { numeric: true }).compare; const all = metadata.toSorted((left, right) => compare(left.terminalId, right.terminalId)); const byThreadId = new Map<string, TerminalSummary[]>(); for (const summary of all) { const group = byThreadId.get(summary.threadId); if (group) group.push(summary); else byThreadId.set(summary.threadId, [summary]); } index = { all, byThreadId, views: new Map() }; metadataIndexes.set(metadata, index); } return index;} /** Share one ordered index per immutable snapshot without changing metadata subscriptions. */export function selectKnownTerminalSessions( metadata: ReadonlyArray<TerminalSummary> | null, environmentId: EnvironmentId | null, threadId: ThreadId | null,): ReadonlyArray<KnownTerminalSession> { if (environmentId === null || metadata === null || metadata.length === 0) { return EMPTY_KNOWN_TERMINAL_SESSIONS; } const index = terminalMetadataIndex(metadata); let views = index.views.get(environmentId); const cached = views?.get(threadId); if (cached) return cached; const summaries = threadId === null ? index.all : index.byThreadId.get(threadId); if (!summaries || summaries.length === 0) return EMPTY_KNOWN_TERMINAL_SESSIONS; const anchor = summaries[0]!; const kind = threadId === null ? "all" : "thread"; let groups = groupsByAnchor.get(anchor); const previousGroups = groups?.get(environmentId); const previous = previousGroups?.[kind]?.deref(); const sessions = previous?.length === summaries.length && previous.every((session, index) => session.state.summary === summaries[index]) ? previous : summaries.map((summary) => knownSession(summary, environmentId)); if (!groups) { groups = new Map(); groupsByAnchor.set(anchor, groups); } if (sessions !== previous) { groups.set(environmentId, { ...previousGroups, [kind]: new WeakRef(sessions) }); } if (!views) { views = new Map(); index.views.set(environmentId, views); } views.set(threadId, sessions); return sessions;} export function useAttachedTerminalSession(input: { readonly environmentId: EnvironmentId | null; readonly terminal: TerminalAttachInput | null;}): TerminalSessionState { const attach = useEnvironmentQuery( input.environmentId !== null && input.terminal !== null ? terminalEnvironment.attach({ environmentId: input.environmentId, input: input.terminal, }) : null, ); const metadata = useEnvironmentQuery( input.environmentId === null ? null : terminalEnvironment.metadata({ environmentId: input.environmentId, input: null, }), ); return useMemo(() => { if (input.environmentId === null || input.terminal === null) { return EMPTY_TERMINAL_SESSION_STATE; } const summary = (metadata.data === null ? null : terminalMetadataIndex(metadata.data) .byThreadId.get(input.terminal.threadId) ?.find((terminal) => terminal.terminalId === input.terminal?.terminalId)) ?? null; const state = combineTerminalSessionState(summary, attach.data ?? EMPTY_TERMINAL_BUFFER_STATE); return attach.error === null ? state : { ...state, error: attach.error, status: "error" }; }, [attach.data, attach.error, input.environmentId, input.terminal, metadata.data]);} export function useKnownTerminalSessions(input: { readonly environmentId: EnvironmentId | null; readonly threadId: ThreadId | null;}): ReadonlyArray<KnownTerminalSession> { const metadata = useEnvironmentQuery( input.environmentId === null ? null : terminalEnvironment.metadata({ environmentId: input.environmentId, input: null, }), ); return useMemo( () => selectKnownTerminalSessions(metadata.data, input.environmentId, input.threadId), [input.environmentId, input.threadId, metadata.data], );} export function useThreadRunningTerminalIds(input: { readonly environmentId: EnvironmentId | null; readonly threadId: ThreadId | null;}): ReadonlyArray<string> { const sessions = useKnownTerminalSessions(input); return useMemo(() => selectRunningSubprocessTerminalIds(sessions), [sessions]);}
import {
combineTerminalSessionState,
EMPTY_TERMINAL_BUFFER_STATE,
EMPTY_TERMINAL_SESSION_STATE,
selectRunningSubprocessTerminalIds,
type KnownTerminalSession,
type TerminalSessionState,
} from "@t3tools/client-runtime/state/terminal";
import {
ThreadId,
type EnvironmentId,
type TerminalAttachInput,
type TerminalSummary,
} from "@t3tools/contracts";
import { useMemo } from "react";
import { useEnvironmentQuery } from "./query";
import { terminalEnvironment } from "./terminal";
const EMPTY_KNOWN_TERMINAL_SESSIONS = Object.freeze<ReadonlyArray<KnownTerminalSession>>([]);
interface TerminalMetadataIndex {
readonly all: ReadonlyArray<TerminalSummary>;
readonly byThreadId: ReadonlyMap<string, ReadonlyArray<TerminalSummary>>;
readonly views: Map<EnvironmentId, Map<ThreadId | null, ReadonlyArray<KnownTerminalSession>>>;
}
const metadataIndexes = new WeakMap<ReadonlyArray<TerminalSummary>, TerminalMetadataIndex>();
const sessionsBySummary = new WeakMap<TerminalSummary, Map<EnvironmentId, KnownTerminalSession>>();
// Reuse groups a consumer still holds without keeping old group members alive
// merely because their first summary remains in a newer metadata snapshot.
const groupsByAnchor = new WeakMap<
TerminalSummary,
Map<
EnvironmentId,
{
readonly all?: WeakRef<ReadonlyArray<KnownTerminalSession>>;
readonly thread?: WeakRef<ReadonlyArray<KnownTerminalSession>>;
}
>
>();
function knownSession(
summary: TerminalSummary,
environmentId: EnvironmentId,
): KnownTerminalSession {
let byEnvironment = sessionsBySummary.get(summary);
const previous = byEnvironment?.get(environmentId);
if (previous) return previous;
const session = {
target: {
environmentId,
threadId: ThreadId.make(summary.threadId),
terminalId: summary.terminalId,
},
state: combineTerminalSessionState(summary, EMPTY_TERMINAL_BUFFER_STATE),
};
if (!byEnvironment) {
byEnvironment = new Map();
sessionsBySummary.set(summary, byEnvironment);
}
byEnvironment.set(environmentId, session);
return session;
}
function terminalMetadataIndex(metadata: ReadonlyArray<TerminalSummary>): TerminalMetadataIndex {
let index = metadataIndexes.get(metadata);
if (!index) {
const compare = new Intl.Collator(undefined, { numeric: true }).compare;
const all = metadata.toSorted((left, right) => compare(left.terminalId, right.terminalId));
const byThreadId = new Map<string, TerminalSummary[]>();
for (const summary of all) {
const group = byThreadId.get(summary.threadId);
if (group) group.push(summary);
else byThreadId.set(summary.threadId, [summary]);
}
index = { all, byThreadId, views: new Map() };
metadataIndexes.set(metadata, index);
}
return index;
}
/** Share one ordered index per immutable snapshot without changing metadata subscriptions. */
export function selectKnownTerminalSessions(
metadata: ReadonlyArray<TerminalSummary> | null,
environmentId: EnvironmentId | null,
threadId: ThreadId | null,
): ReadonlyArray<KnownTerminalSession> {
if (environmentId === null || metadata === null || metadata.length === 0) {
return EMPTY_KNOWN_TERMINAL_SESSIONS;
}
const index = terminalMetadataIndex(metadata);
let views = index.views.get(environmentId);
const cached = views?.get(threadId);
if (cached) return cached;
const summaries = threadId === null ? index.all : index.byThreadId.get(threadId);
if (!summaries || summaries.length === 0) return EMPTY_KNOWN_TERMINAL_SESSIONS;
const anchor = summaries[0]!;
const kind = threadId === null ? "all" : "thread";
let groups = groupsByAnchor.get(anchor);
const previousGroups = groups?.get(environmentId);
const previous = previousGroups?.[kind]?.deref();
const sessions =
previous?.length === summaries.length &&
previous.every((session, index) => session.state.summary === summaries[index])
? previous
: summaries.map((summary) => knownSession(summary, environmentId));
if (!groups) {
groups = new Map();
groupsByAnchor.set(anchor, groups);
}
if (sessions !== previous) {
groups.set(environmentId, { ...previousGroups, [kind]: new WeakRef(sessions) });
}
if (!views) {
views = new Map();
index.views.set(environmentId, views);
}
views.set(threadId, sessions);
return sessions;
}
export function useAttachedTerminalSession(input: {
readonly environmentId: EnvironmentId | null;
readonly terminal: TerminalAttachInput | null;
}): TerminalSessionState {
const attach = useEnvironmentQuery(
input.environmentId !== null && input.terminal !== null
? terminalEnvironment.attach({
environmentId: input.environmentId,
input: input.terminal,
})
: null,
);
const metadata = useEnvironmentQuery(
input.environmentId === null
? null
: terminalEnvironment.metadata({
environmentId: input.environmentId,
input: null,
}),
);
return useMemo(() => {
if (input.environmentId === null || input.terminal === null) {
return EMPTY_TERMINAL_SESSION_STATE;
}
const summary =
(metadata.data === null
? null
: terminalMetadataIndex(metadata.data)
.byThreadId.get(input.terminal.threadId)
?.find((terminal) => terminal.terminalId === input.terminal?.terminalId)) ?? null;
const state = combineTerminalSessionState(summary, attach.data ?? EMPTY_TERMINAL_BUFFER_STATE);
return attach.error === null ? state : { ...state, error: attach.error, status: "error" };
}, [attach.data, attach.error, input.environmentId, input.terminal, metadata.data]);
}
export function useKnownTerminalSessions(input: {
readonly environmentId: EnvironmentId | null;
readonly threadId: ThreadId | null;
}): ReadonlyArray<KnownTerminalSession> {
const metadata = useEnvironmentQuery(
input.environmentId === null
? null
: terminalEnvironment.metadata({
environmentId: input.environmentId,
input: null,
}),
);
return useMemo(
() => selectKnownTerminalSessions(metadata.data, input.environmentId, input.threadId),
[input.environmentId, input.threadId, metadata.data],
);
}
export function useThreadRunningTerminalIds(input: {
readonly environmentId: EnvironmentId | null;
readonly threadId: ThreadId | null;
}): ReadonlyArray<string> {
const sessions = useKnownTerminalSessions(input);
return useMemo(() => selectRunningSubprocessTerminalIds(sessions), [sessions]);
}