Eliminate sidebar re-render cascades by fixing both sides of the subscription contract: - Session-store writes preserve identity: setWorkspaces, mergeWorkspaces, removeWorkspace, and patchWorkspaceScripts return the previous Map/entry reference when content is unchanged. - HostRuntimeController.updateSnapshot only notifies when a patched field actually changed; idle probe ticks no longer replace the snapshot. - New canonical hook surface in stores/session-store-hooks.ts (useWorkspace, useWorkspaceFields, useWorkspaceStructure, useWorkspaceKeys, useResolveWorkspaceIdByCwd, useWorkspaceStatusesForBadges, useWorkspaceExecutionAuthority, useRecommendedProjectPaths, useHasWorkspaces). These are the only place to subscribe to workspaces. - 15 greedy subscribers migrated to the hook surface. The sidebar is now driven by useWorkspaceStructure for identity/ordering; each row hydrates its own descriptor via useWorkspaceFields, so a workspace update re-renders only its row. Archive-redirect callbacks switched to event-time useSessionStore.getState(). - Deleted dead code: use-session-directory.ts, workspace-fetch-debug.ts, and the unused SidebarProjectEntry aggregates (activeCount, totalWorkspaces, aggregated statusBucket). No user-visible behavior change. Targeted tests green (79 tests across host-runtime, session-store, session-store-hooks, sidebar list, sidebar row model, sidebar shortcuts, workspace source of truth).
99 lines
3.3 KiB
TypeScript
99 lines
3.3 KiB
TypeScript
import { useQuery } from "@tanstack/react-query";
|
|
import { Terminal } from "lucide-react-native";
|
|
import { Text, View } from "react-native";
|
|
import { useIsFocused } from "@react-navigation/native";
|
|
import invariant from "tiny-invariant";
|
|
import type { ListTerminalsResponse } from "@server/shared/messages";
|
|
import { TerminalPane } from "@/components/terminal-pane";
|
|
import { usePaneContext } from "@/panels/pane-context";
|
|
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
|
import { useSessionStore } from "@/stores/session-store";
|
|
import { useWorkspaceExecutionAuthority } from "@/stores/session-store-hooks";
|
|
|
|
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
|
|
|
function trimNonEmpty(value: string | null | undefined): string | null {
|
|
if (typeof value !== "string") {
|
|
return null;
|
|
}
|
|
const trimmed = value.trim();
|
|
return trimmed.length > 0 ? trimmed : null;
|
|
}
|
|
|
|
function useTerminalPanelDescriptor(
|
|
target: { kind: "terminal"; terminalId: string },
|
|
context: { serverId: string; workspaceId: string },
|
|
): PanelDescriptor {
|
|
const client = useSessionStore((state) => state.sessions[context.serverId]?.client ?? null);
|
|
const workspaceAuthority = useWorkspaceExecutionAuthority(context.serverId, context.workspaceId)!;
|
|
const workspaceDirectory = workspaceAuthority.ok
|
|
? workspaceAuthority.authority.workspaceDirectory
|
|
: null;
|
|
const terminalsQuery = useQuery({
|
|
queryKey: ["terminals", context.serverId, workspaceDirectory] as const,
|
|
enabled: Boolean(client && workspaceDirectory),
|
|
queryFn: async (): Promise<ListTerminalsPayload> => {
|
|
if (!client || !workspaceDirectory) {
|
|
throw new Error(
|
|
workspaceAuthority.ok
|
|
? "Workspace execution directory not found"
|
|
: workspaceAuthority.message,
|
|
);
|
|
}
|
|
return client.listTerminals(workspaceDirectory);
|
|
},
|
|
staleTime: 5_000,
|
|
});
|
|
const terminal =
|
|
terminalsQuery.data?.terminals.find((entry) => entry.id === target.terminalId) ?? null;
|
|
|
|
return {
|
|
label: trimNonEmpty(terminal?.title ?? terminal?.name ?? null) ?? "Terminal",
|
|
subtitle: "Terminal",
|
|
titleState: "ready",
|
|
icon: Terminal,
|
|
statusBucket: null,
|
|
};
|
|
}
|
|
|
|
function TerminalPanel() {
|
|
const isFocused = useIsFocused();
|
|
const { serverId, workspaceId, target, isPaneFocused } = usePaneContext();
|
|
const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId)!;
|
|
const workspaceDirectory = workspaceAuthority.ok
|
|
? workspaceAuthority.authority.workspaceDirectory
|
|
: null;
|
|
invariant(target.kind === "terminal", "TerminalPanel requires terminal target");
|
|
|
|
if (!isFocused) {
|
|
return <View style={{ flex: 1 }} />;
|
|
}
|
|
|
|
if (!workspaceDirectory) {
|
|
return (
|
|
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: 16 }}>
|
|
<Text>
|
|
{workspaceAuthority.ok
|
|
? "Workspace execution directory not found."
|
|
: workspaceAuthority.message}
|
|
</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<TerminalPane
|
|
serverId={serverId}
|
|
cwd={workspaceDirectory}
|
|
terminalId={target.terminalId}
|
|
isPaneFocused={isPaneFocused}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export const terminalPanelRegistration: PanelRegistration<"terminal"> = {
|
|
kind: "terminal",
|
|
component: TerminalPanel,
|
|
useDescriptor: useTerminalPanelDescriptor,
|
|
};
|