From 3a17787082612515589114a4f81023616d0d2eb1 Mon Sep 17 00:00:00 2001 From: Renfei Zhou Date: Sun, 15 Nov 2026 13:29:21 +0000 Subject: [PATCH] feat(fork): files reopen where the reader stopped A file tab scrolled halfway opened at the top again after a switch to another tab, a reopen or a reload, because each viewer remounts with a fresh scroll container. The source and rendered Markdown viewers now save the reader's place in localStorage (one key per file and view, 30-day expiry, 300-entry cap) and restore it on mount. PDF.js seeds its view from the same store. Only scrolls after reader input are saved. A line reveal still wins until it has landed once; a new request always reveals, even when the panel store restarts its id at 1 after a tab was closed. Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + .../components/files/BrowserDocumentFrame.tsx | 13 +- .../src/components/files/FilePreviewPanel.tsx | 49 +- .../fileReadingPosition.browser.test.tsx | 482 ++++++++++++++++++ .../components/files/fileReadingPosition.ts | 389 ++++++++++++++ .../src/components/files/fileReadingSave.ts | 103 ++++ .../components/files/fileReadingStore.test.ts | 321 ++++++++++++ .../src/components/files/fileReadingStore.ts | 417 +++++++++++++++ 8 files changed, 1764 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/components/files/fileReadingPosition.browser.test.tsx create mode 100644 apps/web/src/components/files/fileReadingPosition.ts create mode 100644 apps/web/src/components/files/fileReadingSave.ts create mode 100644 apps/web/src/components/files/fileReadingStore.test.ts create mode 100644 apps/web/src/components/files/fileReadingStore.ts diff --git a/README.md b/README.md index d1af3d57f6e76..46a3edbe990a8 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ This is a fork of T3 Code that integrates a few additional features, including: - **Compact when idle for Claude threads:** a choice in Settings → Providers → Advanced → Compact idle threads. Besides compacting before the prompt cache expires, it compacts a large idle Claude thread (default 400K tokens or more) a set time (default 15 minutes) after the last message or answer from a user or a bot, so the compaction runs while no one waits for a reply. The token counts and the wait are set right under the choice. - **Prune idle worker threads:** `t3 prune-worker-threads` deletes the messages, turn items, nodes, plans and runtime requests of idle worker threads. A worker thread is idle when it has no open run and, in the last 7 days, was not created, had no run requested or ended, and had no turn item or message updated. A worker thread is a subagent thread that an agent started. Subagent threads that a bot starts are workers too. Threads a person opens, bot threads themselves and a person's forks are never workers. An idle worker keeps its rows while recovery still needs the worker, or while a thread that keeps its own rows names the worker as parent or fork source. Pruned threads keep their thread row, runs, parent link and pull request links, so they still show up and open with no messages. The command runs only when you call it, so run it daily to keep worker transcripts from piling up. Other threads still grow the database. With `--dry-run`, the command only prints how many threads it would empty and how many rows it would delete. You can run the command while the server runs. - **One way to start subagents in Claude and Codex threads:** T3 Code starts Claude and Codex sessions without the provider's own subagent tools: Claude's `Agent`, `Workflow`, `SendMessage` and `ListAgents`, and Codex's `spawn_agent` with its other collaboration tools. The agent starts every subagent, same provider and model included, with T3 Code's `delegate_task` tool, so each child is a T3 Code thread that you can open and follow. +- **Files remember where you stopped reading:** a code file or a rendered Markdown file in the file viewer opens where you left it when you switch back to its tab, close and reopen it, or reload the app; so does a PDF in the desktop app. Positions stay in this browser for 30 days. A link to a line still jumps to that line. Large text files, tables and HTML pages still open at the top. - Enabling math rendering by default. - Other small fixes. diff --git a/apps/web/src/components/files/BrowserDocumentFrame.tsx b/apps/web/src/components/files/BrowserDocumentFrame.tsx index 7e8bc3e1d171a..bcc51ea66708d 100644 --- a/apps/web/src/components/files/BrowserDocumentFrame.tsx +++ b/apps/web/src/components/files/BrowserDocumentFrame.tsx @@ -32,6 +32,7 @@ import { useState, } from "react"; import { isElectron } from "~/env"; +import { type FileReadingKey, usePdfReadingView } from "./fileReadingSave"; import { FileSurfaceLoading } from "./fileSurfaceChrome"; import type { PdfViewState } from "./PdfViewer"; @@ -75,8 +76,13 @@ export const isPdfPreviewFile = (path: string): boolean => const PdfViewer = lazy(() => import("./PdfViewer")); // The document stays mounted while each revision gets a fresh PDF.js document. -function PdfDocument(props: { src: string; title: string; documentKey: string }) { - const [view, setView] = useState({ page: 0, offset: 0, zoom: null, left: 0 }); +function PdfDocument(props: { + src: string; + title: string; + documentKey: string; + readingKey: FileReadingKey | undefined; +}) { + const [view, setView] = usePdfReadingView(props.readingKey); return ( ) : props.htmlRender ? ( ); } @@ -500,7 +502,10 @@ interface FileRevealState { function useFileLineReveal( relativePath: string | null, revealLine: number | null, + scrollLine: number | null, revealRequestId: number, + onRevealApplied?: (fileContainer: HTMLElement, line: number) => void, + keepReveal?: (fileContainer: HTMLElement) => boolean, ): FilePostRender { const [revealStatesByPath] = useState(() => new Map()); @@ -542,7 +547,7 @@ function useFileLineReveal( state.handledRequestId = null; } - if (revealLine === null) { + if (scrollLine === null) { fileContainer.style.minHeight = ""; return; } @@ -642,15 +647,20 @@ function useFileLineReveal( // handled. const currentContents = instance.file?.contents; const line = - currentContents === undefined ? null : clampFileLine(currentContents, revealLine); + currentContents === undefined ? null : clampFileLine(currentContents, scrollLine); const targetTop = line === null ? null : resolveScrollTarget(line); if (line === null || targetTop === null) { if (attempt < REVEAL_MAX_ATTEMPTS) scheduleReveal(attempt + 1); return; } + if (keepReveal?.(fileContainer) === false) { + state.handledRequestId = revealRequestId; + return; + } updateFileLinkReveal(fileContainer, line); scrollContainer.scrollTop = targetTop; + onRevealApplied?.(fileContainer, line); state.handledRequestId = revealRequestId; guardScrollTarget(line); }); @@ -1042,9 +1052,14 @@ function RenderedMarkdownSurface({ }) { const saveTarget = { environmentId, cwd, relativePath }; useFileSaveSession(saveTarget, onPendingChange); + const scrollRef = useRenderedReadingPosition(environmentId, cwd, relativePath); return ( - + { @@ -1318,7 +1340,16 @@ export default function FilePreviewPanel({ : relativePath && attachment === undefined ? resolvePathLinkTarget(relativePath, cwd) : null; - const onFilePostRender = useFileLineReveal(relativePath, revealLine, revealRequestId); + const onFilePostRender = reading.trackSource( + useFileLineReveal( + relativePath, + revealLine, + reading.scrollLine, + reading.revealKey, + reading.onRevealApplied, + reading.keepReveal, + ), + ); // Mounting the file read under the workspace mutation id refetches it after each mutation. useAtomMount( canReadFiles && @@ -1447,7 +1478,7 @@ export default function FilePreviewPanel({ setRenderedPreferred(pressed); setHandledReveal( pressed && relativePath !== null - ? { path: relativePath, requestId: revealRequestId } + ? { path: relativePath, requestId: reading.revealKey } : null, ); }} diff --git a/apps/web/src/components/files/fileReadingPosition.browser.test.tsx b/apps/web/src/components/files/fileReadingPosition.browser.test.tsx new file mode 100644 index 0000000000000..48b3ef3dcf223 --- /dev/null +++ b/apps/web/src/components/files/fileReadingPosition.browser.test.tsx @@ -0,0 +1,482 @@ +import { DEFAULT_THEMES, preloadHighlighter } from "@pierre/diffs"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + EnvironmentId, + ThreadId, + type ProjectReadFileResult, + type ScopedThreadRef, +} from "@t3tools/contracts"; +import { AsyncResult } from "effect/reactivity"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { userEvent } from "vite-plus/test/browser"; +import { act, render } from "../../../test/render"; +import appCss from "~/index.css?inline"; +import { useClosedViewStore } from "~/closedViewStore"; +import { selectActiveRightPanelSurface, useRightPanelStore } from "~/rightPanelStore"; + +const state = vi.hoisted(() => ({ + file: null as ProjectReadFileResult | null, + files: {} as Record, + optimistic: null as ProjectReadFileResult | null, + writeFile: vi.fn(), +})); + +vi.mock(import("@t3tools/client-runtime/state/runtime"), async (original) => ({ + ...(await original()), + runAtomCommand: ((_registry: unknown, _command: unknown, request: unknown) => + state.writeFile(request)) as never, +})); +vi.mock(import("~/state/session"), async (original) => ({ + ...(await original()), + readEnvironmentScope: () => true, + useEnvironmentScope: () => true, +})); +vi.mock(import("~/state/filesystem"), async (original) => ({ + ...(await original()), + useFilesystemReadAccess: () => ({ canReadFiles: true, isPending: false, error: null }), +})); +vi.mock(import("~/state/use-atom-command"), async (original) => ({ + ...(await original()), + useAtomCommand: (() => state.writeFile) as never, +})); +vi.mock(import("~/state/server"), async (original) => { + const actual = await original(); + const { Atom } = await import("effect/reactivity"); + const noConfig = Atom.make(null); + return { + ...actual, + serverEnvironment: { ...actual.serverEnvironment, configValueAtom: (() => noConfig) as never }, + }; +}); +vi.mock(import("~/remoteOpen"), async (original) => ({ + ...(await original()), + useRemoteOpenState: () => ({ mode: "local-exec" }) as never, + useRemoteOpenResolution: () => ({ state: { mode: "local-exec" }, isResolved: true }) as never, +})); +vi.mock(import("~/hooks/useTheme"), async (original) => ({ + ...(await original()), + useTheme: () => ({ resolvedTheme: "dark" }) as never, +})); +vi.mock(import("~/hooks/useWorkspaceMutationRefresh"), () => ({ + useWorkspaceMutationRefresh: vi.fn(), +})); +vi.mock(import("./FileBreadcrumbs"), () => ({ FileBreadcrumbs: () => <> })); +vi.mock(import("./FileBrowserPanel"), () => ({ default: () =>
})); +vi.mock(import("../DiffWorkerPoolProvider"), () => ({ + DiffWorkerPoolProvider: ({ children }: { children?: ReactNode }) => <>{children}, +})); +vi.mock(import("./projectFilesQueryState"), async (original) => ({ + ...(await original()), + useProjectFileQuery: (_environmentId: EnvironmentId, _cwd: string, path: string | null) => ({ + data: (path === null ? undefined : state.files[path]) ?? state.file, + error: null, + isPending: false, + readError: null, + isNotFile: false, + isFile: true, + refresh: vi.fn(), + }), + getOptimisticProjectFileQueryData: () => state.optimistic, + getUnsavedProjectFileQueryData: () => null, + setProjectFileQueryData: ( + _environmentId: EnvironmentId, + _cwd: string, + relativePath: string, + contents: string, + ) => { + state.optimistic = { relativePath, contents, truncated: false, byteLength: contents.length }; + }, + confirmProjectFileQueryData: () => true, +})); + +import FilePreviewPanel from "./FilePreviewPanel"; +import { type FileReadingKey, usePdfReadingView } from "./fileReadingSave"; +import { FILE_LINK_REVEAL_ATTRIBUTE } from "./fileSurfaceChrome"; + +const environmentId = EnvironmentId.make("file-reading-position-test"); + +function setup() { + const keys = ["t3code.fileExplorerOpen", "t3code.renderMarkdown"]; + const previous = keys.map((key) => localStorage.getItem(key)); + localStorage.setItem(keys[0]!, "false"); + localStorage.setItem(keys[1]!, "true"); + state.file = null; + state.files = {}; + state.optimistic = null; + state.writeFile.mockReset().mockResolvedValue(AsyncResult.success(undefined)); + return { + [Symbol.dispose]() { + for (const [index, key] of keys.entries()) { + const value = previous[index]; + if (value == null) localStorage.removeItem(key); + else localStorage.setItem(key, value); + } + }, + }; +} + +const noop = () => {}; + +function sourceFile(relativePath: string, word: string, lines: number): ProjectReadFileResult { + const contents = Array.from( + { length: lines }, + (_, index) => `const ${word}_${index + 1} = ${index + 1};`, + ).join("\n"); + return { relativePath, contents, byteLength: contents.length, truncated: false }; +} + +async function readingSetup(name: string) { + await preloadHighlighter({ themes: Object.values(DEFAULT_THEMES), langs: ["typescript"] }); + const settings = setup(); + const style = document.createElement("style"); + style.textContent = appCss; + document.head.append(style); + const doc = Array.from({ length: 80 }, (_, index) => `Paragraph ${index + 1}.`).join("\n\n"); + state.files = { + "a.ts": sourceFile("a.ts", "alpha", 300), + "b.ts": sourceFile("b.ts", "beta", 300), + "c.ts": sourceFile("c.ts", "gamma", 10), + "doc.md": { relativePath: "doc.md", contents: doc, byteLength: doc.length, truncated: false }, + }; + const ref = scopeThreadRef(environmentId, ThreadId.make(`reading-${name}`)); + const cwd = `/reading/${name}`; + return { + ref, + cwd, + panel: useRightPanelStore.getState(), + saved: (path: string, view: "source" | "rendered") => + JSON.parse( + localStorage.getItem( + `t3code.fileReading.v1:${JSON.stringify([environmentId, `${cwd}/${path}`, view])}`, + ) ?? "null", + ) as { line?: number; block?: number; at: number } | null, + [Symbol.dispose]() { + useRightPanelStore.getState().removeThread(ref); + for (const key of Object.keys(localStorage)) { + if (key.startsWith("t3code.fileReading.v1:") || key.startsWith("t3code.fileReveal.v1:")) { + localStorage.removeItem(key); + } + } + style.remove(); + settings[Symbol.dispose](); + }, + }; +} + +/** Renders the active file tab the way the right panel does: one panel per workspace. */ +function ReadingPanel(props: { readonly threadRef: ScopedThreadRef; readonly cwd: string }) { + const surface = useRightPanelStore((store) => + selectActiveRightPanelSurface(store.byThreadKey, props.threadRef), + ); + return ( +
+ {surface?.kind === "file" ? ( + + ) : null} +
+ ); +} + +function sourceView(container: HTMLElement) { + const scroller = () => container.querySelector(".file-preview-virtualizer")!; + const line = (lineNumber: number) => + container + .querySelector("diffs-container") + ?.shadowRoot?.querySelector(`[data-line="${lineNumber}"]`) ?? null; + return { + scroller, + line, + /** The first row the viewport shows, partly or fully, and its top relative to the viewport. */ + topLine() { + const top = scroller().getBoundingClientRect().top; + const rows = container + .querySelector("diffs-container") + ?.shadowRoot?.querySelectorAll("[data-line]"); + const row = [...(rows ?? [])] + .filter((element) => element.getBoundingClientRect().bottom > top + 0.5) + .sort( + (left, right) => + Number(left.getAttribute("data-line")) - Number(right.getAttribute("data-line")), + )[0]; + return { + line: Number(row?.getAttribute("data-line")), + offset: Math.round(row!.getBoundingClientRect().top - top), + }; + }, + expectLineInView(lineNumber: number, text: string) { + const element = line(lineNumber); + expect(element?.textContent).toContain(text); + const view = scroller().getBoundingClientRect(); + const rect = element!.getBoundingClientRect(); + expect(rect.top).toBeGreaterThanOrEqual(view.top - 1); + expect(rect.bottom).toBeLessThanOrEqual(view.bottom + 1); + }, + }; +} + +describe("file reading position", () => { + it("returns a source file to the reader's line after a tab switch and a panel remount", async () => { + using reading = await readingSetup("switch"); + const { panel, ref } = reading; + panel.openFile(ref, "b.ts"); + panel.openFile(ref, "a.ts"); + await using screen = await render(); + const view = sourceView(screen.container); + await vi.waitFor(() => view.expectLineInView(1, "alpha_1")); + await userEvent.wheel(view.scroller(), { delta: { y: 1213 } }); + await vi.waitFor(() => expect(reading.saved("a.ts", "source")?.line).toBeGreaterThan(50)); + const before = view.topLine(); + expect(before.offset).toBeLessThan(0); + + await act(() => panel.activateSurface(ref, "file:b.ts")); + await vi.waitFor(() => view.expectLineInView(1, "beta_1")); + await act(() => panel.activateSurface(ref, "file:a.ts")); + await vi.waitFor(() => expect(view.topLine()).toEqual(before)); + + await screen.rerender(<>); + await screen.rerender(); + await vi.waitFor(() => expect(view.topLine()).toEqual(before)); + }); + + it("reveals a citation over a saved position, then keeps the reader's later place", async () => { + using reading = await readingSetup("cited"); + const { panel, ref } = reading; + panel.openFile(ref, "b.ts"); + panel.openFile(ref, "a.ts"); + await using screen = await render(); + const view = sourceView(screen.container); + await vi.waitFor(() => view.expectLineInView(1, "alpha_1")); + await userEvent.wheel(view.scroller(), { delta: { y: 3000 } }); + await vi.waitFor(() => expect(reading.saved("a.ts", "source")?.line).toBeGreaterThan(100)); + + await act(() => panel.openFile(ref, "a.ts", 40)); + await vi.waitFor(() => view.expectLineInView(40, "alpha_40")); + await act(() => panel.activateSurface(ref, "file:b.ts")); + await vi.waitFor(() => view.expectLineInView(1, "beta_1")); + await act(() => panel.activateSurface(ref, "file:a.ts")); + await vi.waitFor(() => view.expectLineInView(40, "alpha_40")); + expect(reading.saved("a.ts", "source")?.line).toBe(40); + await userEvent.wheel(view.scroller(), { delta: { y: 230 } }); + await vi.waitFor(() => expect(reading.saved("a.ts", "source")?.line).toBeGreaterThan(40)); + const before = view.topLine(); + expect(before.line).toBeLessThan(100); + + await act(() => panel.activateSurface(ref, "file:b.ts")); + await vi.waitFor(() => view.expectLineInView(1, "beta_1")); + await act(() => panel.openFile(ref, "c.ts")); + await act(() => panel.closeSurface(ref, "file:c.ts")); + await act(() => panel.activateSurface(ref, "file:a.ts")); + await vi.waitFor(() => expect(view.topLine()).toEqual(before)); + expect(view.line(40)?.hasAttribute(FILE_LINK_REVEAL_ATTRIBUTE)).toBe(true); + }); + + it("clamps a saved position past the end of shorter contents without rewriting it", async () => { + using reading = await readingSetup("shorter"); + const { panel, ref } = reading; + panel.openFile(ref, "b.ts"); + panel.openFile(ref, "a.ts"); + await using screen = await render(); + const view = sourceView(screen.container); + await vi.waitFor(() => view.expectLineInView(1, "alpha_1")); + await userEvent.wheel(view.scroller(), { delta: { y: 4000 } }); + await vi.waitFor(() => expect(reading.saved("a.ts", "source")?.line).toBeGreaterThan(150)); + await act(() => panel.activateSurface(ref, "file:b.ts")); + await vi.waitFor(() => view.expectLineInView(1, "beta_1")); + const saved = reading.saved("a.ts", "source"); + + state.files["a.ts"] = sourceFile("a.ts", "alpha", 60); + await act(() => panel.activateSurface(ref, "file:a.ts")); + await vi.waitFor(() => view.expectLineInView(60, "alpha_60")); + const scroller = view.scroller(); + expect(scroller.scrollTop).toBeGreaterThanOrEqual( + scroller.scrollHeight - scroller.clientHeight - 1, + ); + expect(reading.saved("a.ts", "source")).toEqual(saved); + }); + + it("returns rendered Markdown to the reader's block after a tab switch, clamped when shorter", async () => { + using reading = await readingSetup("rendered"); + const { panel, ref } = reading; + panel.openFile(ref, "b.ts"); + panel.openFile(ref, "doc.md"); + await using screen = await render(); + const viewport = () => + screen.container + .querySelector(".chat-markdown") + ?.closest('[data-slot="scroll-area-viewport"]'); + await vi.waitFor(() => expect(viewport()?.textContent).toContain("Paragraph 80.")); + await userEvent.wheel(viewport()!, { delta: { y: 1500 } }); + await vi.waitFor(() => expect(reading.saved("doc.md", "rendered")?.block).toBeGreaterThan(10)); + const wheeled = reading.saved("doc.md", "rendered")!.block!; + + // The custom scrollbar sits beside the viewport and moves it directly. + const scrollbar = screen.container.querySelector( + '[data-slot="scroll-area-scrollbar"]', + )!; + const track = scrollbar.getBoundingClientRect(); + await userEvent.click(scrollbar, { + position: { x: track.width / 2, y: track.height - 4 }, + }); + await vi.waitFor(() => + expect(reading.saved("doc.md", "rendered")?.block).toBeGreaterThan(wheeled), + ); + const before = viewport()!.scrollTop; + + await act(() => panel.activateSurface(ref, "file:b.ts")); + await vi.waitFor(() => expect(viewport()).toBeFalsy()); + await act(() => panel.activateSurface(ref, "file:doc.md")); + await vi.waitFor(() => expect(Math.abs(viewport()!.scrollTop - before)).toBeLessThanOrEqual(1)); + + await act(() => panel.activateSurface(ref, "file:b.ts")); + await vi.waitFor(() => expect(viewport()).toBeFalsy()); + const saved = reading.saved("doc.md", "rendered"); + const shorter = Array.from({ length: 30 }, (_, index) => `Paragraph ${index + 1}.`).join( + "\n\n", + ); + state.files["doc.md"] = { + relativePath: "doc.md", + contents: shorter, + byteLength: shorter.length, + truncated: false, + }; + await act(() => panel.activateSurface(ref, "file:doc.md")); + await vi.waitFor(() => { + const element = viewport()!; + expect(element.textContent).not.toContain("Paragraph 31."); + expect(element.scrollHeight).toBeGreaterThan(element.clientHeight); + expect(element.scrollTop).toBeGreaterThanOrEqual( + element.scrollHeight - element.clientHeight - 1, + ); + }); + expect(reading.saved("doc.md", "rendered")).toEqual(saved); + }); + + it("returns a citation reopened with Reopen Closed View to the reader's place", async () => { + using reading = await readingSetup("reopen-closed"); + const { panel, ref } = reading; + panel.openFile(ref, "b.ts"); + panel.openFile(ref, "a.ts", 40); + await using screen = await render(); + const view = sourceView(screen.container); + await vi.waitFor(() => view.expectLineInView(40, "alpha_40")); + await userEvent.wheel(view.scroller(), { delta: { y: 2000 } }); + await vi.waitFor(() => expect(reading.saved("a.ts", "source")?.line).toBeGreaterThan(100)); + const before = view.topLine(); + + await act(() => panel.closeSurface(ref, "file:a.ts")); + await vi.waitFor(() => view.expectLineInView(1, "beta_1")); + const entry = useClosedViewStore + .getState() + .entries.find((closed) => closed.kind === "panel-tab" && closed.surface.id === "file:a.ts")!; + // Reopen Closed View opens the tab with its old line, then drops the closed entry after an await. + panel.openFile(ref, "a.ts", 40); + await Promise.resolve(); + useClosedViewStore.getState().remove(entry.id); + + await vi.waitFor(() => expect(view.topLine()).toEqual(before)); + expect(view.line(40)).toBeNull(); + }); + + it("jumps to a Markdown citation reopened at request 1 after it was read rendered", async () => { + using reading = await readingSetup("reopen-md"); + const { panel, ref } = reading; + panel.openFile(ref, "b.ts"); + panel.openFile(ref, "doc.md", 40); + await using screen = await render(); + const view = sourceView(screen.container); + await vi.waitFor(() => expect(view.line(40)).toBeTruthy()); + await userEvent.click(screen.getByRole("button", { name: "Show rendered markdown" })); + await vi.waitFor(() => expect(screen.container.querySelector(".chat-markdown")).toBeTruthy()); + + await act(() => panel.closeSurface(ref, "file:doc.md")); + await vi.waitFor(() => view.expectLineInView(1, "beta_1")); + await act(() => panel.openFile(ref, "doc.md", 40)); + + await vi.waitFor(() => expect(screen.container.querySelector(".chat-markdown")).toBeFalsy()); + await vi.waitFor(() => + expect(view.line(40)?.hasAttribute(FILE_LINK_REVEAL_ATTRIBUTE)).toBe(true), + ); + const scroller = view.scroller(); + const line = view.line(40)!.getBoundingClientRect(); + const box = scroller.getBoundingClientRect(); + expect(line.top).toBeGreaterThanOrEqual(box.top); + expect(line.bottom).toBeLessThanOrEqual(box.bottom); + }); + + it("jumps to a citation reopened at request 1 after its tab was closed", async () => { + using reading = await readingSetup("reopen"); + const panel = useRightPanelStore.getState(); + panel.openFile(reading.ref, "b.ts"); + panel.openFile(reading.ref, "a.ts", 40); + await using screen = await render(); + const view = sourceView(screen.container); + await vi.waitFor(() => view.expectLineInView(40, "alpha_40")); + const revealedTop = view.scroller().scrollTop; + await userEvent.wheel(view.scroller(), { delta: { y: 2000 } }); + await vi.waitFor(() => expect(view.scroller().scrollTop).toBeGreaterThan(revealedTop + 1000)); + + await act(() => panel.closeSurface(reading.ref, "file:a.ts")); + await vi.waitFor(() => view.expectLineInView(1, "beta_1")); + await act(() => panel.openFile(reading.ref, "a.ts", 40)); + + await vi.waitFor(() => view.expectLineInView(40, "alpha_40")); + }); +}); + +describe("PDF reading view", () => { + function PdfView(props: { + readonly file: FileReadingKey; + readonly onView: (page: number) => void; + }) { + const [view, setView] = usePdfReadingView(props.file); + props.onView(view.page); + return ( + + ); + } + + it("seeds a new PDF.js mount with the page saved by an earlier one", async () => { + const file = { environmentId, absolutePath: "/reading/pdf/report.pdf" }; + const pages: number[] = []; + try { + { + await using screen = await render( + pages.push(page)} />, + ); + await userEvent.click(screen.getByRole("button", { name: "Go to page 3" })); + await vi.waitFor(() => expect(pages.at(-1)).toBe(2)); + } + pages.length = 0; + await using screen = await render( + pages.push(page)} />, + ); + await expect.element(screen.getByRole("button")).toBeVisible(); + expect(pages[0]).toBe(2); + } finally { + for (const key of Object.keys(localStorage)) { + if (key.startsWith("t3code.fileReading.v1:")) localStorage.removeItem(key); + } + } + }); +}); diff --git a/apps/web/src/components/files/fileReadingPosition.ts b/apps/web/src/components/files/fileReadingPosition.ts new file mode 100644 index 0000000000000..a44fb07947522 --- /dev/null +++ b/apps/web/src/components/files/fileReadingPosition.ts @@ -0,0 +1,389 @@ +import { VirtualizedFile, type File as FileInstance, type PostRenderPhase } from "@pierre/diffs"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; +import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks"; +import { useState } from "react"; + +import { useClosedViewStore } from "~/closedViewStore"; +import { useRightPanelStore } from "~/rightPanelStore"; + +import { + createRevealLedger, + liveFileSurface, + type FileReadingKey, + type ReadingPosition, + type ReadingPositionOf, + type RevealVerdict, +} from "./fileReadingStore"; + +import { PendingPosition, readings } from "./fileReadingSave"; + +const HOLD_MS = 1000; +const CAPTURE_ATTEMPTS = 8; + +const ledger = createRevealLedger(readings, useRightPanelStore, useClosedViewStore); +ledger.start(); + +type FilePostRender = ( + fileContainer: HTMLElement, + instance: FileInstance, + phase: PostRenderPhase, +) => void; +type SourcePosition = ReadingPositionOf<"source">; +type RenderedPosition = ReadingPositionOf<"rendered">; + +interface ViewGeometry

{ + /** The scroll top that puts `position` at the viewport top, or null until the content can place it. */ + target(position: P): number | null; + /** The position at the viewport top, or null while the content cannot tell. */ + capture(): P | null; +} + +/** + * Restores and saves one scroll container's position. Only scrolls that follow reader input on the + * container are saved, so restores, reveals and layout resets never overwrite a saved position. + */ +class ReadingTracker

{ + private state: "idle" | "holding" | "armed" = "idle"; + private frame: number | null = null; + private holdUntil = 0; + /** A scrollbar drag can pause longer than the save delay without a new pointerdown. */ + private pointerDown = false; + private readonly pending: PendingPosition; + /** The restored or last captured position. */ + anchor: P | null = null; + + /** `input` receives reader input; it differs from `element` when a custom scrollbar sits beside it. */ + constructor( + private readonly element: HTMLElement, + file: FileReadingKey, + private readonly geometry: ViewGeometry

, + private readonly input: HTMLElement = element, + ) { + this.pending = new PendingPosition(file, () => { + if (this.state === "armed" && !this.pointerDown) this.state = "idle"; + }); + input.addEventListener("wheel", this.arm, { passive: true }); + input.addEventListener("touchstart", this.arm, { passive: true }); + input.addEventListener("pointerdown", this.press, { passive: true, capture: true }); + input.addEventListener("keydown", this.arm, { capture: true }); + element.addEventListener("scroll", this.onScroll, { passive: true }); + window.addEventListener("pointerup", this.release, true); + window.addEventListener("pointercancel", this.release, true); + window.addEventListener("pagehide", this.pending.flush); + } + + restore(position: P): void { + this.cancelFrame(); + this.anchor = position; + this.state = "holding"; + this.holdUntil = performance.now() + HOLD_MS; + this.place(position); + this.frame = requestAnimationFrame(this.hold); + } + + /** Hands the container to a reveal; a position the reader already scrolled to is kept. */ + stop(): void { + this.cancelFrame(); + this.pending.flush(); + this.state = "idle"; + } + + /** Saves where a landed reveal put the reader, without reading the half-rendered DOM. */ + record(position: P): void { + this.stop(); + this.anchor = position; + this.pending.write(position); + } + + dispose(): void { + this.stop(); + this.input.removeEventListener("wheel", this.arm); + this.input.removeEventListener("touchstart", this.arm); + this.input.removeEventListener("pointerdown", this.press, { capture: true }); + this.input.removeEventListener("keydown", this.arm, { capture: true }); + this.element.removeEventListener("scroll", this.onScroll); + window.removeEventListener("pointerup", this.release, true); + window.removeEventListener("pointercancel", this.release, true); + window.removeEventListener("pagehide", this.pending.flush); + } + + private readonly press = () => { + this.pointerDown = true; + this.arm(); + }; + + private readonly release = () => { + this.pointerDown = false; + if (this.state === "armed" && !this.pending.waiting && this.frame === null) this.state = "idle"; + }; + + private readonly arm = () => { + if (this.state === "holding") this.cancelFrame(); + this.state = "armed"; + // Input that scrolls nothing (a key in the editor, a wheel at the end) disarms after the delay. + this.pending.touch(); + }; + + private readonly onScroll = () => { + if (this.state === "armed" && this.frame === null) this.capture(CAPTURE_ATTEMPTS); + }; + + private capture(attempts: number): void { + this.frame = requestAnimationFrame(() => { + this.frame = null; + if (this.state !== "armed") return; + const position = this.geometry.capture(); + if (position === null) { + // Pierre moves its rendered window a frame or two after a long jump. + if (attempts > 1) this.capture(attempts - 1); + return; + } + this.anchor = position; + this.pending.hold(position); + }); + } + + private readonly hold = (time: number) => { + this.frame = null; + if (this.state !== "holding" || this.anchor === null) return; + if (time >= this.holdUntil) { + this.state = "idle"; + return; + } + this.place(this.anchor); + this.frame = requestAnimationFrame(this.hold); + }; + + private place(position: P): void { + const target = this.geometry.target(position); + if (target === null) return; + const max = Math.max(0, this.element.scrollHeight - this.element.clientHeight); + const top = Math.min(Math.max(0, target), max); + if (Math.abs(this.element.scrollTop - top) > 1) this.element.scrollTop = top; + } + + private cancelFrame(): void { + if (this.frame !== null) cancelAnimationFrame(this.frame); + this.frame = null; + } +} + +type SourceLines = Pick< + VirtualizedFile, + "getLinePosition" | "getNumericScrollAnchor" +>; + +class SourceGeometry implements ViewGeometry { + constructor( + private readonly element: HTMLElement, + private fileContainer: HTMLElement, + private lines: SourceLines, + ) {} + + follow(fileContainer: HTMLElement, lines: SourceLines): void { + this.fileContainer = fileContainer; + this.lines = lines; + } + + target(position: SourcePosition): number | null { + const line = this.lines.getLinePosition(position.line); + return line === undefined ? null : this.fileTop() + line.top + position.offset; + } + + capture(): SourcePosition | null { + const viewportTop = this.element.scrollTop - this.fileTop(); + const anchor = this.lines.getNumericScrollAnchor(viewportTop); + if (anchor === undefined) return null; + return { view: "source", line: anchor.lineNumber, offset: viewportTop - anchor.top }; + } + + /** Where a line reveal centers `line`. */ + centered(line: number): SourcePosition { + const height = this.lines.getLinePosition(line)?.height ?? 0; + return { view: "source", line, offset: -Math.max(0, (this.element.clientHeight - height) / 2) }; + } + + private fileTop(): number { + return ( + this.element.scrollTop + + this.fileContainer.getBoundingClientRect().top - + this.element.getBoundingClientRect().top + ); + } +} + +class RenderedGeometry implements ViewGeometry { + constructor(private readonly viewport: HTMLElement) {} + + target(position: RenderedPosition): number | null { + // The first block's top margin belongs to the top of the file. + if (position.block === 0 && position.fraction === 0) return 0; + const blocks = this.blocks(); + if (!blocks || blocks.length === 0) return null; + // A block past the end of shorter contents clamps to the bottom. + const block = blocks.item(position.block); + if (!block) return this.viewport.scrollHeight; + const rect = block.getBoundingClientRect(); + return ( + this.viewport.scrollTop + + rect.top - + this.viewport.getBoundingClientRect().top + + position.fraction * rect.height + ); + } + + capture(): RenderedPosition | null { + const blocks = this.blocks(); + if (!blocks || blocks.length === 0) return null; + const top = this.viewport.getBoundingClientRect().top; + let low = 0; + let high = blocks.length - 1; + while (low < high) { + const middle = (low + high) >> 1; + if (blocks[middle]!.getBoundingClientRect().bottom > top) high = middle; + else low = middle + 1; + } + const rect = blocks[low]!.getBoundingClientRect(); + const fraction = rect.height > 0 ? (top - rect.top) / rect.height : 0; + return { view: "rendered", block: low, fraction: Math.min(Math.max(0, fraction), 0.999) }; + } + + private blocks(): HTMLCollection | undefined { + return this.viewport.querySelector(".chat-markdown")?.children; + } +} + +interface SourceBinding { + readonly tracker: ReadingTracker; + readonly geometry: SourceGeometry; +} +// Keyed by Pierre's file container: its unmount phase also fires when the file turns into a placeholder. +const sourceBindings = new WeakMap(); +const sourceTrackerByFile = new Map>(); +const fileId = (file: FileReadingKey) => JSON.stringify([file.environmentId, file.absolutePath]); + +function trackSourceView( + fileContainer: HTMLElement, + instance: SourceLines, + phase: PostRenderPhase, + file: FileReadingKey, + verdict: RevealVerdict, +): void { + const existing = sourceBindings.get(fileContainer); + if (phase === "unmount") { + existing?.tracker.dispose(); + sourceBindings.delete(fileContainer); + if (existing !== undefined && sourceTrackerByFile.get(fileId(file)) === existing.tracker) { + sourceTrackerByFile.delete(fileId(file)); + } + return; + } + if (existing !== undefined) { + existing.geometry.follow(fileContainer, instance); + if (verdict === "pending") existing.tracker.stop(); + return; + } + const element = fileContainer.closest(".file-preview-virtualizer"); + if (element === null) return; + const geometry = new SourceGeometry(element, fileContainer, instance); + const tracker = new ReadingTracker(element, file, geometry); + sourceBindings.set(fileContainer, { tracker, geometry }); + sourceTrackerByFile.set(fileId(file), tracker); + if (verdict === "pending") return; + const saved = readings.readPosition(file, "source"); + if (saved !== null) tracker.restore(saved); +} + +/** + * Where the file panel's current file was last read. A line reveal the tab has not shown yet + * wins over the saved position; once shown, the saved position wins. + */ +export function useFileReadingPosition(input: { + readonly environmentId: EnvironmentId; + readonly threadRef: ScopedThreadRef; + /** The tab's own path, as the right panel store names it. */ + readonly requestedPath: string | null; + readonly cwd: string; + /** The workspace file shown, or null for an attachment. */ + readonly relativePath: string | null; + readonly revealLine: number | null; +}) { + const threadKey = scopedThreadKey(input.threadRef); + const surface = useRightPanelStore((state) => + liveFileSurface(state.byThreadKey[threadKey], input.requestedPath), + ); + const verdict = ledger.verdict(surface); + const file = + input.relativePath === null + ? null + : { + environmentId: input.environmentId, + absolutePath: resolvePathLinkTarget(input.relativePath, input.cwd), + }; + const viewMount = () => ({ + surface, + renderedAllowed: + verdict === "shown" && + file !== null && + readings.newestView(file, ["source", "rendered"]) === "rendered", + }); + // A reveal that lands while this file's view is mounted must not flip it to rendered. + const [mounted, setMounted] = useState(viewMount); + if (mounted.surface !== surface) setMounted(viewMount()); + + return { + scrollLine: verdict === "pending" ? input.revealLine : null, + revealKey: ledger.generation(surface), + renderedAllowed: mounted.renderedAllowed, + /** Asked just before a reveal scrolls: a reopened tab whose citation was shown restores instead. */ + keepReveal: (fileContainer: HTMLElement) => { + if (file === null || ledger.verdict(surface) !== "shown") return true; + const saved = readings.readPosition(file, "source"); + const binding = sourceBindings.get(fileContainer); + if (saved === null || binding === undefined) return true; + binding.tracker.restore(saved); + return false; + }, + onRevealApplied: (fileContainer: HTMLElement, line: number) => { + if (surface !== undefined) ledger.markShown(threadKey, surface); + const binding = sourceBindings.get(fileContainer); + binding?.tracker.record(binding.geometry.centered(line)); + }, + trackSource: + (reveal: FilePostRender): FilePostRender => + (fileContainer, instance, phase) => { + reveal(fileContainer, instance, phase); + if (file === null || !(instance instanceof VirtualizedFile)) return; + // A landed reveal does not re-render the panel, so ask the ledger now. + trackSourceView(fileContainer, instance, phase, file, ledger.verdict(surface)); + }, + caretLine: (): number | "first-visible" => + (ledger.verdict(surface) === "pending" ? input.revealLine : null) ?? + (file === null ? undefined : sourceTrackerByFile.get(fileId(file))?.anchor?.line) ?? + "first-visible", + }; +} + +/** A stable ref for a rendered Markdown `ScrollArea` root, restoring and saving its top block. */ +export function useRenderedReadingPosition( + environmentId: EnvironmentId, + cwd: string, + relativePath: string, +) { + const [ref] = useState(() => (root: HTMLElement | null) => { + const viewport = root?.querySelector('[data-slot="scroll-area-viewport"]'); + if (!viewport) return; + const file = { environmentId, absolutePath: resolvePathLinkTarget(relativePath, cwd) }; + const tracker = new ReadingTracker( + viewport, + file, + new RenderedGeometry(viewport), + root ?? viewport, + ); + const saved = readings.readPosition(file, "rendered"); + if (saved !== null) tracker.restore(saved); + return () => tracker.dispose(); + }); + return ref; +} diff --git a/apps/web/src/components/files/fileReadingSave.ts b/apps/web/src/components/files/fileReadingSave.ts new file mode 100644 index 0000000000000..b85a3557d3d1d --- /dev/null +++ b/apps/web/src/components/files/fileReadingSave.ts @@ -0,0 +1,103 @@ +import { useEffect, useState } from "react"; + +import type { PdfViewState } from "./PdfViewer"; +import { + createFileReadingStore, + type FileReadingKey, + type ReadingPosition, +} from "./fileReadingStore"; + +export type { FileReadingKey } from "./fileReadingStore"; + +// Kept apart from the panel shell so the chat view's PDF frame does not load Pierre's file classes. +const SAVE_DELAY_MS = 300; + +export const readings = createFileReadingStore({ + storage: () => window.localStorage, + now: () => Date.now(), +}); + +/** A position waiting out the save delay, written early on unmount and pagehide. */ +export class PendingPosition { + private position: ReadingPosition | null = null; + private timer: ReturnType | null = null; + + constructor( + private readonly file: FileReadingKey, + private readonly onSettle?: () => void, + ) {} + + hold(position: ReadingPosition): void { + this.position = position; + this.clearTimer(); + this.schedule(); + } + + /** Starts the settle delay without a position, unless one is already waiting. */ + touch(): void { + if (this.timer === null) this.schedule(); + } + + write(position: ReadingPosition): void { + this.position = position; + this.flush(); + } + + get waiting(): boolean { + return this.timer !== null; + } + + readonly flush = (): void => { + this.clearTimer(); + if (this.position !== null) readings.writePosition(this.file, this.position); + this.position = null; + }; + + private schedule(): void { + this.timer = setTimeout(() => { + this.flush(); + this.onSettle?.(); + }, SAVE_DELAY_MS); + } + + private clearTimer(): void { + if (this.timer !== null) clearTimeout(this.timer); + this.timer = null; + } +} + +const FIRST_PAGE: PdfViewState = { page: 0, offset: 0, zoom: null, left: 0 }; + +/** PDF.js view state seeded from and saved to the reading store when `file` is given. */ +export function usePdfReadingView(file: FileReadingKey | undefined) { + const [pending] = useState(() => (file === undefined ? null : new PendingPosition(file))); + const [view, setView] = useState(() => { + const saved = file === undefined ? null : readings.readPosition(file, "pdf"); + return saved === null + ? FIRST_PAGE + : { page: saved.page, offset: saved.offset, zoom: saved.zoom, left: saved.left }; + }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- writes the debounced PDF position to localStorage when the page hides or the document unmounts + useEffect(() => { + if (pending === null) return; + window.addEventListener("pagehide", pending.flush); + return () => { + window.removeEventListener("pagehide", pending.flush); + pending.flush(); + }; + }, [pending]); + const changeView = (next: PdfViewState) => { + // PdfViewer reports its own restore; saving that again would renew the entry on every open. + if ( + next.page === view.page && + next.offset === view.offset && + next.zoom === view.zoom && + next.left === view.left + ) { + return; + } + setView(next); + pending?.hold({ view: "pdf", ...next }); + }; + return [view, changeView] as const; +} diff --git a/apps/web/src/components/files/fileReadingStore.test.ts b/apps/web/src/components/files/fileReadingStore.test.ts new file mode 100644 index 0000000000000..860ffacc45380 --- /dev/null +++ b/apps/web/src/components/files/fileReadingStore.test.ts @@ -0,0 +1,321 @@ +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { type EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { useClosedViewStore } from "~/closedViewStore"; +import { useRightPanelStore } from "~/rightPanelStore"; + +import { + createFileReadingStore, + createRevealLedger, + liveFileSurface, + type StorageLike, +} from "./fileReadingStore"; + +const DAY = 24 * 60 * 60 * 1000; +const file = { environmentId: "env-1", absolutePath: "/work/a.ts" }; +const sourceKey = 't3code.fileReading.v1:["env-1","/work/a.ts","source"]'; + +function memoryStorage(options: { quota?: number } = {}) { + const items = new Map(); + let setCalls = 0; + const used = () => [...items].reduce((sum, [key, value]) => sum + key.length + value.length, 0); + const storage: StorageLike = { + get length() { + return items.size; + }, + key: (index) => [...items.keys()][index] ?? null, + getItem: (key) => items.get(key) ?? null, + setItem: (key, value) => { + setCalls += 1; + const previous = items.get(key); + items.set(key, value); + if (options.quota !== undefined && used() > options.quota) { + if (previous === undefined) items.delete(key); + else items.set(key, previous); + throw new DOMException("full", "QuotaExceededError"); + } + }, + removeItem: (key) => void items.delete(key), + }; + return { storage, items, setCalls: () => setCalls }; +} + +function setup(storage: StorageLike | null = memoryStorage().storage) { + const clock = { now: 1_000_000 }; + const readings = createFileReadingStore({ storage: () => storage, now: () => clock.now }); + return { clock, readings }; +} + +function setupPanel() { + useRightPanelStore.setState({ + byThreadKey: {}, + userActionRevisionByThreadKey: {}, + closeRevisionByThreadKey: {}, + }); + useClosedViewStore.setState({ entries: [] }); + const ref = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-reading")); + return { ref, threadKey: scopedThreadKey(ref) }; +} + +const surfaceOf = (threadKey: string, relativePath: string) => + liveFileSurface(useRightPanelStore.getState().byThreadKey[threadKey], relativePath); + +describe("file reading positions", () => { + it("keys an entry by environment, absolute path and view", () => { + const { items, storage } = memoryStorage(); + const { readings } = setup(storage); + readings.writePosition(file, { view: "source", line: 12, offset: -4 }); + + expect(JSON.parse(items.get(sourceKey)!)).toEqual({ + v: 1, + at: 1_000_000, + view: "source", + line: 12, + offset: -4, + }); + expect(readings.readPosition(file, "source")).toEqual({ view: "source", line: 12, offset: -4 }); + expect(readings.readPosition(file, "rendered")).toBeNull(); + expect(readings.readPosition({ ...file, environmentId: "env-2" }, "source")).toBeNull(); + expect(setup(storage).readings.readPosition(file, "source")).toEqual({ + view: "source", + line: 12, + offset: -4, + }); + }); + + it("names the newest of a file's saved views", () => { + const { clock, readings } = setup(); + expect(readings.newestView(file, ["source", "rendered"])).toBeNull(); + readings.writePosition(file, { view: "rendered", block: 3, fraction: 0.5 }); + clock.now += 1; + readings.writePosition(file, { view: "source", line: 4, offset: 0 }); + expect(readings.newestView(file, ["source", "rendered"])).toBe("source"); + clock.now += 1; + readings.writePosition(file, { view: "rendered", block: 4, fraction: 0 }); + expect(readings.newestView(file, ["source", "rendered"])).toBe("rendered"); + }); + + it("expires an entry 30 days after it was written, and reads do not renew it", () => { + const { items, storage } = memoryStorage(); + const { clock, readings } = setup(storage); + readings.writePosition(file, { view: "source", line: 3, offset: 0 }); + clock.now += 30 * DAY - 1; + expect(readings.readPosition(file, "source")?.line).toBe(3); + clock.now += 1; + expect(readings.readPosition(file, "source")).toBeNull(); + expect(items.has(sourceKey)).toBe(false); + }); + + it("keeps 300 positions, evicting the oldest, and leaves other keys alone", () => { + const { items, storage } = memoryStorage(); + items.set("t3code.renderMarkdown", "true"); + const { clock, readings } = setup(storage); + for (let index = 0; index < 301; index += 1) { + clock.now += 1; + readings.writePosition( + { ...file, absolutePath: `/work/${index}.ts` }, + { + view: "source", + line: 1, + offset: 0, + }, + ); + } + const positionKeys = [...items.keys()].filter((key) => key.startsWith("t3code.fileReading")); + expect(positionKeys).toHaveLength(300); + expect(items.has('t3code.fileReading.v1:["env-1","/work/0.ts","source"]')).toBe(false); + expect(items.get("t3code.renderMarkdown")).toBe("true"); + }); + + it("drops corrupt, wrong-version, out-of-range and expired entries on first use", () => { + const { items, storage } = memoryStorage(); + const key = (path: string) => `t3code.fileReading.v1:["env-1","${path}","source"]`; + items.set(key("/corrupt"), "{"); + items.set( + key("/v2"), + JSON.stringify({ v: 2, at: 1_000_000, view: "source", line: 1, offset: 0 }), + ); + items.set( + key("/line"), + JSON.stringify({ v: 1, at: 1_000_000, view: "source", line: 0, offset: 0 }), + ); + items.set(key("/infinite"), '{"v":1,"at":1000000,"view":"source","line":2,"offset":1e999}'); + items.set( + 't3code.fileReading.v1:["env-1","/fraction","rendered"]', + JSON.stringify({ v: 1, at: 1_000_000, view: "rendered", block: 0, fraction: 1 }), + ); + items.set( + key("/old"), + JSON.stringify({ v: 1, at: 1_000_000 - 31 * DAY, view: "source", line: 1, offset: 0 }), + ); + items.set( + "t3code.fileReveal.v1:not-json", + JSON.stringify({ v: 1, at: 1_000_000, id: 1, line: 1 }), + ); + items.set( + key("/good"), + JSON.stringify({ v: 1, at: 1_000_000, view: "source", line: 7, offset: -2 }), + ); + const { readings } = setup(storage); + + expect(readings.readPosition({ ...file, absolutePath: "/corrupt" }, "source")).toBeNull(); + expect([...items.keys()]).toEqual([key("/good")]); + expect(readings.readPosition({ ...file, absolutePath: "/good" }, "source")?.line).toBe(7); + }); + + it("frees the oldest half on a full quota, retries once, then keeps writes in memory", () => { + const full = memoryStorage({ quota: 1_200 }); + const { clock, readings } = setup(full.storage); + for (let index = 0; index < 20; index += 1) { + clock.now += 1; + readings.writePosition( + { ...file, absolutePath: `/work/${index}.ts` }, + { + view: "source", + line: index + 1, + offset: 0, + }, + ); + } + expect(full.items.has('t3code.fileReading.v1:["env-1","/work/19.ts","source"]')).toBe(true); + expect(full.items.has('t3code.fileReading.v1:["env-1","/work/0.ts","source"]')).toBe(false); + + const tiny = memoryStorage({ quota: 10 }); + const stuck = setup(tiny.storage).readings; + stuck.writePosition(file, { view: "source", line: 5, offset: 0 }); + expect(tiny.setCalls()).toBe(2); + stuck.writePosition( + { ...file, absolutePath: "/work/b.ts" }, + { view: "source", line: 6, offset: 0 }, + ); + expect(tiny.setCalls()).toBe(2); + expect(stuck.readPosition(file, "source")?.line).toBe(5); + expect(stuck.readPosition({ ...file, absolutePath: "/work/b.ts" }, "source")?.line).toBe(6); + }); + + it("serves from memory when storage is missing or throws", () => { + const missing = setup(null).readings; + missing.writePosition(file, { view: "pdf", page: 2, offset: 10, zoom: null, left: 0 }); + expect(missing.readPosition(file, "pdf")?.page).toBe(2); + + const throwing: StorageLike = { + get length(): number { + throw new Error("denied"); + }, + key: () => { + throw new Error("denied"); + }, + getItem: () => { + throw new Error("denied"); + }, + setItem: () => { + throw new Error("denied"); + }, + removeItem: () => { + throw new Error("denied"); + }, + }; + const denied = setup(throwing).readings; + expect(denied.readPosition(file, "source")).toBeNull(); + denied.writePosition(file, { view: "source", line: 9, offset: 0 }); + expect(denied.readPosition(file, "source")?.line).toBe(9); + + const blocked = createFileReadingStore({ + storage: () => { + throw new Error("SecurityError"); + }, + now: () => 0, + }); + blocked.writeReceipt("thread", "a.ts", { id: 1, line: 2 }); + expect(blocked.readReceipt("thread", "a.ts")).toEqual({ id: 1, line: 2 }); + }); +}); + +describe("reveal receipts", () => { + it("adopts a matching receipt so a reloaded citation restores instead of jumping", () => { + const { ref, threadKey } = setupPanel(); + useRightPanelStore.getState().openFile(ref, "a.ts", 40); + useRightPanelStore.getState().openFile(ref, "b.ts", 9); + const { items, storage } = memoryStorage(); + setup(storage).readings.writeReceipt(threadKey, "a.ts", { id: 1, line: 40 }); + setup(storage).readings.writeReceipt(threadKey, "b.ts", { id: 1, line: 8 }); + setup(storage).readings.writeReceipt(threadKey, "gone.ts", { id: 1, line: 3 }); + + const { readings } = setup(storage); + const ledger = createRevealLedger(readings, useRightPanelStore, useClosedViewStore); + + expect(ledger.verdict(surfaceOf(threadKey, "a.ts"))).toBe("shown"); + expect(ledger.verdict(surfaceOf(threadKey, "b.ts"))).toBe("pending"); + expect(readings.readReceipt(threadKey, "gone.ts")).toEqual({ id: 1, line: 3, closed: true }); + expect([...items.keys()].some((key) => key.includes("gone.ts"))).toBe(true); + }); + + it("marks a thread's receipts closed when a tab closed before the ledger started", () => { + const { ref, threadKey } = setupPanel(); + useRightPanelStore.getState().openFile(ref, "a.ts", 40); + useRightPanelStore.getState().openFile(ref, "b.ts"); + useRightPanelStore.getState().closeSurface(ref, "file:b.ts"); + const { storage } = memoryStorage(); + setup(storage).readings.writeReceipt(threadKey, "a.ts", { id: 1, line: 40 }); + + const { readings } = setup(storage); + const ledger = createRevealLedger(readings, useRightPanelStore, useClosedViewStore); + + expect(ledger.verdict(surfaceOf(threadKey, "a.ts"))).toBe("pending"); + expect(readings.readReceipt(threadKey, "a.ts")).toEqual({ id: 1, line: 40, closed: true }); + }); + + it("writes a receipt for a live tab only, and marks it closed when the tab closes", () => { + const { ref, threadKey } = setupPanel(); + useRightPanelStore.getState().openFile(ref, "a.ts", 40); + const { readings } = setup(); + const ledger = createRevealLedger(readings, useRightPanelStore, useClosedViewStore); + const first = surfaceOf(threadKey, "a.ts")!; + expect(ledger.verdict(first)).toBe("pending"); + + useRightPanelStore.getState().openFile(ref, "a.ts", 41); + ledger.markShown(threadKey, first); + expect(ledger.verdict(first)).toBe("shown"); + expect(readings.readReceipt(threadKey, "a.ts")).toBeNull(); + + const second = surfaceOf(threadKey, "a.ts")!; + expect(ledger.verdict(second)).toBe("pending"); + expect(ledger.generation(second)).not.toBe(ledger.generation(first)); + ledger.markShown(threadKey, second); + expect(readings.readReceipt(threadKey, "a.ts")).toEqual({ id: 2, line: 41 }); + + useRightPanelStore.getState().closeSurface(ref, "file:a.ts"); + expect(readings.readReceipt(threadKey, "a.ts")).toEqual({ id: 2, line: 41, closed: true }); + useRightPanelStore.getState().openFile(ref, "a.ts", 41); + const reopened = surfaceOf(threadKey, "a.ts")!; + expect(reopened.revealRequestId).toBe(1); + expect(ledger.verdict(reopened)).toBe("pending"); + }); + + it("treats Reopen Closed View of a shown citation as shown, and a fresh citation as new", () => { + const { ref, threadKey } = setupPanel(); + useRightPanelStore.getState().openFile(ref, "b.ts"); + useRightPanelStore.getState().openFile(ref, "a.ts", 40); + const { readings } = setup(); + const ledger = createRevealLedger(readings, useRightPanelStore, useClosedViewStore); + ledger.markShown(threadKey, surfaceOf(threadKey, "a.ts")!); + const closedEntry = () => + useClosedViewStore + .getState() + .entries.find((entry) => entry.kind === "panel-tab" && entry.surface.id === "file:a.ts")!; + + useRightPanelStore.getState().closeSurface(ref, "file:a.ts"); + const entry = closedEntry(); + useRightPanelStore.getState().openFile(ref, "a.ts", 40); + const reopened = surfaceOf(threadKey, "a.ts")!; + expect(ledger.verdict(reopened)).toBe("pending"); + useClosedViewStore.getState().remove(entry.id); + expect(ledger.verdict(reopened)).toBe("shown"); + expect(readings.readReceipt(threadKey, "a.ts")).toEqual({ id: 1, line: 40 }); + + useRightPanelStore.getState().closeSurface(ref, "file:a.ts"); + useRightPanelStore.getState().openFile(ref, "a.ts", 40); + expect(ledger.verdict(surfaceOf(threadKey, "a.ts"))).toBe("pending"); + }); +}); diff --git a/apps/web/src/components/files/fileReadingStore.ts b/apps/web/src/components/files/fileReadingStore.ts new file mode 100644 index 0000000000000..0adb6faa85e55 --- /dev/null +++ b/apps/web/src/components/files/fileReadingStore.ts @@ -0,0 +1,417 @@ +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; + +import type { useClosedViewStore } from "~/closedViewStore"; +import type { + RightPanelSurface, + ThreadRightPanelState, + useRightPanelStore, +} from "~/rightPanelStore"; + +const READING_TTL_MS = 30 * 24 * 60 * 60 * 1000; +const POSITION_PREFIX = "t3code.fileReading.v1:"; +const RECEIPT_PREFIX = "t3code.fileReveal.v1:"; +const POSITION_CAP = 300; +const RECEIPT_CAP = 200; + +const PositiveInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)); +const Index = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); + +const ReadingPosition = Schema.Union([ + /** `offset` is the viewport top minus the line top, so it is zero or negative. */ + Schema.Struct({ view: Schema.Literal("source"), line: PositiveInt, offset: Schema.Finite }), + /** `block` is a top-level Markdown block; `fraction` is how far the viewport top is into it. */ + Schema.Struct({ + view: Schema.Literal("rendered"), + block: Index, + fraction: Schema.Finite.check( + Schema.isBetween({ minimum: 0, maximum: 1, exclusiveMaximum: true }), + ), + }), + Schema.Struct({ + view: Schema.Literal("pdf"), + page: Index, + offset: Schema.Finite, + zoom: Schema.NullOr(Schema.Finite.check(Schema.isGreaterThan(0))), + left: Schema.Finite, + }), +]); +export type ReadingPosition = typeof ReadingPosition.Type; +export type ReadingView = ReadingPosition["view"]; +export type ReadingPositionOf = Extract; + +/** `closed` marks a tab closed after its reveal was shown; only Reopen Closed View brings it back. */ +const RevealReceipt = Schema.Struct({ + id: PositiveInt, + line: PositiveInt, + closed: Schema.optionalKey(Schema.Literal(true)), +}); +export type RevealReceipt = typeof RevealReceipt.Type; + +const EntryHeader = Schema.Struct({ v: Schema.Literal(1), at: Schema.Finite }); +const decodeHeader = Schema.decodeUnknownOption(EntryHeader); +const decodeReceiptKey = Schema.decodeUnknownOption(Schema.Tuple([Schema.String, Schema.String])); + +export interface FileReadingKey { + readonly environmentId: string; + readonly absolutePath: string; +} + +export interface StorageLike { + readonly length: number; + key(index: number): string | null; + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +} + +interface Entry { + readonly at: number; + readonly value: A; +} + +/** One key per entry under `prefix`; `index` holds every live storage key, oldest first. */ +interface Table { + readonly prefix: string; + readonly cap: number; + readonly decode: (input: unknown) => Option.Option; + readonly index: Map; + readonly written: Map>; +} + +function table( + prefix: string, + cap: number, + decode: (input: unknown) => Option.Option, +): Table { + return { prefix, cap, decode, index: new Map(), written: new Map() }; +} + +const positionKey = (file: FileReadingKey, view: ReadingView) => + POSITION_PREFIX + JSON.stringify([file.environmentId, file.absolutePath, view]); +const receiptKey = (threadKey: string, relativePath: string) => + RECEIPT_PREFIX + JSON.stringify([threadKey, relativePath]); + +export type FileReadingStore = ReturnType; + +export function createFileReadingStore(options: { + readonly storage: () => StorageLike | null | undefined; + readonly now: () => number; +}) { + const positions = table( + POSITION_PREFIX, + POSITION_CAP, + Schema.decodeUnknownOption(ReadingPosition), + ); + const receipts = table(RECEIPT_PREFIX, RECEIPT_CAP, Schema.decodeUnknownOption(RevealReceipt)); + const tables: ReadonlyArray> = [positions, receipts]; + let storage: StorageLike | null | undefined; + let scanned = false; + let writable = true; + + const resolveStorage = (): StorageLike | null => { + if (storage === undefined) { + try { + storage = options.storage() ?? null; + } catch { + storage = null; + } + } + return storage; + }; + const fresh = (at: number, now: number) => Math.abs(now - at) < READING_TTL_MS; + + const dropStored = (target: Table, key: string) => { + target.index.delete(key); + try { + resolveStorage()?.removeItem(key); + } catch { + // Unreadable storage keeps its key; the in-memory index has already dropped it. + } + }; + + const readStored = (target: Table, key: string, now: number): Entry | undefined => { + let raw: string | null = null; + try { + raw = resolveStorage()?.getItem(key) ?? null; + } catch { + return undefined; + } + if (raw === null) return undefined; + let json: unknown; + try { + json = JSON.parse(raw); + } catch { + dropStored(target, key); + return undefined; + } + const header = decodeHeader(json); + const value = target.decode(json); + if (Option.isNone(header) || Option.isNone(value) || !fresh(header.value.at, now)) { + dropStored(target, key); + return undefined; + } + return { at: header.value.at, value: value.value }; + }; + + const evict = (target: Table, now: number) => { + for (const [key, at] of target.index) { + if (target.index.size <= target.cap && fresh(at, now)) break; + dropStored(target, key); + } + }; + + const scan = () => { + if (scanned) return; + scanned = true; + const store = resolveStorage(); + if (store === null) return; + let keys: string[]; + try { + keys = Array.from({ length: store.length }, (_, index) => store.key(index) ?? ""); + } catch { + return; + } + const now = options.now(); + const found: Array<{ target: Table; key: string; at: number }> = []; + for (const key of keys) { + const target = tables.find((candidate) => key.startsWith(candidate.prefix)); + if (target === undefined) continue; + const entry = + target === receipts && Option.isNone(parseReceiptKey(key)) + ? undefined + : readStored(target, key, now); + if (entry === undefined) dropStored(target, key); + else found.push({ target, key, at: entry.at }); + } + found.sort((left, right) => left.at - right.at); + for (const { target, key, at } of found) target.index.set(key, at); + for (const target of tables) evict(target, now); + }; + + const trySet = (key: string, raw: string) => { + try { + resolveStorage()?.setItem(key, raw); + return true; + } catch { + return false; + } + }; + + const read = (target: Table, key: string): Entry | undefined => { + scan(); + const now = options.now(); + const written = target.written.get(key); + const mine = written !== undefined && fresh(written.at, now) ? written : undefined; + const stored = readStored(target, key, now); + return stored !== undefined && (mine === undefined || stored.at > mine.at) ? stored : mine; + }; + + const write = (target: Table, key: string, value: A) => { + scan(); + const at = options.now(); + target.written.delete(key); + target.written.set(key, { at, value }); + for (const writtenKey of target.written.keys()) { + if (target.written.size <= target.cap) break; + target.written.delete(writtenKey); + } + if (!writable || resolveStorage() === null) return; + const raw = JSON.stringify({ v: 1, at, ...value }); + if (!trySet(key, raw)) { + for (const other of tables) { + for (const [otherKey, otherAt] of other.index) { + if (!fresh(otherAt, at)) dropStored(other, otherKey); + } + } + let oldestHalf = Math.ceil(target.index.size / 2); + for (const oldKey of target.index.keys()) { + if (oldestHalf-- <= 0) break; + dropStored(target, oldKey); + } + if (!trySet(key, raw)) { + writable = false; + return; + } + } + target.index.delete(key); + target.index.set(key, at); + evict(target, at); + }; + + return { + readPosition( + file: FileReadingKey, + view: V, + ): ReadingPositionOf | null { + const value = read(positions, positionKey(file, view))?.value; + return value?.view === view ? (value as ReadingPositionOf) : null; + }, + writePosition(file: FileReadingKey, position: ReadingPosition): void { + write(positions, positionKey(file, position.view), position); + }, + /** The view among `views` whose saved entry is newest. */ + newestView(file: FileReadingKey, views: ReadonlyArray): ReadingView | null { + let newest: Entry | undefined; + for (const view of views) { + const entry = read(positions, positionKey(file, view)); + if (entry?.value.view === view && (newest === undefined || entry.at > newest.at)) { + newest = entry; + } + } + return newest?.value.view ?? null; + }, + readReceipt(threadKey: string, relativePath: string): RevealReceipt | null { + return read(receipts, receiptKey(threadKey, relativePath))?.value ?? null; + }, + writeReceipt(threadKey: string, relativePath: string, receipt: RevealReceipt): void { + write(receipts, receiptKey(threadKey, relativePath), receipt); + }, + receiptTabs(): ReadonlyArray<{ readonly threadKey: string; readonly relativePath: string }> { + scan(); + return [...new Set([...receipts.index.keys(), ...receipts.written.keys()])].flatMap((key) => + Option.match(parseReceiptKey(key), { + onNone: () => [], + onSome: ([threadKey, relativePath]) => [{ threadKey, relativePath }], + }), + ); + }, + }; +} + +function parseReceiptKey(key: string) { + try { + return decodeReceiptKey(JSON.parse(key.slice(RECEIPT_PREFIX.length))); + } catch { + return Option.none(); + } +} + +type FileSurface = Extract; +export type RevealVerdict = "none" | "pending" | "shown"; + +export function liveFileSurface( + thread: ThreadRightPanelState | undefined, + relativePath: string | null, +): FileSurface | undefined { + if (relativePath === null) return undefined; + const id = `file:${relativePath}`; + return thread?.surfaces.find( + (surface): surface is FileSurface => surface.id === id && surface.kind === "file", + ); +} + +/** + * Whether a tab's line reveal has been shown. In a page load the truth is the surface object: + * `openFile` always builds a new one. Receipts carry it across reloads for unchanged tabs. + */ +export function createRevealLedger( + readings: FileReadingStore, + panel: Pick, + closedViews: Pick, +) { + const shown = new WeakSet(); + const generations = new WeakMap(); + let lastGeneration = 0; + let started = false; + + const close = (threadKey: string, relativePath: string) => { + const receipt = readings.readReceipt(threadKey, relativePath); + if (receipt !== null && receipt.closed !== true) { + readings.writeReceipt(threadKey, relativePath, { ...receipt, closed: true }); + } + }; + + const start = () => { + if (started) return; + started = true; + const state = panel.getState(); + for (const { threadKey, relativePath } of readings.receiptTabs()) { + // A close before this module loaded may have reopened the tab with a recycled request id. + const surface = + (state.closeRevisionByThreadKey[threadKey] ?? 0) === 0 + ? liveFileSurface(state.byThreadKey[threadKey], relativePath) + : undefined; + const receipt = readings.readReceipt(threadKey, relativePath); + if (surface === undefined) close(threadKey, relativePath); + else if ( + receipt?.closed !== true && + receipt?.id === surface.revealRequestId && + receipt.line === surface.revealLine + ) { + shown.add(surface); + } + } + panel.subscribe((next, previous) => { + if (next.byThreadKey === previous.byThreadKey) return; + for (const [threadKey, before] of Object.entries(previous.byThreadKey)) { + const after = next.byThreadKey[threadKey]; + if (after === before) continue; + for (const surface of before.surfaces) { + if (surface.kind !== "file" || surface.id !== `file:${surface.relativePath}`) continue; + if (after?.surfaces.some((entry) => entry.id === surface.id)) continue; + close(threadKey, surface.relativePath); + } + } + }); + // Reopen Closed View calls `openFile` with the closed tab's line, then removes its entry. + closedViews.subscribe((next, previous) => { + if (next.entries.some((entry) => !previous.entries.includes(entry))) return; + for (const entry of previous.entries) { + if (next.entries.includes(entry) || entry.kind !== "panel-tab") continue; + const closed = entry.surface; + if (closed.kind !== "file" || closed.attachment !== undefined) continue; + const threadKey = scopedThreadKey(entry.threadRef); + const live = liveFileSurface(panel.getState().byThreadKey[threadKey], closed.relativePath); + const receipt = readings.readReceipt(threadKey, closed.relativePath); + if ( + live === undefined || + shown.has(live) || + receipt?.closed !== true || + live.revealLine !== receipt.line || + closed.revealLine !== receipt.line + ) { + continue; + } + shown.add(live); + readings.writeReceipt(threadKey, closed.relativePath, { + id: live.revealRequestId, + line: receipt.line, + }); + } + }); + }; + + return { + /** Adopts receipts and starts forgetting closed tabs; later calls do nothing. */ + start, + verdict(surface: FileSurface | undefined): RevealVerdict { + start(); + if (surface === undefined || surface.revealLine === null) return "none"; + return shown.has(surface) ? "shown" : "pending"; + }, + /** A reveal key unique to the surface object, so a recycled request id still reveals. */ + generation(surface: FileSurface | undefined): number { + if (surface === undefined) return 0; + let generation = generations.get(surface); + if (generation === undefined) { + generation = ++lastGeneration; + generations.set(surface, generation); + } + return generation; + }, + markShown(threadKey: string, surface: FileSurface): void { + start(); + shown.add(surface); + if (surface.revealLine === null) return; + const live = liveFileSurface(panel.getState().byThreadKey[threadKey], surface.relativePath); + if (live !== surface) return; + readings.writeReceipt(threadKey, surface.relativePath, { + id: surface.revealRequestId, + line: surface.revealLine, + }); + }, + }; +}