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
37 changes: 36 additions & 1 deletion apps/web/src/components/files/FilePreviewPanel.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { DEFAULT_THEMES, preloadHighlighter } from "@pierre/diffs";
import { EnvironmentId, ThreadId, type ProjectReadFileResult } from "@t3tools/contracts";
import { AsyncResult } from "effect/reactivity";
import type { ReactNode } from "react";
import { StrictMode, type ReactNode } from "react";
import { describe, expect, it, vi } from "vite-plus/test";
import { userEvent } from "vite-plus/test/browser";
import { act, fakeTimers, render } from "../../../test/render";
Expand Down Expand Up @@ -268,6 +268,41 @@ describe("source file focus", () => {
});
});

describe("source file scroll", () => {
it("returns a file tab to where it was scrolled after another file was shown", async () => {
using _settings = setup();
const style = document.createElement("style");
style.textContent = appCss;
document.head.append(style);
using _style = { [Symbol.dispose]: () => style.remove() };
await preloadHighlighter({ themes: Object.values(DEFAULT_THEMES), langs: ["typescript"] });
const sourceFile = (relativePath: string, lines: number) => {
const contents = Array.from({ length: lines }, (_, i) => `const line${i} = ${i};`).join("\n");
state.file = { relativePath, contents, byteLength: contents.length, truncated: false };
// The app mounts under StrictMode, whose remount must not wipe the saved offset.
return (
<StrictMode>
<div style={{ height: 400, display: "flex", flexDirection: "column" }}>
{filePreview(relativePath)}
</div>
</StrictMode>
);
};
const scroller = () => document.querySelector<HTMLElement>(".file-preview-virtualizer");

await using screen = await render(sourceFile("a.ts", 2000));
await vi.waitFor(() => expect(scroller()?.scrollHeight).toBeGreaterThan(5000));
scroller()!.scrollTop = 4200;
await vi.waitFor(() => expect(scroller()?.scrollTop).toBe(4200));

await screen.rerender(sourceFile("b.ts", 1500));
await vi.waitFor(() => expect(scroller()?.scrollTop).toBe(0));
await screen.rerender(sourceFile("a.ts", 2000));

await vi.waitFor(() => expect(scroller()?.scrollTop).toBe(4200));
});
});

describe("rendered Markdown task saves", () => {
it("keeps a truncated read read-only without queuing a replacement", async () => {
using _settings = setup();
Expand Down
44 changes: 43 additions & 1 deletion apps/web/src/components/files/FilePreviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,10 @@ interface FileRevealState {
cancelGuard: (() => void) | null;
handledRequestId: number | null;
latestRequestId: number | null;
/** The path's code view offset when it last unmounted, restored when it is shown again. */
scrollTop: number;
scrollContainer: HTMLElement | null;
restoreFrameId: number | null;
}

function useFileLineReveal(
Expand All @@ -521,6 +525,9 @@ function useFileLineReveal(
cancelGuard: null,
handledRequestId: null,
latestRequestId: null,
scrollTop: 0,
scrollContainer: null,
restoreFrameId: null,
};
if (!existingState) revealStatesByPath.set(relativePath, state);

Expand All @@ -530,10 +537,20 @@ function useFileLineReveal(
state.frameId = null;
}
state.cancelGuard?.();
if (state.restoreFrameId !== null) {
cancelAnimationFrame(state.restoreFrameId);
state.restoreFrameId = null;
}
};

const scrollContainer = fileContainer.closest<HTMLElement>(".file-preview-virtualizer");

if (phase === "unmount") {
// A view torn down before its restore ran never showed the saved offset, so keep it.
const restorePending = state.restoreFrameId !== null;
cancelPendingReveal();
if (scrollContainer && !restorePending) state.scrollTop = scrollContainer.scrollTop;
state.scrollContainer = null;
return;
}

Expand All @@ -550,12 +567,37 @@ function useFileLineReveal(
state.handledRequestId = null;
}

if (scrollContainer && state.scrollContainer !== scrollContainer) {
state.scrollContainer = scrollContainer;
const savedScrollTop = state.scrollTop;
// A pending line reveal owns the scroll position.
if (
savedScrollTop > 0 &&
(revealLine === null || state.handledRequestId === revealRequestId)
) {
const restoreScroll = (attempt: number) => {
state.restoreFrameId = requestAnimationFrame(() => {
state.restoreFrameId = null;
if (!scrollContainer.isConnected || scrollContainer.scrollTop !== 0) return;
// Wait for the virtualizer to size the file so the offset is not clamped.
const fits =
scrollContainer.scrollHeight - scrollContainer.clientHeight >= savedScrollTop;
if (fits || attempt >= REVEAL_MAX_ATTEMPTS) {
scrollContainer.scrollTop = savedScrollTop;
return;
}
restoreScroll(attempt + 1);
});
};
restoreScroll(0);
}
}

if (revealLine === null) {
fileContainer.style.minHeight = "";
return;
}

const scrollContainer = fileContainer.closest<HTMLElement>(".file-preview-virtualizer");
if (!scrollContainer) return;
fileContainer.style.minHeight = `${Math.ceil(
Math.max(instance.height, scrollContainer.clientHeight),
Expand Down
Loading