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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||||
import { useIsFocused } from "@react-navigation/native";
|
import { useIsFocused } from "@react-navigation/native";
|
||||||
import { ActivityIndicator, BackHandler, Keyboard, Pressable, Text, View } from "react-native";
|
import { ActivityIndicator, BackHandler, Keyboard, Pressable, Text, View } from "react-native";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useRouter, type Href } from "expo-router";
|
import { useRouter, type Href } from "expo-router";
|
||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
import { DiffStat } from "@/components/diff-stat";
|
import { DiffStat } from "@/components/diff-stat";
|
||||||
@@ -94,8 +94,6 @@ import { useWorkspace } from "@/stores/session-store-hooks";
|
|||||||
import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention";
|
import { useWorkspaceTerminalSessionRetention } from "@/terminal/hooks/use-workspace-terminal-session-retention";
|
||||||
import type { CheckoutStatusPayload } from "@/git/use-status-query";
|
import type { CheckoutStatusPayload } from "@/git/use-status-query";
|
||||||
import { checkoutStatusQueryKey } from "@/git/query-keys";
|
import { checkoutStatusQueryKey } from "@/git/query-keys";
|
||||||
import type { ListTerminalsResponse } from "@server/shared/messages";
|
|
||||||
import { upsertTerminalListEntry } from "@/utils/terminal-list";
|
|
||||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
@@ -158,10 +156,12 @@ import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/l
|
|||||||
import { getIsElectron, isNative, isWeb } from "@/constants/platform";
|
import { getIsElectron, isNative, isWeb } from "@/constants/platform";
|
||||||
import { useContainerWidthBelow } from "@/hooks/use-container-width";
|
import { useContainerWidthBelow } from "@/hooks/use-container-width";
|
||||||
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
|
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
|
||||||
|
import { canCreateWorkspaceTerminal } from "@/screens/workspace/terminals/state";
|
||||||
|
import { useWorkspaceTerminals } from "@/screens/workspace/terminals/use-workspace-terminals";
|
||||||
|
|
||||||
const TERMINALS_QUERY_STALE_TIME = 5_000;
|
|
||||||
const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000;
|
const WORKSPACE_SETUP_AUTO_OPEN_WINDOW_MS = 30_000;
|
||||||
const EMPTY_UI_TABS: WorkspaceTab[] = [];
|
const EMPTY_UI_TABS: WorkspaceTab[] = [];
|
||||||
|
const EMPTY_WORKSPACE_SCRIPTS: WorkspaceDescriptor["scripts"] = [];
|
||||||
const EMPTY_PINNED_AGENT_IDS = new Set<string>();
|
const EMPTY_PINNED_AGENT_IDS = new Set<string>();
|
||||||
const EMPTY_SET = new Set<string>();
|
const EMPTY_SET = new Set<string>();
|
||||||
|
|
||||||
@@ -1028,8 +1028,6 @@ function WorkspaceHeaderTitleBar({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
|
||||||
|
|
||||||
type PaneDirection = "left" | "right" | "up" | "down";
|
type PaneDirection = "left" | "right" | "up" | "down";
|
||||||
|
|
||||||
function parsePaneDirection(actionId: string): PaneDirection | null {
|
function parsePaneDirection(actionId: string): PaneDirection | null {
|
||||||
@@ -1180,39 +1178,6 @@ function resolveWorkspaceAuthorityState(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getHostDisplayName(host: { label?: string | null } | null, fallback: string): string {
|
function getHostDisplayName(host: { label?: string | null } | null, fallback: string): string {
|
||||||
const trimmed = host?.label?.trim();
|
const trimmed = host?.label?.trim();
|
||||||
return trimmed ? trimmed : fallback;
|
return trimmed ? trimmed : fallback;
|
||||||
@@ -1359,17 +1324,6 @@ function shouldShowWorkspaceExplorerSidebar(input: {
|
|||||||
return input.isRouteFocused && shouldShowWorkspaceScreenHeader(input);
|
return input.isRouteFocused && shouldShowWorkspaceScreenHeader(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
function canCreateWorkspaceTerminal(input: {
|
|
||||||
isRouteFocused: boolean;
|
|
||||||
client: unknown;
|
|
||||||
isConnected: boolean;
|
|
||||||
workspaceDirectory: string | null;
|
|
||||||
}): boolean {
|
|
||||||
return Boolean(
|
|
||||||
input.isRouteFocused && input.client && input.isConnected && input.workspaceDirectory,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string): string | null {
|
function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string): string | null {
|
||||||
if (!serverId || !workspaceId) {
|
if (!serverId || !workspaceId) {
|
||||||
return null;
|
return null;
|
||||||
@@ -1377,6 +1331,108 @@ function buildWorkspaceTerminalScopeKey(serverId: string, workspaceId: string):
|
|||||||
return `${serverId}:${workspaceId}`;
|
return `${serverId}:${workspaceId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface WorkspaceTerminalTabActionsInput {
|
||||||
|
persistenceKey: string | null;
|
||||||
|
focusWorkspacePane: (workspaceKey: string, paneId: string) => void;
|
||||||
|
openWorkspaceTabFocused: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
|
||||||
|
toast: {
|
||||||
|
error: (message: string) => void;
|
||||||
|
show: (message: string) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkspaceTerminalTabActions {
|
||||||
|
handleTerminalCreated: (input: { terminalId: string; paneId?: string }) => void;
|
||||||
|
handleScriptTerminalSelected: (terminalId: string) => void;
|
||||||
|
handleWorkspacePathUnavailable: () => void;
|
||||||
|
handleTerminalCreateQueued: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useWorkspaceTerminalTabActions({
|
||||||
|
persistenceKey,
|
||||||
|
focusWorkspacePane,
|
||||||
|
openWorkspaceTabFocused,
|
||||||
|
toast,
|
||||||
|
}: WorkspaceTerminalTabActionsInput): WorkspaceTerminalTabActions {
|
||||||
|
const handleTerminalCreated = useCallback(
|
||||||
|
({ terminalId, paneId }: { terminalId: string; paneId?: string }) => {
|
||||||
|
if (!persistenceKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (paneId) {
|
||||||
|
focusWorkspacePane(persistenceKey, paneId);
|
||||||
|
}
|
||||||
|
openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId });
|
||||||
|
},
|
||||||
|
[focusWorkspacePane, openWorkspaceTabFocused, persistenceKey],
|
||||||
|
);
|
||||||
|
const handleScriptTerminalSelected = useCallback(
|
||||||
|
(terminalId: string) => {
|
||||||
|
if (!persistenceKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId });
|
||||||
|
},
|
||||||
|
[openWorkspaceTabFocused, persistenceKey],
|
||||||
|
);
|
||||||
|
const handleWorkspacePathUnavailable = useCallback(() => {
|
||||||
|
toast.error("Workspace path is not available yet");
|
||||||
|
}, [toast]);
|
||||||
|
const handleTerminalCreateQueued = useCallback(() => {
|
||||||
|
toast.show("Preparing workspace, opening terminal when ready...");
|
||||||
|
}, [toast]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleTerminalCreated,
|
||||||
|
handleScriptTerminalSelected,
|
||||||
|
handleWorkspacePathUnavailable,
|
||||||
|
handleTerminalCreateQueued,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function useWorkspaceCheckoutStatus(input: {
|
||||||
|
client: ReturnType<typeof useHostRuntimeClient>;
|
||||||
|
isConnected: boolean;
|
||||||
|
isRouteFocused: boolean;
|
||||||
|
normalizedServerId: string;
|
||||||
|
normalizedWorkspaceId: string;
|
||||||
|
workspaceDirectory: string | null;
|
||||||
|
}) {
|
||||||
|
const isCheckoutQueryEnabled = useMemo(
|
||||||
|
() =>
|
||||||
|
canCreateWorkspaceTerminal({
|
||||||
|
isRouteFocused: input.isRouteFocused,
|
||||||
|
client: input.client,
|
||||||
|
isConnected: input.isConnected,
|
||||||
|
workspaceDirectory: input.workspaceDirectory,
|
||||||
|
}),
|
||||||
|
[input.isRouteFocused, input.client, input.isConnected, input.workspaceDirectory],
|
||||||
|
);
|
||||||
|
const checkoutQuery = useQuery({
|
||||||
|
queryKey: checkoutStatusQueryKey(
|
||||||
|
input.normalizedServerId,
|
||||||
|
input.workspaceDirectory ?? `missing-workspace-directory:${input.normalizedWorkspaceId}`,
|
||||||
|
),
|
||||||
|
enabled: isCheckoutQueryEnabled,
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!input.client || !input.workspaceDirectory) {
|
||||||
|
throw new Error("Host is not connected");
|
||||||
|
}
|
||||||
|
return await input.client.getCheckoutStatus(input.workspaceDirectory);
|
||||||
|
},
|
||||||
|
staleTime: Infinity,
|
||||||
|
refetchOnMount: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
const isCheckoutStatusLoading = useMemo(
|
||||||
|
() => isCheckoutQueryEnabled && checkoutQuery.data === undefined && !checkoutQuery.isError,
|
||||||
|
[isCheckoutQueryEnabled, checkoutQuery.data, checkoutQuery.isError],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { checkoutQuery, isCheckoutStatusLoading };
|
||||||
|
}
|
||||||
|
|
||||||
function WorkspaceScreenContent({
|
function WorkspaceScreenContent({
|
||||||
serverId,
|
serverId,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -1405,7 +1461,6 @@ function WorkspaceScreenContent({
|
|||||||
scopeKey: workspaceTerminalScopeKey,
|
scopeKey: workspaceTerminalScopeKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const client = useHostRuntimeClient(normalizedServerId);
|
const client = useHostRuntimeClient(normalizedServerId);
|
||||||
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
|
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
|
||||||
const workspaceAuthority = useMemo(
|
const workspaceAuthority = useMemo(
|
||||||
@@ -1430,12 +1485,19 @@ function WorkspaceScreenContent({
|
|||||||
useProvidersSnapshot(normalizedServerId, {
|
useProvidersSnapshot(normalizedServerId, {
|
||||||
enabled: isRouteFocused,
|
enabled: isRouteFocused,
|
||||||
});
|
});
|
||||||
const [pendingTerminalCreateInput, setPendingTerminalCreateInput] = useState<{
|
|
||||||
paneId?: string;
|
const persistenceKey = useMemo(
|
||||||
} | null>(null);
|
() =>
|
||||||
const canCreateTerminalNow = useMemo(
|
buildWorkspaceTabPersistenceKey({
|
||||||
() => canCreateWorkspaceTerminal({ isRouteFocused, client, isConnected, workspaceDirectory }),
|
serverId: normalizedServerId,
|
||||||
[isRouteFocused, client, isConnected, workspaceDirectory],
|
workspaceId: normalizedWorkspaceId,
|
||||||
|
}),
|
||||||
|
[normalizedServerId, normalizedWorkspaceId],
|
||||||
|
);
|
||||||
|
const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused);
|
||||||
|
const focusWorkspacePane = useWorkspaceLayoutStore((state) => state.focusPane);
|
||||||
|
const hasHydratedWorkspaces = useSessionStore(
|
||||||
|
(state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false,
|
||||||
);
|
);
|
||||||
|
|
||||||
const workspaceAgentVisibility = useStoreWithEqualityFn(
|
const workspaceAgentVisibility = useStoreWithEqualityFn(
|
||||||
@@ -1449,167 +1511,56 @@ function WorkspaceScreenContent({
|
|||||||
workspaceAgentVisibilityEqual,
|
workspaceAgentVisibilityEqual,
|
||||||
);
|
);
|
||||||
|
|
||||||
const terminalsQueryKey = useMemo(
|
const {
|
||||||
() => ["terminals", normalizedServerId, workspaceDirectory] as const,
|
handleTerminalCreated,
|
||||||
[normalizedServerId, workspaceDirectory],
|
handleScriptTerminalSelected,
|
||||||
);
|
handleWorkspacePathUnavailable,
|
||||||
const terminalsQuery = useQuery({
|
handleTerminalCreateQueued,
|
||||||
queryKey: terminalsQueryKey,
|
} = useWorkspaceTerminalTabActions({
|
||||||
enabled: canCreateTerminalNow,
|
persistenceKey,
|
||||||
queryFn: async () => {
|
focusWorkspacePane,
|
||||||
if (!client || !workspaceDirectory) {
|
openWorkspaceTabFocused,
|
||||||
throw new Error("Host is not connected");
|
toast,
|
||||||
}
|
|
||||||
return await client.listTerminals(workspaceDirectory);
|
|
||||||
},
|
|
||||||
staleTime: TERMINALS_QUERY_STALE_TIME,
|
|
||||||
});
|
});
|
||||||
const terminals = useMemo(() => terminalsQuery.data?.terminals ?? [], [terminalsQuery.data]);
|
const {
|
||||||
const liveTerminalIds = useMemo(() => terminals.map((terminal) => terminal.id), [terminals]);
|
createMutation: createTerminalMutation,
|
||||||
const [pendingScriptTerminalIds, setPendingScriptTerminalIds] = useState<Map<string, number>>(
|
createTerminal,
|
||||||
() => new Map(),
|
handleScriptTerminalStarted,
|
||||||
);
|
handleViewScriptTerminal,
|
||||||
useEffect(() => {
|
invalidateTerminals,
|
||||||
setPendingScriptTerminalIds(new Map());
|
killMutation: killTerminalMutation,
|
||||||
}, [normalizedServerId, normalizedWorkspaceId]);
|
knownTerminalIds,
|
||||||
const terminalsDataUpdatedAt = terminalsQuery.dataUpdatedAt;
|
liveTerminalIds,
|
||||||
useEffect(() => {
|
pendingCreateInput: pendingTerminalCreateInput,
|
||||||
setPendingScriptTerminalIds(
|
query: terminalsQuery,
|
||||||
reconcilePendingScriptTerminals(liveTerminalIds, terminalsDataUpdatedAt),
|
removeTerminalFromCache,
|
||||||
);
|
standaloneTerminalIds,
|
||||||
}, [liveTerminalIds, terminalsDataUpdatedAt]);
|
terminals,
|
||||||
const knownTerminalIds = useMemo(() => {
|
} = useWorkspaceTerminals({
|
||||||
const terminalIds = new Set(liveTerminalIds);
|
client,
|
||||||
for (const terminalId of pendingScriptTerminalIds.keys()) {
|
isConnected,
|
||||||
terminalIds.add(terminalId);
|
isRouteFocused,
|
||||||
}
|
normalizedServerId,
|
||||||
return Array.from(terminalIds);
|
normalizedWorkspaceId,
|
||||||
}, [liveTerminalIds, pendingScriptTerminalIds]);
|
workspaceDirectory,
|
||||||
const scriptTerminalIds = useMemo(() => {
|
workspaceScripts: workspaceDescriptor?.scripts ?? EMPTY_WORKSPACE_SCRIPTS,
|
||||||
const terminalIds = new Set(pendingScriptTerminalIds.keys());
|
hasHydratedWorkspaces,
|
||||||
for (const script of workspaceDescriptor?.scripts ?? []) {
|
isMissingWorkspaceExecutionAuthority,
|
||||||
if (script.terminalId) {
|
onTerminalCreated: handleTerminalCreated,
|
||||||
terminalIds.add(script.terminalId);
|
onScriptTerminalSelected: handleScriptTerminalSelected,
|
||||||
}
|
onWorkspacePathUnavailable: handleWorkspacePathUnavailable,
|
||||||
}
|
onTerminalCreateQueued: handleTerminalCreateQueued,
|
||||||
return terminalIds;
|
|
||||||
}, [pendingScriptTerminalIds, workspaceDescriptor?.scripts]);
|
|
||||||
const standaloneTerminalIds = useMemo(
|
|
||||||
() =>
|
|
||||||
terminals
|
|
||||||
.filter((terminal) => !scriptTerminalIds.has(terminal.id))
|
|
||||||
.map((terminal) => terminal.id),
|
|
||||||
[scriptTerminalIds, terminals],
|
|
||||||
);
|
|
||||||
const createTerminalMutation = useMutation({
|
|
||||||
mutationFn: async (_input?: { paneId?: string }) => {
|
|
||||||
if (!client || !workspaceDirectory) {
|
|
||||||
throw new Error("Host is not connected");
|
|
||||||
}
|
|
||||||
return await client.createTerminal(workspaceDirectory);
|
|
||||||
},
|
|
||||||
onSuccess: (payload, input) => {
|
|
||||||
const createdTerminal = payload.terminal;
|
|
||||||
if (createdTerminal) {
|
|
||||||
queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => {
|
|
||||||
const nextTerminals = upsertTerminalListEntry({
|
|
||||||
terminals: current?.terminals ?? [],
|
|
||||||
terminal: createdTerminal,
|
|
||||||
});
|
|
||||||
const cwd = current?.cwd ?? workspaceDirectory;
|
|
||||||
return {
|
|
||||||
...(cwd ? { cwd } : {}),
|
|
||||||
terminals: nextTerminals,
|
|
||||||
requestId: current?.requestId ?? `terminal-create-${createdTerminal.id}`,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void queryClient.invalidateQueries({ queryKey: terminalsQueryKey });
|
|
||||||
if (createdTerminal) {
|
|
||||||
const workspaceKey = buildWorkspaceTabPersistenceKey({
|
|
||||||
serverId: normalizedServerId,
|
|
||||||
workspaceId: normalizedWorkspaceId,
|
|
||||||
});
|
|
||||||
if (!workspaceKey) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (input?.paneId) {
|
|
||||||
focusWorkspacePane(workspaceKey, input.paneId);
|
|
||||||
}
|
|
||||||
useWorkspaceLayoutStore
|
|
||||||
.getState()
|
|
||||||
.openTabFocused(workspaceKey, { kind: "terminal", terminalId: createdTerminal.id });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const killTerminalMutation = 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;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
const { archiveAgent } = useArchiveAgent();
|
const { archiveAgent } = useArchiveAgent();
|
||||||
|
|
||||||
useEffect(() => {
|
const { checkoutQuery, isCheckoutStatusLoading } = useWorkspaceCheckoutStatus({
|
||||||
if (!isRouteFocused || !client || !isConnected || !workspaceDirectory) {
|
client,
|
||||||
return;
|
isConnected,
|
||||||
}
|
isRouteFocused,
|
||||||
|
normalizedServerId,
|
||||||
const unsubscribeChanged = client.on("terminals_changed", (message) => {
|
normalizedWorkspaceId,
|
||||||
if (message.payload.cwd !== workspaceDirectory) {
|
workspaceDirectory,
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (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, terminalsQueryKey, workspaceDirectory]);
|
|
||||||
|
|
||||||
const isCheckoutQueryEnabled = useMemo(
|
|
||||||
() => canCreateWorkspaceTerminal({ isRouteFocused, client, isConnected, workspaceDirectory }),
|
|
||||||
[isRouteFocused, client, isConnected, workspaceDirectory],
|
|
||||||
);
|
|
||||||
const checkoutQuery = useQuery({
|
|
||||||
queryKey: checkoutStatusQueryKey(
|
|
||||||
normalizedServerId,
|
|
||||||
workspaceDirectory ?? `missing-workspace-directory:${normalizedWorkspaceId}`,
|
|
||||||
),
|
|
||||||
enabled: isCheckoutQueryEnabled,
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!client || !workspaceDirectory) {
|
|
||||||
throw new Error("Host is not connected");
|
|
||||||
}
|
|
||||||
return await client.getCheckoutStatus(workspaceDirectory);
|
|
||||||
},
|
|
||||||
staleTime: Infinity,
|
|
||||||
refetchOnMount: false,
|
|
||||||
refetchOnReconnect: false,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
});
|
});
|
||||||
const isCheckoutStatusLoading = useMemo(
|
|
||||||
() => isCheckoutQueryEnabled && checkoutQuery.data === undefined && !checkoutQuery.isError,
|
|
||||||
[isCheckoutQueryEnabled, checkoutQuery.data, checkoutQuery.isError],
|
|
||||||
);
|
|
||||||
const hasHydratedWorkspaces = useSessionStore(
|
|
||||||
(state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false,
|
|
||||||
);
|
|
||||||
const hasHydratedAgents = useSessionStore(
|
const hasHydratedAgents = useSessionStore(
|
||||||
(state) => state.sessions[normalizedServerId]?.hasHydratedAgents ?? false,
|
(state) => state.sessions[normalizedServerId]?.hasHydratedAgents ?? false,
|
||||||
);
|
);
|
||||||
@@ -1618,30 +1569,6 @@ function WorkspaceScreenContent({
|
|||||||
workspace: workspaceDescriptor,
|
workspace: workspaceDescriptor,
|
||||||
hasHydratedWorkspaces,
|
hasHydratedWorkspaces,
|
||||||
});
|
});
|
||||||
useEffect(() => {
|
|
||||||
if (!pendingTerminalCreateInput) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (canCreateTerminalNow && !createTerminalMutation.isPending) {
|
|
||||||
const pendingInput = pendingTerminalCreateInput;
|
|
||||||
setPendingTerminalCreateInput(null);
|
|
||||||
createTerminalMutation.mutate(pendingInput);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasHydratedWorkspaces && isMissingWorkspaceExecutionAuthority) {
|
|
||||||
setPendingTerminalCreateInput(null);
|
|
||||||
toast.error("Workspace path is not available yet");
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
canCreateTerminalNow,
|
|
||||||
createTerminalMutation,
|
|
||||||
hasHydratedWorkspaces,
|
|
||||||
isMissingWorkspaceExecutionAuthority,
|
|
||||||
pendingTerminalCreateInput,
|
|
||||||
toast,
|
|
||||||
]);
|
|
||||||
const workspaceHeaderCheckoutState = buildWorkspaceHeaderCheckoutState({
|
const workspaceHeaderCheckoutState = buildWorkspaceHeaderCheckoutState({
|
||||||
isCheckoutStatusLoading,
|
isCheckoutStatusLoading,
|
||||||
isError: checkoutQuery.isError,
|
isError: checkoutQuery.isError,
|
||||||
@@ -1738,15 +1665,6 @@ function WorkspaceScreenContent({
|
|||||||
return () => handler.remove();
|
return () => handler.remove();
|
||||||
}, [isExplorerOpen, isRouteFocused, showMobileAgent]);
|
}, [isExplorerOpen, isRouteFocused, showMobileAgent]);
|
||||||
|
|
||||||
const persistenceKey = useMemo(
|
|
||||||
() =>
|
|
||||||
buildWorkspaceTabPersistenceKey({
|
|
||||||
serverId: normalizedServerId,
|
|
||||||
workspaceId: normalizedWorkspaceId,
|
|
||||||
}),
|
|
||||||
[normalizedServerId, normalizedWorkspaceId],
|
|
||||||
);
|
|
||||||
|
|
||||||
const workspaceLayout = useWorkspaceLayoutStore((state) =>
|
const workspaceLayout = useWorkspaceLayoutStore((state) =>
|
||||||
persistenceKey ? (state.layoutByWorkspace[persistenceKey] ?? null) : null,
|
persistenceKey ? (state.layoutByWorkspace[persistenceKey] ?? null) : null,
|
||||||
);
|
);
|
||||||
@@ -1761,7 +1679,6 @@ function WorkspaceScreenContent({
|
|||||||
[workspaceLayout],
|
[workspaceLayout],
|
||||||
);
|
);
|
||||||
useSyncWorkspaceActiveBrowser({ workspaceLayout, isRouteFocused });
|
useSyncWorkspaceActiveBrowser({ workspaceLayout, isRouteFocused });
|
||||||
const openWorkspaceTabFocused = useWorkspaceLayoutStore((state) => state.openTabFocused);
|
|
||||||
const openWorkspaceTabInBackground = useWorkspaceLayoutStore(
|
const openWorkspaceTabInBackground = useWorkspaceLayoutStore(
|
||||||
(state) => state.openTabInBackground,
|
(state) => state.openTabInBackground,
|
||||||
);
|
);
|
||||||
@@ -1777,39 +1694,6 @@ function WorkspaceScreenContent({
|
|||||||
const splitWorkspacePane = useWorkspaceLayoutStore((state) => state.splitPane);
|
const splitWorkspacePane = useWorkspaceLayoutStore((state) => state.splitPane);
|
||||||
const splitWorkspacePaneEmpty = useWorkspaceLayoutStore((state) => state.splitPaneEmpty);
|
const splitWorkspacePaneEmpty = useWorkspaceLayoutStore((state) => state.splitPaneEmpty);
|
||||||
const moveWorkspaceTabToPane = useWorkspaceLayoutStore((state) => state.moveTabToPane);
|
const moveWorkspaceTabToPane = useWorkspaceLayoutStore((state) => state.moveTabToPane);
|
||||||
const focusWorkspacePane = useWorkspaceLayoutStore((state) => state.focusPane);
|
|
||||||
const handleScriptTerminalStarted = useCallback(
|
|
||||||
(terminalId: string) => {
|
|
||||||
setPendingScriptTerminalIds((pendingTerminalIds) => {
|
|
||||||
if (pendingTerminalIds.get(terminalId) === terminalsQuery.dataUpdatedAt) {
|
|
||||||
return pendingTerminalIds;
|
|
||||||
}
|
|
||||||
const nextTerminalIds = new Map(pendingTerminalIds);
|
|
||||||
nextTerminalIds.set(terminalId, terminalsQuery.dataUpdatedAt);
|
|
||||||
return nextTerminalIds;
|
|
||||||
});
|
|
||||||
if (persistenceKey) {
|
|
||||||
openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId });
|
|
||||||
}
|
|
||||||
void queryClient.invalidateQueries({ queryKey: terminalsQueryKey });
|
|
||||||
},
|
|
||||||
[
|
|
||||||
openWorkspaceTabFocused,
|
|
||||||
persistenceKey,
|
|
||||||
queryClient,
|
|
||||||
terminalsQuery.dataUpdatedAt,
|
|
||||||
terminalsQueryKey,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
const handleViewScriptTerminal = useCallback(
|
|
||||||
(terminalId: string) => {
|
|
||||||
if (!persistenceKey) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
openWorkspaceTabFocused(persistenceKey, { kind: "terminal", terminalId });
|
|
||||||
},
|
|
||||||
[openWorkspaceTabFocused, persistenceKey],
|
|
||||||
);
|
|
||||||
const paneFocusSuppressedRef = useRef(false);
|
const paneFocusSuppressedRef = useRef(false);
|
||||||
const resizeWorkspaceSplit = useWorkspaceLayoutStore((state) => state.resizeSplit);
|
const resizeWorkspaceSplit = useWorkspaceLayoutStore((state) => state.resizeSplit);
|
||||||
const reorderWorkspaceTabsInPane = useWorkspaceLayoutStore((state) => state.reorderTabsInPane);
|
const reorderWorkspaceTabsInPane = useWorkspaceLayoutStore((state) => state.reorderTabsInPane);
|
||||||
@@ -2200,24 +2084,7 @@ function WorkspaceScreenContent({
|
|||||||
[focusWorkspacePane, openWorkspaceDraftTab, persistenceKey],
|
[focusWorkspacePane, openWorkspaceDraftTab, persistenceKey],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCreateTerminal = useStableEvent((input?: { paneId?: string }) => {
|
const handleCreateTerminal = useStableEvent(createTerminal);
|
||||||
if (createTerminalMutation.isPending || pendingTerminalCreateInput) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (canCreateTerminalNow) {
|
|
||||||
createTerminalMutation.mutate(input);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasHydratedWorkspaces && isMissingWorkspaceExecutionAuthority) {
|
|
||||||
toast.error("Workspace path is not available yet");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setPendingTerminalCreateInput(input ?? {});
|
|
||||||
toast.show("Preparing workspace, opening terminal when ready...");
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleCreateBrowserTab = useCallback(
|
const handleCreateBrowserTab = useCallback(
|
||||||
(input?: { paneId?: string }) => {
|
(input?: { paneId?: string }) => {
|
||||||
@@ -2284,10 +2151,7 @@ function WorkspaceScreenContent({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
queryClient.setQueryData<ListTerminalsPayload>(
|
removeTerminalFromCache(terminalId);
|
||||||
terminalsQueryKey,
|
|
||||||
removeTerminalFromPayload(terminalId),
|
|
||||||
);
|
|
||||||
setHoveredTabKey((current) => (current === tabId ? null : current));
|
setHoveredTabKey((current) => (current === tabId ? null : current));
|
||||||
setHoveredCloseTabKey((current) => (current === tabId ? null : current));
|
setHoveredCloseTabKey((current) => (current === tabId ? null : current));
|
||||||
if (persistenceKey) {
|
if (persistenceKey) {
|
||||||
@@ -2297,18 +2161,16 @@ function WorkspaceScreenContent({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void killTerminalAsync(terminalId).catch(() => {
|
void killTerminalAsync(terminalId).catch(invalidateTerminals);
|
||||||
void queryClient.invalidateQueries({ queryKey: terminalsQueryKey });
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
closeTab,
|
closeTab,
|
||||||
closeWorkspaceTabWithCleanup,
|
closeWorkspaceTabWithCleanup,
|
||||||
|
invalidateTerminals,
|
||||||
killTerminalAsync,
|
killTerminalAsync,
|
||||||
persistenceKey,
|
persistenceKey,
|
||||||
queryClient,
|
removeTerminalFromCache,
|
||||||
terminalsQueryKey,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user