Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -458,11 +458,16 @@ vi.mock("@/components/secondary-panel/ThreadSecondaryPanelTabContent", () => ({
HostScopedFilePreviewTabContent: ({
activePath,
hostId,
isPanelOpen,
}: {
activePath: string;
hostId: string;
isPanelOpen: boolean;
}) => (
<div>
<div
data-testid="host-scoped-file-preview"
data-panel-open={isPanelOpen ? "true" : "false"}
>
host:{hostId}:{activePath}
</div>
),
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -725,6 +725,7 @@ export function PluginPanelRightPanelHost({
<LazyHostScopedFilePreviewTabContent
activePath={tab.path}
hostId={tab.hostId}
isPanelOpen={isOpen}
lineRange={tab.lineRange}
/>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -69,10 +74,10 @@ describe("GitDiffTabContent panel gating", () => {
isDiffPanelActive
isPanelOpen={isPanelOpen}
gitDiffPresentation={{
view: "unified",
overflow: "scroll",
showLineNumbers: true,
}}
view: "unified",
overflow: "scroll",
showLineNumbers: true,
}}
/>
</Wrapper>
);
Expand Down Expand Up @@ -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) => (
<Wrapper>
<HostScopedFilePreviewTabContent
activePath="/tmp/example.txt"
hostId="host-1"
isPanelOpen={isPanelOpen}
lineRange={null}
/>
</Wrapper>
);

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);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -493,6 +498,7 @@ export function HostFilePreviewTabContent({
export function HostScopedFilePreviewTabContent({
activePath,
hostId,
isPanelOpen,
lineRange,
onOpenInEditor,
}: HostScopedFilePreviewTabContentProps) {
Expand All @@ -502,7 +508,7 @@ export function HostScopedFilePreviewTabContent({
isFetching,
isLoading,
refetch,
} = useHostFilePreview(hostId, activePath);
} = useHostFilePreview(hostId, activePath, { enabled: isPanelOpen });
return (
<SecondaryPanelFilePreview
activePath={activePath}
Expand Down
197 changes: 197 additions & 0 deletions apps/app/src/hooks/queries/host-file-preview-query.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
// @vitest-environment jsdom

import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createQueryClientTestHarness } from "@/test/queryClientTestHarness";
import { hostFilePreviewQueryKey } from "./query-keys";
import { HEAVY_PAYLOAD_GC_TIME_MS } from "./query-policies";
import { useHostFilePreview } from "./host-file-preview-query";

const filesSdk = vi.hoisted(() => ({
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: "<h1>Report</h1>",
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: "<h1>Report</h1>",
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();
});
});
Loading
Loading