From d6e673d9c1bed77d2feebad88400f1c0e5dca35a Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Thu, 20 Aug 2026 15:39:21 -0700 Subject: [PATCH] Defer hidden host preview payload work --- .../plugin/PluginPanelRightPanelHost.test.tsx | 16 +- .../plugin/PluginPanelRightPanelHost.tsx | 1 + ...econdaryPanelTabContent.panelGate.test.tsx | 66 +++++- .../ThreadSecondaryPanelTabContent.tsx | 8 +- .../queries/host-file-preview-query.test.tsx | 197 ++++++++++++++++++ .../hooks/queries/host-file-preview-query.ts | 116 +++++++++-- 6 files changed, 383 insertions(+), 21 deletions(-) create mode 100644 apps/app/src/hooks/queries/host-file-preview-query.test.tsx diff --git a/apps/app/src/components/plugin/PluginPanelRightPanelHost.test.tsx b/apps/app/src/components/plugin/PluginPanelRightPanelHost.test.tsx index 1d704f60ee..de65df1593 100644 --- a/apps/app/src/components/plugin/PluginPanelRightPanelHost.test.tsx +++ b/apps/app/src/components/plugin/PluginPanelRightPanelHost.test.tsx @@ -458,11 +458,16 @@ vi.mock("@/components/secondary-panel/ThreadSecondaryPanelTabContent", () => ({ HostScopedFilePreviewTabContent: ({ activePath, hostId, + isPanelOpen, }: { activePath: string; hostId: string; + isPanelOpen: boolean; }) => ( -
+
host:{hostId}:{activePath}
), @@ -858,6 +863,15 @@ describe("PluginPanelRightPanelHost", () => { expect( await screen.findByText("host:host-explicit:/tmp/example.log"), ).toBeTruthy(); + expect( + screen.getByTestId("host-scoped-file-preview").dataset.panelOpen, + ).toBe("true"); + fireEvent.click(screen.getByRole("button", { name: "Hide right panel" })); + await waitFor(() => { + expect( + screen.getByTestId("host-scoped-file-preview").dataset.panelOpen, + ).toBe("false"); + }); fireEvent.click(screen.getByRole("button", { name: "Open storage file" })); expect( diff --git a/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx b/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx index 5a6792c400..b4e32b713f 100644 --- a/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx +++ b/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx @@ -725,6 +725,7 @@ export function PluginPanelRightPanelHost({ ); diff --git a/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.panelGate.test.tsx b/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.panelGate.test.tsx index 71d58d5875..99df92ff3b 100644 --- a/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.panelGate.test.tsx +++ b/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.panelGate.test.tsx @@ -10,16 +10,21 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { environmentDiffFilesQueryKeyPrefix, environmentFilePreviewQueryKeyPrefix, + hostFilePreviewQueryKey, } from "@/hooks/queries/query-keys"; import { sdk } from "@/lib/sdk"; import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; import { GitDiffTabContent, + HostScopedFilePreviewTabContent, WorkspaceFilePreviewTabContent, } from "./ThreadSecondaryPanelTabContent"; vi.mock("@/lib/sdk", () => ({ - sdk: { environments: { diffFiles: vi.fn(), diffFile: vi.fn() } }, + sdk: { + environments: { diffFiles: vi.fn(), diffFile: vi.fn() }, + files: { createPreview: vi.fn(), read: vi.fn() }, + }, })); // The preview body is not under test; keep pierre out of jsdom. @@ -69,10 +74,10 @@ describe("GitDiffTabContent panel gating", () => { isDiffPanelActive isPanelOpen={isPanelOpen} gitDiffPresentation={{ - view: "unified", - overflow: "scroll", - showLineNumbers: true, - }} + view: "unified", + overflow: "scroll", + showLineNumbers: true, + }} /> ); @@ -141,3 +146,54 @@ describe("WorkspaceFilePreviewTabContent panel gating", () => { }); }); }); + +describe("HostScopedFilePreviewTabContent panel gating", () => { + it("does not start or refetch a host read while the retained panel is closed", async () => { + vi.mocked(sdk.files.createPreview).mockResolvedValue({ + baseUrl: "/api/v1/file-previews/lease-1", + expiresAtMs: Date.now() + 60_000, + }); + vi.mocked(sdk.files.read).mockResolvedValue({ + path: "/tmp/example.txt", + content: "hello\n", + contentEncoding: "utf8", + mimeType: "text/plain", + modifiedAtMs: 1, + sha256: "hash", + sizeBytes: 6, + }); + const { queryClient, wrapper: Wrapper } = createQueryClientTestHarness(); + const renderTab = (isPanelOpen: boolean) => ( + + + + ); + + const view = render(renderTab(false)); + expect(sdk.files.read).not.toHaveBeenCalled(); + expect(sdk.files.createPreview).not.toHaveBeenCalled(); + + view.rerender(renderTab(true)); + await waitFor(() => { + expect(sdk.files.read).toHaveBeenCalledTimes(1); + }); + + view.rerender(renderTab(false)); + await act(async () => { + await queryClient.invalidateQueries({ + queryKey: hostFilePreviewQueryKey("host-1", "/tmp/example.txt"), + }); + }); + expect(sdk.files.read).toHaveBeenCalledTimes(1); + + view.rerender(renderTab(true)); + await waitFor(() => { + expect(sdk.files.read).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.tsx b/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.tsx index 71939327c5..e132960702 100644 --- a/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.tsx +++ b/apps/app/src/components/secondary-panel/ThreadSecondaryPanelTabContent.tsx @@ -130,6 +130,11 @@ export interface HostFilePreviewTabContentProps { export interface HostScopedFilePreviewTabContentProps { activePath: string; hostId: string; + /** + * Whether the secondary panel is open. The retained panel body stays + * mounted while closed, but its host read must pause until it is visible. + */ + isPanelOpen: boolean; lineRange: FilePreviewLineRange | null; onOpenInEditor?: (path: string) => void; } @@ -493,6 +498,7 @@ export function HostFilePreviewTabContent({ export function HostScopedFilePreviewTabContent({ activePath, hostId, + isPanelOpen, lineRange, onOpenInEditor, }: HostScopedFilePreviewTabContentProps) { @@ -502,7 +508,7 @@ export function HostScopedFilePreviewTabContent({ isFetching, isLoading, refetch, - } = useHostFilePreview(hostId, activePath); + } = useHostFilePreview(hostId, activePath, { enabled: isPanelOpen }); return ( ({ + createPreview: vi.fn(), + read: vi.fn(), +})); + +vi.mock("@/lib/sdk", () => ({ + sdk: { files: filesSdk }, +})); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); + vi.useRealTimers(); +}); + +describe("useHostFilePreview", () => { + it("uses a successful preview lease for media without reading or retaining file bytes", async () => { + filesSdk.createPreview.mockResolvedValue({ + baseUrl: "/api/v1/file-previews/lease-1", + expiresAtMs: Date.now() + 60_000, + }); + filesSdk.read.mockResolvedValue({ + path: "/tmp/diagram.png", + content: "iVBORw0KGgo=", + contentEncoding: "base64", + mimeType: "image/png", + modifiedAtMs: 1, + sha256: "hash", + sizeBytes: 8, + }); + const { queryClient, wrapper } = createQueryClientTestHarness(); + const { result } = renderHook( + () => useHostFilePreview("host-1", "/tmp/diagram.png"), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(filesSdk.createPreview).toHaveBeenCalledTimes(1); + expect(filesSdk.read).not.toHaveBeenCalled(); + expect(result.current.data).toEqual({ + kind: "image", + mimeType: "image/png", + name: "diagram.png", + path: "/tmp/diagram.png", + url: "/api/v1/file-previews/lease-1/diagram.png", + }); + expect( + queryClient.getQueryCache().find({ + queryKey: hostFilePreviewQueryKey("host-1", "/tmp/diagram.png"), + })?.gcTime, + ).toBe(HEAVY_PAYLOAD_GC_TIME_MS); + }); + + it("keeps HTML source bytes while avoiding a base64 fallback after a lease succeeds", async () => { + filesSdk.createPreview.mockResolvedValue({ + baseUrl: "/api/v1/file-previews/lease-2", + expiresAtMs: Date.now() + 60_000, + }); + filesSdk.read.mockResolvedValue({ + path: "/tmp/report.html", + content: "

Report

", + contentEncoding: "utf8", + mimeType: "text/html", + modifiedAtMs: 1, + sha256: "hash", + sizeBytes: 15, + }); + const encodeSpy = vi.spyOn(globalThis, "btoa"); + const { wrapper } = createQueryClientTestHarness(); + const { result } = renderHook( + () => useHostFilePreview("host-1", "/tmp/report.html"), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(filesSdk.createPreview).toHaveBeenCalledTimes(1); + expect(filesSdk.read).toHaveBeenCalledTimes(1); + expect(filesSdk.createPreview.mock.invocationCallOrder[0]).toBeLessThan( + filesSdk.read.mock.invocationCallOrder[0]!, + ); + expect(encodeSpy).not.toHaveBeenCalled(); + expect(result.current.data).toMatchObject({ + kind: "text", + content: "

Report

", + url: "/api/v1/file-previews/lease-2/report.html", + }); + }); + + it("keeps ambiguous TypeScript paths on the source-preview path", async () => { + filesSdk.createPreview.mockResolvedValue({ + baseUrl: "/api/v1/file-previews/lease-3", + expiresAtMs: Date.now() + 60_000, + }); + filesSdk.read.mockResolvedValue({ + path: "/tmp/example.ts", + content: "export const value = 1;\n", + contentEncoding: "utf8", + mimeType: "video/mp2t", + modifiedAtMs: 1, + sha256: "hash", + sizeBytes: 24, + }); + const { wrapper } = createQueryClientTestHarness(); + const { result } = renderHook( + () => useHostFilePreview("host-1", "/tmp/example.ts"), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(filesSdk.read).toHaveBeenCalledTimes(1); + expect(result.current.data).toMatchObject({ + kind: "text", + content: "export const value = 1;\n", + }); + }); + + it("reads and builds a data URL only after preview lease creation fails", async () => { + filesSdk.createPreview.mockRejectedValue(new Error("host unavailable")); + filesSdk.read.mockResolvedValue({ + path: "/tmp/diagram.png", + content: "iVBORw0KGgo=", + contentEncoding: "base64", + mimeType: "image/png", + modifiedAtMs: 1, + sha256: "hash", + sizeBytes: 8, + }); + const { wrapper } = createQueryClientTestHarness(); + const { result } = renderHook( + () => useHostFilePreview("host-1", "/tmp/diagram.png"), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(filesSdk.createPreview.mock.invocationCallOrder[0]).toBeLessThan( + filesSdk.read.mock.invocationCallOrder[0]!, + ); + expect(result.current.data).toMatchObject({ + kind: "image", + url: "data:image/png;base64,iVBORw0KGgo=", + }); + }); + + it("aborts an active read and releases the heavy cache entry when disabled", async () => { + let readSignal: AbortSignal | undefined; + filesSdk.createPreview.mockResolvedValue({ + baseUrl: "/api/v1/file-previews/lease-4", + expiresAtMs: Date.now() + 60_000, + }); + filesSdk.read.mockImplementation( + ({ signal }: { signal: AbortSignal }) => + new Promise((_resolve, reject) => { + readSignal = signal; + signal.addEventListener("abort", () => reject(signal.reason)); + }), + ); + const { queryClient, wrapper } = createQueryClientTestHarness(); + const { rerender } = renderHook( + ({ enabled }) => + useHostFilePreview("host-1", "/tmp/example.txt", { enabled }), + { initialProps: { enabled: true }, wrapper }, + ); + + await waitFor(() => expect(filesSdk.read).toHaveBeenCalledTimes(1)); + const activeQuery = queryClient.getQueryCache().find({ + queryKey: hostFilePreviewQueryKey("host-1", "/tmp/example.txt"), + }); + expect(activeQuery).toBeDefined(); + + vi.useFakeTimers(); + rerender({ enabled: false }); + expect(readSignal?.aborted).toBe(true); + expect(activeQuery?.getObserversCount()).toBe(0); + + await act(async () => { + await vi.advanceTimersByTimeAsync(HEAVY_PAYLOAD_GC_TIME_MS + 1); + }); + expect( + queryClient.getQueryCache().find({ + queryKey: hostFilePreviewQueryKey("host-1", "/tmp/example.txt"), + }), + ).toBeUndefined(); + }); +}); diff --git a/apps/app/src/hooks/queries/host-file-preview-query.ts b/apps/app/src/hooks/queries/host-file-preview-query.ts index ff05642aee..79b5883b0a 100644 --- a/apps/app/src/hooks/queries/host-file-preview-query.ts +++ b/apps/app/src/hooks/queries/host-file-preview-query.ts @@ -2,10 +2,49 @@ import { useQuery } from "@tanstack/react-query"; import { sdk } from "@/lib/sdk"; import { buildFilePreview, + isHtmlFilePreviewPath, normalizeFilePreviewMimeType, type FilePreview, } from "@/lib/file-preview"; import { hostFilePreviewQueryKey } from "./query-keys"; +import { HEAVY_PAYLOAD_QUERY_POLICY } from "./query-policies"; + +interface QueryOptions { + enabled?: boolean; +} + +interface HostMediaPreviewType { + kind: "image" | "video"; + mimeType: string; +} + +const HOST_MEDIA_PREVIEW_TYPES = new Map([ + [".avif", { kind: "image", mimeType: "image/avif" }], + [".bmp", { kind: "image", mimeType: "image/bmp" }], + [".gif", { kind: "image", mimeType: "image/gif" }], + [".heic", { kind: "image", mimeType: "image/heic" }], + [".heif", { kind: "image", mimeType: "image/heif" }], + [".ico", { kind: "image", mimeType: "image/vnd.microsoft.icon" }], + [".jpeg", { kind: "image", mimeType: "image/jpeg" }], + [".jpg", { kind: "image", mimeType: "image/jpeg" }], + [".png", { kind: "image", mimeType: "image/png" }], + [".svg", { kind: "image", mimeType: "image/svg+xml" }], + [".svgz", { kind: "image", mimeType: "image/svg+xml" }], + [".tif", { kind: "image", mimeType: "image/tiff" }], + [".tiff", { kind: "image", mimeType: "image/tiff" }], + [".webp", { kind: "image", mimeType: "image/webp" }], + [".3g2", { kind: "video", mimeType: "video/3gpp2" }], + [".3gp", { kind: "video", mimeType: "video/3gpp" }], + [".avi", { kind: "video", mimeType: "video/x-msvideo" }], + [".m4v", { kind: "video", mimeType: "video/x-m4v" }], + [".mov", { kind: "video", mimeType: "video/quicktime" }], + [".mp4", { kind: "video", mimeType: "video/mp4" }], + [".mpeg", { kind: "video", mimeType: "video/mpeg" }], + [".mpg", { kind: "video", mimeType: "video/mpeg" }], + [".ogv", { kind: "video", mimeType: "video/ogg" }], + [".webm", { kind: "video", mimeType: "video/webm" }], + [".wmv", { kind: "video", mimeType: "video/x-ms-wmv" }], +]); function decodeBase64Bytes(content: string): Uint8Array { const binaryContent = atob(content); @@ -44,35 +83,84 @@ function splitAbsoluteHostFilePath(path: string): { return { name, rootPath }; } -export function useHostFilePreview(hostId: string | null, path: string | null) { - const enabled = hostId !== null && path !== null; +function getHostMediaPreviewType(name: string): HostMediaPreviewType | null { + const extensionIndex = name.lastIndexOf("."); + if (extensionIndex <= 0) return null; + return ( + HOST_MEDIA_PREVIEW_TYPES.get(name.slice(extensionIndex).toLowerCase()) ?? + null + ); +} + +export function useHostFilePreview( + hostId: string | null, + path: string | null, + options?: QueryOptions, +) { + const enabled = + (options?.enabled ?? true) && hostId !== null && path !== null; + const activeHostId = enabled ? hostId : null; + const activePath = enabled ? path : null; return useQuery({ - queryKey: hostFilePreviewQueryKey(hostId, path), + // Move a retained-but-disabled observer off the heavy payload's key. That + // aborts an in-flight read and lets the one-minute GC policy start while + // the closed panel body remains mounted. + queryKey: hostFilePreviewQueryKey(activeHostId, activePath), queryFn: async ({ signal }) => { - if (hostId === null || path === null) { + if (activeHostId === null || activePath === null) { throw new Error("Host file preview target is incomplete"); } - const response = await sdk.files.read({ hostId, path, signal }); + const { name, rootPath } = splitAbsoluteHostFilePath(activePath); + const previewLease = await sdk.files + .createPreview({ hostId: activeHostId, rootPath, signal }) + .catch(() => null); + signal.throwIfAborted(); + const previewUrl = + previewLease === null + ? null + : `${previewLease.baseUrl}/${encodeURIComponent(name)}`; + const mediaPreviewType = getHostMediaPreviewType(name); + if (previewUrl !== null && mediaPreviewType !== null) { + return { ...mediaPreviewType, name, path: activePath, url: previewUrl }; + } + + const response = await sdk.files.read({ + hostId: activeHostId, + path: activePath, + signal, + }); const contentBytes = response.contentEncoding === "base64" ? decodeBase64Bytes(response.content) : new TextEncoder().encode(response.content); const mimeType = normalizeFilePreviewMimeType(response.mimeType ?? null); + const preview = buildFilePreview({ + contentBytes, + mimeType, + name, + path: activePath, + url: previewUrl ?? activePath, + }); + if ( + previewUrl !== null || + (preview.kind !== "image" && + preview.kind !== "video" && + !isHtmlFilePreviewPath(activePath)) + ) { + return preview; + } + const base64Content = response.contentEncoding === "base64" ? response.content : encodeBase64Bytes(contentBytes); - const { name, rootPath } = splitAbsoluteHostFilePath(path); - const previewLease = await sdk.files - .createPreview({ hostId, rootPath, signal }) - .catch(() => null); - const url = - previewLease === null - ? `data:${mimeType};base64,${base64Content}` - : `${previewLease.baseUrl}/${encodeURIComponent(name)}`; - return buildFilePreview({ contentBytes, mimeType, name, path, url }); + return { + ...preview, + url: `data:${mimeType};base64,${base64Content}`, + }; }, enabled, staleTime: 30_000, + ...HEAVY_PAYLOAD_QUERY_POLICY, }); }