refactor(app): extract workspace terminal lifecycle (#881)
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
collectKnownTerminalIds,
|
||||
collectScriptTerminalIds,
|
||||
collectStandaloneTerminalIds,
|
||||
reconcilePendingScriptTerminals,
|
||||
removeTerminalFromPayload,
|
||||
upsertCreatedTerminalPayload,
|
||||
type ListTerminalsPayload,
|
||||
} from "@/screens/workspace/terminals/state";
|
||||
import type { CreateTerminalResponse } from "@server/shared/messages";
|
||||
|
||||
function listedTerminal(id: string): ListTerminalsPayload["terminals"][number] {
|
||||
return { id, name: id, title: id };
|
||||
}
|
||||
|
||||
function createdTerminal(id: string): NonNullable<CreateTerminalResponse["payload"]["terminal"]> {
|
||||
return { id, name: id, cwd: "/repo", title: id };
|
||||
}
|
||||
|
||||
describe("workspace terminal state", () => {
|
||||
it("keeps pending script terminals until they appear or a fresher list arrives", () => {
|
||||
const pending = new Map([
|
||||
["older-than-list", 10],
|
||||
["now-live", 20],
|
||||
["still-pending", 30],
|
||||
]);
|
||||
|
||||
const reconciled = reconcilePendingScriptTerminals(["now-live"], 20)(pending);
|
||||
|
||||
expect(reconciled).toEqual(new Map([["still-pending", 30]]));
|
||||
});
|
||||
|
||||
it("returns the same pending map when reconciliation changes nothing", () => {
|
||||
const pending = new Map([["still-pending", 30]]);
|
||||
|
||||
const reconciled = reconcilePendingScriptTerminals([], 20)(pending);
|
||||
|
||||
expect(reconciled).toBe(pending);
|
||||
});
|
||||
|
||||
it("combines live and pending terminal ids without duplicating script terminals", () => {
|
||||
const pendingScriptTerminalIds = new Map([
|
||||
["script-pending", 10],
|
||||
["terminal-1", 10],
|
||||
]);
|
||||
|
||||
expect(
|
||||
collectKnownTerminalIds({
|
||||
liveTerminalIds: ["terminal-1", "terminal-2"],
|
||||
pendingScriptTerminalIds,
|
||||
}),
|
||||
).toEqual(["terminal-1", "terminal-2", "script-pending"]);
|
||||
expect(
|
||||
collectScriptTerminalIds({
|
||||
pendingScriptTerminalIds,
|
||||
scripts: [{ terminalId: "script-live" }, { terminalId: null }],
|
||||
}),
|
||||
).toEqual(new Set(["script-pending", "terminal-1", "script-live"]));
|
||||
expect(
|
||||
collectStandaloneTerminalIds({
|
||||
terminals: [
|
||||
listedTerminal("terminal-1"),
|
||||
listedTerminal("terminal-2"),
|
||||
listedTerminal("script-live"),
|
||||
],
|
||||
scriptTerminalIds: new Set(["terminal-1", "script-live"]),
|
||||
}),
|
||||
).toEqual(["terminal-2"]);
|
||||
});
|
||||
|
||||
it("updates terminal cache entries for created and closed terminals", () => {
|
||||
const current: ListTerminalsPayload = {
|
||||
cwd: "/repo",
|
||||
requestId: "existing",
|
||||
terminals: [listedTerminal("terminal-1")],
|
||||
};
|
||||
|
||||
expect(
|
||||
upsertCreatedTerminalPayload({
|
||||
current,
|
||||
terminal: createdTerminal("terminal-2"),
|
||||
workspaceDirectory: "/repo",
|
||||
}),
|
||||
).toEqual({
|
||||
cwd: "/repo",
|
||||
requestId: "existing",
|
||||
terminals: [
|
||||
listedTerminal("terminal-1"),
|
||||
{ id: "terminal-2", name: "terminal-2", title: "terminal-2" },
|
||||
],
|
||||
});
|
||||
expect(removeTerminalFromPayload("terminal-1")(current)).toEqual({
|
||||
cwd: "/repo",
|
||||
requestId: "existing",
|
||||
terminals: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { CreateTerminalResponse, ListTerminalsResponse } from "@server/shared/messages";
|
||||
import { upsertTerminalListEntry } from "@/utils/terminal-list";
|
||||
|
||||
export const TERMINALS_QUERY_STALE_TIME = 5_000;
|
||||
|
||||
export type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
||||
type TerminalEntry = ListTerminalsPayload["terminals"][number];
|
||||
type CreatedTerminal = NonNullable<CreateTerminalResponse["payload"]["terminal"]>;
|
||||
|
||||
export function buildTerminalsQueryKey(serverId: string, workspaceDirectory: string | null) {
|
||||
return ["terminals", serverId, workspaceDirectory] as const;
|
||||
}
|
||||
|
||||
export function canCreateWorkspaceTerminal(input: {
|
||||
isRouteFocused: boolean;
|
||||
client: unknown;
|
||||
isConnected: boolean;
|
||||
workspaceDirectory: string | null;
|
||||
}): boolean {
|
||||
return Boolean(
|
||||
input.isRouteFocused && input.client && input.isConnected && input.workspaceDirectory,
|
||||
);
|
||||
}
|
||||
|
||||
export function reconcilePendingScriptTerminals(liveTerminalIds: string[], dataUpdatedAt: number) {
|
||||
return function update(pendingTerminalIds: Map<string, number>): Map<string, number> {
|
||||
if (pendingTerminalIds.size === 0) {
|
||||
return pendingTerminalIds;
|
||||
}
|
||||
const liveIds = new Set(liveTerminalIds);
|
||||
let changed = false;
|
||||
const nextTerminalIds = new Map<string, number>();
|
||||
for (const [terminalId, listedAt] of pendingTerminalIds) {
|
||||
if (liveIds.has(terminalId) || dataUpdatedAt > listedAt) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
nextTerminalIds.set(terminalId, listedAt);
|
||||
}
|
||||
return changed ? nextTerminalIds : pendingTerminalIds;
|
||||
};
|
||||
}
|
||||
|
||||
export function collectKnownTerminalIds(input: {
|
||||
liveTerminalIds: string[];
|
||||
pendingScriptTerminalIds: Map<string, number>;
|
||||
}): string[] {
|
||||
const terminalIds = new Set(input.liveTerminalIds);
|
||||
for (const terminalId of input.pendingScriptTerminalIds.keys()) {
|
||||
terminalIds.add(terminalId);
|
||||
}
|
||||
return Array.from(terminalIds);
|
||||
}
|
||||
|
||||
export function collectScriptTerminalIds(input: {
|
||||
pendingScriptTerminalIds: Map<string, number>;
|
||||
scripts: Array<{ terminalId?: string | null }>;
|
||||
}): Set<string> {
|
||||
const terminalIds = new Set(input.pendingScriptTerminalIds.keys());
|
||||
for (const script of input.scripts) {
|
||||
if (script.terminalId) {
|
||||
terminalIds.add(script.terminalId);
|
||||
}
|
||||
}
|
||||
return terminalIds;
|
||||
}
|
||||
|
||||
export function collectStandaloneTerminalIds(input: {
|
||||
terminals: TerminalEntry[];
|
||||
scriptTerminalIds: Set<string>;
|
||||
}): string[] {
|
||||
return input.terminals
|
||||
.filter((terminal) => !input.scriptTerminalIds.has(terminal.id))
|
||||
.map((terminal) => terminal.id);
|
||||
}
|
||||
|
||||
export function removeTerminalFromPayload(terminalId: string) {
|
||||
return function updatePayload(
|
||||
current: ListTerminalsPayload | undefined,
|
||||
): ListTerminalsPayload | undefined {
|
||||
if (!current) {
|
||||
return current;
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
terminals: current.terminals.filter((terminal) => terminal.id !== terminalId),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function upsertCreatedTerminalPayload(input: {
|
||||
current: ListTerminalsPayload | undefined;
|
||||
terminal: CreatedTerminal;
|
||||
workspaceDirectory: string | null;
|
||||
}): ListTerminalsPayload {
|
||||
const nextTerminals = upsertTerminalListEntry({
|
||||
terminals: input.current?.terminals ?? [],
|
||||
terminal: input.terminal,
|
||||
});
|
||||
const cwd = input.current?.cwd ?? input.workspaceDirectory;
|
||||
return {
|
||||
...(cwd ? { cwd } : {}),
|
||||
terminals: nextTerminals,
|
||||
requestId: input.current?.requestId ?? `terminal-create-${input.terminal.id}`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import {
|
||||
buildTerminalsQueryKey,
|
||||
canCreateWorkspaceTerminal,
|
||||
collectKnownTerminalIds,
|
||||
collectScriptTerminalIds,
|
||||
collectStandaloneTerminalIds,
|
||||
reconcilePendingScriptTerminals,
|
||||
removeTerminalFromPayload,
|
||||
TERMINALS_QUERY_STALE_TIME,
|
||||
type ListTerminalsPayload,
|
||||
upsertCreatedTerminalPayload,
|
||||
} from "@/screens/workspace/terminals/state";
|
||||
|
||||
interface PendingTerminalCreateInput {
|
||||
paneId?: string;
|
||||
}
|
||||
|
||||
interface UseWorkspaceTerminalsInput {
|
||||
client: DaemonClient | null;
|
||||
isConnected: boolean;
|
||||
isRouteFocused: boolean;
|
||||
normalizedServerId: string;
|
||||
normalizedWorkspaceId: string;
|
||||
workspaceDirectory: string | null;
|
||||
workspaceScripts: WorkspaceDescriptor["scripts"];
|
||||
hasHydratedWorkspaces: boolean;
|
||||
isMissingWorkspaceExecutionAuthority: boolean;
|
||||
onTerminalCreated: (input: { terminalId: string; paneId?: string }) => void;
|
||||
onScriptTerminalSelected: (terminalId: string) => void;
|
||||
onWorkspacePathUnavailable: () => void;
|
||||
onTerminalCreateQueued: () => void;
|
||||
}
|
||||
|
||||
export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) {
|
||||
const {
|
||||
client,
|
||||
isConnected,
|
||||
isRouteFocused,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
workspaceDirectory,
|
||||
workspaceScripts,
|
||||
hasHydratedWorkspaces,
|
||||
isMissingWorkspaceExecutionAuthority,
|
||||
onTerminalCreated,
|
||||
onScriptTerminalSelected,
|
||||
onWorkspacePathUnavailable,
|
||||
onTerminalCreateQueued,
|
||||
} = input;
|
||||
const queryClient = useQueryClient();
|
||||
const [pendingCreateInput, setPendingCreateInput] = useState<PendingTerminalCreateInput | null>(
|
||||
null,
|
||||
);
|
||||
const canCreateNow = useMemo(
|
||||
() => canCreateWorkspaceTerminal({ isRouteFocused, client, isConnected, workspaceDirectory }),
|
||||
[isRouteFocused, client, isConnected, workspaceDirectory],
|
||||
);
|
||||
const queryKey = useMemo(
|
||||
() => buildTerminalsQueryKey(normalizedServerId, workspaceDirectory),
|
||||
[normalizedServerId, workspaceDirectory],
|
||||
);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey,
|
||||
enabled: canCreateNow,
|
||||
queryFn: async () => {
|
||||
if (!client || !workspaceDirectory) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
return await client.listTerminals(workspaceDirectory);
|
||||
},
|
||||
staleTime: TERMINALS_QUERY_STALE_TIME,
|
||||
});
|
||||
const terminals = useMemo(() => query.data?.terminals ?? [], [query.data]);
|
||||
const liveTerminalIds = useMemo(() => terminals.map((terminal) => terminal.id), [terminals]);
|
||||
const [pendingScriptTerminalIds, setPendingScriptTerminalIds] = useState<Map<string, number>>(
|
||||
() => new Map(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setPendingScriptTerminalIds(new Map());
|
||||
}, [normalizedServerId, normalizedWorkspaceId]);
|
||||
|
||||
const dataUpdatedAt = query.dataUpdatedAt;
|
||||
useEffect(() => {
|
||||
setPendingScriptTerminalIds(reconcilePendingScriptTerminals(liveTerminalIds, dataUpdatedAt));
|
||||
}, [liveTerminalIds, dataUpdatedAt]);
|
||||
|
||||
const knownTerminalIds = useMemo(
|
||||
() => collectKnownTerminalIds({ liveTerminalIds, pendingScriptTerminalIds }),
|
||||
[liveTerminalIds, pendingScriptTerminalIds],
|
||||
);
|
||||
const scriptTerminalIds = useMemo(
|
||||
() => collectScriptTerminalIds({ pendingScriptTerminalIds, scripts: workspaceScripts }),
|
||||
[pendingScriptTerminalIds, workspaceScripts],
|
||||
);
|
||||
const standaloneTerminalIds = useMemo(
|
||||
() => collectStandaloneTerminalIds({ terminals, scriptTerminalIds }),
|
||||
[scriptTerminalIds, terminals],
|
||||
);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (_input?: PendingTerminalCreateInput) => {
|
||||
if (!client || !workspaceDirectory) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
return await client.createTerminal(workspaceDirectory);
|
||||
},
|
||||
onSuccess: (payload, createInput) => {
|
||||
const createdTerminal = payload.terminal;
|
||||
if (createdTerminal) {
|
||||
queryClient.setQueryData<ListTerminalsPayload>(queryKey, (current) =>
|
||||
upsertCreatedTerminalPayload({
|
||||
current,
|
||||
terminal: createdTerminal,
|
||||
workspaceDirectory,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
void queryClient.invalidateQueries({ queryKey });
|
||||
if (createdTerminal) {
|
||||
onTerminalCreated({
|
||||
terminalId: createdTerminal.id,
|
||||
paneId: createInput?.paneId,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
const killMutation = useMutation({
|
||||
mutationFn: async (terminalId: string) => {
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
const payload = await client.killTerminal(terminalId);
|
||||
if (!payload.success) {
|
||||
throw new Error("Unable to close terminal");
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRouteFocused || !client || !isConnected || !workspaceDirectory) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubscribeChanged = client.on("terminals_changed", (message) => {
|
||||
if (message.payload.cwd !== workspaceDirectory) {
|
||||
return;
|
||||
}
|
||||
|
||||
queryClient.setQueryData<ListTerminalsPayload>(queryKey, (current) => ({
|
||||
cwd: message.payload.cwd,
|
||||
terminals: message.payload.terminals,
|
||||
requestId: current?.requestId ?? `terminals-changed-${Date.now()}`,
|
||||
}));
|
||||
});
|
||||
|
||||
client.subscribeTerminals({ cwd: workspaceDirectory });
|
||||
|
||||
return () => {
|
||||
unsubscribeChanged();
|
||||
client.unsubscribeTerminals({ cwd: workspaceDirectory });
|
||||
};
|
||||
}, [client, isConnected, isRouteFocused, queryClient, queryKey, workspaceDirectory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingCreateInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (canCreateNow && !createMutation.isPending) {
|
||||
const pendingInput = pendingCreateInput;
|
||||
setPendingCreateInput(null);
|
||||
createMutation.mutate(pendingInput);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasHydratedWorkspaces && isMissingWorkspaceExecutionAuthority) {
|
||||
setPendingCreateInput(null);
|
||||
onWorkspacePathUnavailable();
|
||||
}
|
||||
}, [
|
||||
canCreateNow,
|
||||
createMutation,
|
||||
hasHydratedWorkspaces,
|
||||
isMissingWorkspaceExecutionAuthority,
|
||||
onWorkspacePathUnavailable,
|
||||
pendingCreateInput,
|
||||
]);
|
||||
|
||||
const createTerminal = useCallback(
|
||||
(createInput?: PendingTerminalCreateInput) => {
|
||||
if (createMutation.isPending || pendingCreateInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (canCreateNow) {
|
||||
createMutation.mutate(createInput);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasHydratedWorkspaces && isMissingWorkspaceExecutionAuthority) {
|
||||
onWorkspacePathUnavailable();
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingCreateInput(createInput ?? {});
|
||||
onTerminalCreateQueued();
|
||||
},
|
||||
[
|
||||
canCreateNow,
|
||||
createMutation,
|
||||
hasHydratedWorkspaces,
|
||||
isMissingWorkspaceExecutionAuthority,
|
||||
onTerminalCreateQueued,
|
||||
onWorkspacePathUnavailable,
|
||||
pendingCreateInput,
|
||||
],
|
||||
);
|
||||
|
||||
const handleScriptTerminalStarted = useCallback(
|
||||
(terminalId: string) => {
|
||||
setPendingScriptTerminalIds((pendingTerminalIds) => {
|
||||
if (pendingTerminalIds.get(terminalId) === query.dataUpdatedAt) {
|
||||
return pendingTerminalIds;
|
||||
}
|
||||
const nextTerminalIds = new Map(pendingTerminalIds);
|
||||
nextTerminalIds.set(terminalId, query.dataUpdatedAt);
|
||||
return nextTerminalIds;
|
||||
});
|
||||
onScriptTerminalSelected(terminalId);
|
||||
void queryClient.invalidateQueries({ queryKey });
|
||||
},
|
||||
[onScriptTerminalSelected, query.dataUpdatedAt, queryClient, queryKey],
|
||||
);
|
||||
|
||||
const handleViewScriptTerminal = useCallback(
|
||||
(terminalId: string) => {
|
||||
onScriptTerminalSelected(terminalId);
|
||||
},
|
||||
[onScriptTerminalSelected],
|
||||
);
|
||||
|
||||
const removeTerminalFromCache = useCallback(
|
||||
(terminalId: string) => {
|
||||
queryClient.setQueryData<ListTerminalsPayload>(
|
||||
queryKey,
|
||||
removeTerminalFromPayload(terminalId),
|
||||
);
|
||||
},
|
||||
[queryClient, queryKey],
|
||||
);
|
||||
|
||||
const invalidateTerminals = useCallback(() => {
|
||||
void queryClient.invalidateQueries({ queryKey });
|
||||
}, [queryClient, queryKey]);
|
||||
|
||||
return {
|
||||
canCreateNow,
|
||||
createMutation,
|
||||
createTerminal,
|
||||
handleScriptTerminalStarted,
|
||||
handleViewScriptTerminal,
|
||||
invalidateTerminals,
|
||||
killMutation,
|
||||
knownTerminalIds,
|
||||
liveTerminalIds,
|
||||
pendingCreateInput,
|
||||
query,
|
||||
queryKey,
|
||||
removeTerminalFromCache,
|
||||
standaloneTerminalIds,
|
||||
terminals,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user