From 42f38ba10cb2aa501d0a3188be2f35d6a11eaaec Mon Sep 17 00:00:00 2001 From: Shpetim <32248437+ShpetimA@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:33:11 +0200 Subject: [PATCH 1/9] feat(web): add pull request file sidebar --- .../pullRequest/PullRequestCodeTab.tsx | 248 ++++++++++++------ .../PullRequestDiffFileTree.test.tsx | 65 +++++ .../pullRequest/PullRequestDiffFileTree.tsx | 245 +++++++++++++++++ .../pullRequest/pullRequestDiff.logic.test.ts | 51 +++- .../pullRequest/pullRequestDiff.logic.ts | 32 ++- docs/user/source-control.md | 6 +- 6 files changed, 548 insertions(+), 99 deletions(-) create mode 100644 apps/web/src/components/pullRequest/PullRequestDiffFileTree.test.tsx create mode 100644 apps/web/src/components/pullRequest/PullRequestDiffFileTree.tsx diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 4ffb1cbd6e90..5cc8e9e2e9c9 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -1,5 +1,5 @@ import type { CodeViewItem, DiffLineAnnotation, SelectedLineRange } from "@pierre/diffs"; -import type { CodeViewDiffItem } from "@pierre/diffs/react"; +import type { CodeViewDiffItem, CodeViewHandle } from "@pierre/diffs/react"; import type { EnvironmentId, PullRequestDetailView, @@ -22,10 +22,12 @@ import { TriangleAlertIcon, XIcon, } from "lucide-react"; +import * as Schema from "effect/Schema"; import { useAtomRefresh } from "@effect/atom-react"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useClientSettings } from "~/hooks/useSettings"; +import { useLocalStorage } from "~/hooks/useLocalStorage"; import { useTheme } from "~/hooks/useTheme"; import { areAllDiffFilesCollapsed } from "~/lib/diffCollapse"; import { pullRequestFindingKey, type PullRequestFinding } from "./pullRequestDetail.logic"; @@ -68,8 +70,10 @@ import { toastManager } from "../ui/toast"; import { Toggle, ToggleGroup } from "../ui/toggle-group"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { PendingReviewCommentCard, ReviewThreadCard } from "./PullRequestReviewAnnotation"; +import { PullRequestDiffFileTree } from "./PullRequestDiffFileTree"; import { PullRequestReviewBar } from "./PullRequestReviewBar"; import { + getPullRequestFileLoadState, isFileDiffCollapsed, isLineInFileDiff, type DiffFoldOverride, @@ -95,6 +99,8 @@ type ReviewAnnotation = DiffLineAnnotation; /** Commits per press of "Show more" in the scope menu. */ const COMMIT_PAGE_SIZE = 10; +const PR_DIFF_FILE_TREE_OPEN_STORAGE_KEY = "t3code.pullRequestDiffFileTreeOpen"; + /** One answer from the host: a whole number of files, and where the next one carries on. */ interface DiffSlice { /** What was asked for, null being the first slice. Identifies the slice among the loaded ones. */ @@ -217,6 +223,11 @@ export function PullRequestCodeTab({ const [foldOverride, setFoldOverride] = useState(null); const [diffRenderMode, setDiffRenderMode] = useState<"stacked" | "split">("stacked"); const [wordWrap, setWordWrap] = useState(settings.wordWrap); + const [fileTreeOpen, setFileTreeOpen] = useLocalStorage( + PR_DIFF_FILE_TREE_OPEN_STORAGE_KEY, + true, + Schema.Boolean, + ); const [selectedLines, setSelectedLines] = useState<{ id: string; range: SelectedLineRange; @@ -235,6 +246,7 @@ export function PullRequestCodeTab({ readonly slices: ReadonlyArray; }>({ key: "", cursor: null, slices: NO_SLICES }); const parseCache = useRef(new Map()); + const viewerRef = useRef | null>(null); const referenceKey = pullRequestReviewKey(reference); const commit = selectedCommitOid; @@ -539,6 +551,22 @@ export function PullRequestCodeTab({ ); const allFilesCollapsed = areAllDiffFilesCollapsed(fileKeys, collapsedFileKeys); + const loadNextDiffSlice = useCallback(() => { + if ( + nextCursor === null || + nextCursor === cursor || + diffQuery.isPending || + diffQuery.error !== null + ) { + return; + } + setSliceState((previous) => + previous.key === scopeKey && previous.cursor !== nextCursor + ? { ...previous, cursor: nextCursor } + : previous, + ); + }, [cursor, diffQuery.error, diffQuery.isPending, nextCursor, scopeKey]); + // The sentinel is held as state rather than a ref because the viewer mounts its own footer: // an effect reading a ref could run before that node exists and would never arm the observer. const [sentinel, setSentinel] = useState(null); @@ -557,7 +585,7 @@ export function PullRequestCodeTab({ const observer = new IntersectionObserver( (observed) => { if (observed.some((entry) => entry.isIntersecting)) { - setSliceState((previous) => ({ ...previous, cursor: nextCursor })); + loadNextDiffSlice(); } }, // Start the next slice slightly before the sentinel is on screen. @@ -565,7 +593,7 @@ export function PullRequestCodeTab({ ); observer.observe(sentinel); return () => observer.disconnect(); - }, [cursor, diffQuery.error, diffQuery.isPending, nextCursor, sentinel]); + }, [cursor, diffQuery.error, diffQuery.isPending, loadNextDiffSlice, nextCursor, sentinel]); // A stable identity: the viewer's SlotPortals memoizes each file's header/annotation portal on // these render props, so a fresh function here would recreate every visible file's portal on @@ -583,6 +611,25 @@ export function PullRequestCodeTab({ [], ); + const revealFile = useCallback( + (path: string) => { + const item = items.find((candidate) => resolveFileDiffPath(candidate.fileDiff) === path); + if (item === undefined) return; + if (item.collapsed === true) { + toggleFile(item.id); + } + window.requestAnimationFrame(() => { + viewerRef.current?.scrollTo({ + type: "item", + id: item.id, + align: "start", + behavior: "smooth-auto", + }); + }); + }, + [items, toggleFile], + ); + const toggleAllFiles = () => { // Held as an override of the default rather than as the file keys on screen: a diff that is // still paging would otherwise bring its next slice in folded, moments after the reader @@ -972,6 +1019,11 @@ export function PullRequestCodeTab({ } }, [commit, onSelectedCommitChange, selectedCommit]); const scopeLabel = selectedCommit ? selectedCommit.messageHeadline : "All commits"; + const fileLoadState = getPullRequestFileLoadState( + files.length, + commit === null ? detail.changedFiles : null, + nextCursor !== null, + ); /** * The same controls the thread diff panel carries, in the same order, minus the * ignore-whitespace toggle: that is `git diff -w` on the server, and no host's pull request @@ -1029,41 +1081,50 @@ export function PullRequestCodeTab({ ) : null} - {/* One count, and the caveats as icons that carry their own words. Spelled out they - competed for a strip this narrow and every one of them truncated to nothing. */} - - - {files.length} {files.length === 1 ? "file" : "files"} - {nextCursor === null ? "" : "+"} - - {withheldContent ? ( - - }> - - - - The host withheld part of this diff — a binary file, or a change too large to - inline. - - - ) : null} - {commit !== null && review.inlineComment ? ( - - }> - - - - A comment is anchored to the whole change, so switch to All commits to write one. - - - ) : null} - + + {/* Caveats stay compact: spelling them out in this strip makes every control truncate. */} + {withheldContent || (commit !== null && review.inlineComment) ? ( + + {withheldContent ? ( + + }> + + + + The host withheld part of this diff — a binary file, or a change too large to + inline. + + + ) : null} + {commit !== null && review.inlineComment ? ( + + }> + + + + A comment is anchored to the whole change, so switch to All commits to write one. + + + ) : null} + + ) : null}
{ - const composedPath = event.nativeEvent.composedPath?.() ?? []; - for (const node of composedPath) { - if (!(node instanceof HTMLElement)) continue; - // A control inside the header — the collapse chevron — handles itself, and - // this capture listener fires before its own click does. Leave it alone or - // the two toggles cancel out. - if (node instanceof HTMLButtonElement || node instanceof HTMLAnchorElement) { - return; - } - if (node.hasAttribute("data-diffs-header")) { - const filePath = node.querySelector("[data-title]")?.textContent?.trim(); - if (filePath === undefined || filePath === "") return; - const item = items.find( - (candidate) => resolveFileDiffPath(candidate.fileDiff) === filePath, - ); - if (item !== undefined) toggleFile(item.id); - return; +
+
{ + const composedPath = event.nativeEvent.composedPath?.() ?? []; + for (const node of composedPath) { + if (!(node instanceof HTMLElement)) continue; + // A control inside the header — the collapse chevron — handles itself, and + // this capture listener fires before its own click does. Leave it alone or + // the two toggles cancel out. + if (node instanceof HTMLButtonElement || node instanceof HTMLAnchorElement) { + return; + } + if (node.hasAttribute("data-diffs-header")) { + const filePath = node.querySelector("[data-title]")?.textContent?.trim(); + if (filePath === undefined || filePath === "") return; + const item = items.find( + (candidate) => resolveFileDiffPath(candidate.fileDiff) === filePath, + ); + if (item !== undefined) toggleFile(item.id); + return; + } } - } - }} - > - {/* The viewer virtualizes against the element it is told is scrolling and places its + }} + > + {/* The viewer virtualizes against the element it is told is scrolling and places its rows absolutely, so it has to own that element — the thread diff panel hands it the same one. Scrolling from a parent instead leaves it painting over its neighbours. */} - - // Keep scrollbar space stable so file metadata and line numbers do not shift as a - // diff crosses the overflow boundary. The viewer is itself focusable for keyboard - // interaction, but its native host outline clips and competes with the focus - // indicators on its actual controls. - className="h-full overflow-auto [scrollbar-gutter:stable]" - items={items} - selectedLines={selectedLines} - onSelectedLinesChange={setSelectedLines} - options={diffViewOptions} - // The viewer owns the scroll container, so the sentinel that asks for the next slice - // has to live inside it — at the end of the files, where reaching it means the reader - // is running out of diff. - renderCodeViewFooter={renderCodeViewFooter} - renderHeaderPrefix={renderHeaderPrefix} - renderHeaderMetadata={renderHeaderMetadata} - renderAnnotation={renderAnnotation} - unsafeCSSExtra={REPLACE_FILE_COUNTS_CSS} - /> - {reviewOverlay} + + // Keep scrollbar space stable so file metadata and line numbers do not shift as a + // diff crosses the overflow boundary. The viewer is itself focusable for keyboard + // interaction, but its native host outline clips and competes with the focus + // indicators on its actual controls. + className="h-full overflow-auto [scrollbar-gutter:stable]" + viewerRef={viewerRef} + items={items} + selectedLines={selectedLines} + onSelectedLinesChange={setSelectedLines} + options={diffViewOptions} + // The viewer owns the scroll container, so the sentinel that asks for the next slice + // has to live inside it — at the end of the files, where reaching it means the reader + // is running out of diff. + renderCodeViewFooter={renderCodeViewFooter} + renderHeaderPrefix={renderHeaderPrefix} + renderHeaderMetadata={renderHeaderMetadata} + renderAnnotation={renderAnnotation} + unsafeCSSExtra={REPLACE_FILE_COUNTS_CSS} + /> + {reviewOverlay} +
+ {fileTreeOpen ? ( + + ) : null}
{unstructured}
diff --git a/apps/web/src/components/pullRequest/PullRequestDiffFileTree.test.tsx b/apps/web/src/components/pullRequest/PullRequestDiffFileTree.test.tsx new file mode 100644 index 000000000000..c92dbcaefcab --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestDiffFileTree.test.tsx @@ -0,0 +1,65 @@ +import type { FileDiffMetadata } from "@pierre/diffs"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { PullRequestDiffFileTree } from "./PullRequestDiffFileTree"; + +function changedFile(name: string): FileDiffMetadata { + return { name, type: "change", hunks: [] } as unknown as FileDiffMetadata; +} + +describe("PullRequestDiffFileTree", () => { + it("shows loaded-file progress in the pagination action", () => { + const markup = renderToStaticMarkup( + {}} + onSelectFile={() => {}} + />, + ); + + expect(markup).toContain("Load more · 2 of 80 loaded"); + expect(markup).toContain("width:2.5%"); + expect(markup).toContain('aria-busy="false"'); + expect(markup).toContain('aria-label="Collapse all folders"'); + }); + + it("keeps the current progress visible while the next page loads", () => { + const markup = renderToStaticMarkup( + {}} + onSelectFile={() => {}} + />, + ); + + expect(markup).toContain("Loading · 2 of 80 loaded"); + expect(markup).toContain('aria-busy="true"'); + }); + + it("does not present an exhausted reported count as complete while more files remain", () => { + const markup = renderToStaticMarkup( + {}} + onSelectFile={() => {}} + />, + ); + + expect(markup).toContain("Load more · 2 loaded"); + expect(markup).not.toContain("2 of 2 loaded"); + expect(markup).not.toContain("width:100%"); + }); +}); diff --git a/apps/web/src/components/pullRequest/PullRequestDiffFileTree.tsx b/apps/web/src/components/pullRequest/PullRequestDiffFileTree.tsx new file mode 100644 index 000000000000..7e8d06d9cb12 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestDiffFileTree.tsx @@ -0,0 +1,245 @@ +import type { FileDiffMetadata } from "@pierre/diffs"; +import type { GitStatusEntry } from "@pierre/trees"; +import { FileTree, useFileTree, useFileTreeSelector } from "@pierre/trees/react"; +import { ChevronsDownUpIcon, ChevronsUpDownIcon } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { useTheme } from "~/hooks/useTheme"; +import { resolveFileDiffPath } from "~/lib/diffRendering"; +import { T3_PIERRE_ICONS } from "~/pierre-icons"; + +import { Button } from "../ui/button"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { getPullRequestFileLoadState } from "./pullRequestDiff.logic"; + +const TREE_UNSAFE_CSS = ` + :host { + --trees-bg-override: transparent; + --trees-selected-bg-override: color-mix(in srgb, currentColor 12%, transparent); + --trees-hover-bg-override: color-mix(in srgb, currentColor 7%, transparent); + --trees-border-color-override: color-mix(in srgb, currentColor 14%, transparent); + --trees-font-family-override: var(--font-sans); + --trees-font-size-override: 12px; + } + button[data-type='item'] { border-radius: 5px; } +`; + +function toGitStatus(file: FileDiffMetadata): GitStatusEntry { + const path = resolveFileDiffPath(file); + switch (file.type) { + case "new": + return { path, status: "added" }; + case "deleted": + return { path, status: "deleted" }; + case "rename-pure": + case "rename-changed": + return { path, status: "renamed" }; + case "change": + return { path, status: "modified" }; + } +} + +function collectDirectoryPaths(paths: ReadonlyArray): ReadonlyArray { + const directories = new Set(); + for (const path of paths) { + const segments = path.split("/"); + let directory = ""; + for (const segment of segments.slice(0, -1)) { + directory += `${segment}/`; + directories.add(directory); + } + } + return [...directories]; +} + +/** A path-first Pierre tree for the portion of a pull-request diff loaded so far. */ +export function PullRequestDiffFileTree({ + files, + totalFileCount, + hasMore, + isLoadingMore, + loadMoreFailed, + onLoadMore, + onSelectFile, +}: { + readonly files: ReadonlyArray; + /** Null when the selected host commit does not report its own aggregate file count. */ + readonly totalFileCount: number | null; + readonly hasMore: boolean; + readonly isLoadingMore: boolean; + readonly loadMoreFailed: boolean; + readonly onLoadMore: () => void; + readonly onSelectFile: (path: string) => void; +}) { + const { resolvedTheme } = useTheme(); + const paths = useMemo(() => files.map(resolveFileDiffPath), [files]); + const directoryPaths = useMemo(() => collectDirectoryPaths(paths), [paths]); + const gitStatus = useMemo(() => files.map(toGitStatus), [files]); + const filePathsRef = useRef>(new Set(paths)); + const onSelectFileRef = useRef(onSelectFile); + const previousPathsRef = useRef>(paths); + const previousDirectoryPathsRef = useRef>(directoryPaths); + const newDirectoryExpansionRef = useRef<"open" | "closed">("open"); + const [hasRequestedMore, setHasRequestedMore] = useState(false); + const fileLoadState = getPullRequestFileLoadState(files.length, totalFileCount, hasMore); + const progressTotal = fileLoadState.knownTotalFileCount; + const progressPercent = + progressTotal === null || progressTotal === 0 ? null : (files.length / progressTotal) * 100; + const loadedFileCount = files.length.toLocaleString(); + const loadedFileProgress = + progressTotal === null + ? `${loadedFileCount} loaded` + : `${loadedFileCount} of ${progressTotal.toLocaleString()} loaded`; + + useEffect(() => { + filePathsRef.current = new Set(paths); + onSelectFileRef.current = onSelectFile; + }, [onSelectFile, paths]); + + const { model } = useFileTree({ + density: "compact", + flattenEmptyDirectories: true, + initialExpandedPaths: directoryPaths, + initialExpansion: "closed", + icons: T3_PIERRE_ICONS, + onSelectionChange: (selectedPaths) => { + const path = selectedPaths.at(-1)?.replace(/\/$/, ""); + if (path && filePathsRef.current.has(path)) { + onSelectFileRef.current(path); + } + }, + paths, + search: false, + unsafeCSS: TREE_UNSAFE_CSS, + }); + + const selectAllDirectoriesExpanded = useCallback( + (currentModel: typeof model) => + directoryPaths.every((path) => { + const item = currentModel.getItem(path); + return item !== null && "isExpanded" in item && item.isExpanded(); + }), + [directoryPaths], + ); + const allDirectoriesExpanded = useFileTreeSelector(model, selectAllDirectoriesExpanded); + + useEffect(() => { + if (previousPathsRef.current === paths) return; + const previousDirectoryPaths = previousDirectoryPathsRef.current; + const previousDirectoryPathSet = new Set(previousDirectoryPaths); + const previouslyExpandedPaths = new Set( + previousDirectoryPaths.filter((path) => { + const item = model.getItem(path); + return item !== null && "isExpanded" in item && item.isExpanded(); + }), + ); + const nextExpandedPaths = directoryPaths.filter((path) => + previousDirectoryPathSet.has(path) + ? previouslyExpandedPaths.has(path) + : newDirectoryExpansionRef.current === "open", + ); + previousPathsRef.current = paths; + previousDirectoryPathsRef.current = directoryPaths; + model.resetPaths(paths, { initialExpandedPaths: nextExpandedPaths }); + }, [directoryPaths, model, paths]); + + useEffect(() => { + model.setGitStatus(gitStatus); + }, [gitStatus, model]); + + const setAllDirectoriesExpanded = (expanded: boolean) => { + newDirectoryExpansionRef.current = expanded ? "open" : "closed"; + model.resetPaths(paths, { + initialExpandedPaths: expanded ? directoryPaths : [], + }); + model.setGitStatus(gitStatus); + }; + + return ( +
+
+ Files +
+ {directoryPaths.length > 0 ? ( + + setAllDirectoriesExpanded(!allDirectoriesExpanded)} + /> + } + > + {allDirectoriesExpanded ? ( + + ) : ( + + )} + + + {allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"} + + + ) : null} + + {files.length} + {progressTotal !== null && progressTotal > files.length + ? ` of ${progressTotal}` + : fileLoadState.displayedCountIsLowerBound + ? "+" + : ""} + +
+
+ + {hasMore ? ( +
+ +
+ ) : hasRequestedMore ? ( +
+ All {loadedFileCount} {files.length === 1 ? "file" : "files"} loaded +
+ ) : null} +
+ ); +} diff --git a/apps/web/src/components/pullRequest/pullRequestDiff.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDiff.logic.test.ts index b39cfd9ff1b5..22d3eae4d078 100644 --- a/apps/web/src/components/pullRequest/pullRequestDiff.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDiff.logic.test.ts @@ -1,7 +1,11 @@ import type { FileDiffMetadata } from "@pierre/diffs"; import { describe, expect, it } from "vite-plus/test"; -import { isFileDiffCollapsed, isLineInFileDiff } from "./pullRequestDiff.logic"; +import { + getPullRequestFileLoadState, + isFileDiffCollapsed, + isLineInFileDiff, +} from "./pullRequestDiff.logic"; /** Only the hunk ranges matter here; the viewer fills the rest in when it renders. */ function fileWithHunks( @@ -50,9 +54,9 @@ describe("isLineInFileDiff", () => { describe("isFileDiffCollapsed", () => { const NO_TOGGLES: ReadonlySet = new Set(); - it("folds every file before the reader has touched anything", () => { - expect(isFileDiffCollapsed("a.ts", null, NO_TOGGLES)).toBe(true); - expect(isFileDiffCollapsed("b.ts", null, NO_TOGGLES)).toBe(true); + it("opens every file before the reader has touched anything", () => { + expect(isFileDiffCollapsed("a.ts", null, NO_TOGGLES)).toBe(false); + expect(isFileDiffCollapsed("b.ts", null, NO_TOGGLES)).toBe(false); }); it("opens every file once the toolbar has asked for it", () => { @@ -66,12 +70,12 @@ describe("isFileDiffCollapsed", () => { expect(isFileDiffCollapsed("b.ts", "folded", NO_TOGGLES)).toBe(true); }); - it("keeps a file the reader opened open as the next slice arrives", () => { - // The file keys grow with every slice, so the answer for one already open must not depend on + it("keeps a file the reader folded folded as the next slice arrives", () => { + // The file keys grow with every slice, so the answer for one already folded must not depend on // how many of them there are by then. const toggled = new Set(["b.ts"]); - expect(isFileDiffCollapsed("b.ts", null, toggled)).toBe(false); - expect(isFileDiffCollapsed("c.ts", null, toggled)).toBe(true); + expect(isFileDiffCollapsed("b.ts", null, toggled)).toBe(true); + expect(isFileDiffCollapsed("c.ts", null, toggled)).toBe(false); }); it("still answers to a toggle after either toolbar press", () => { @@ -79,3 +83,34 @@ describe("isFileDiffCollapsed", () => { expect(isFileDiffCollapsed("a.ts", "folded", new Set(["a.ts"]))).toBe(false); }); }); + +describe("getPullRequestFileLoadState", () => { + it("uses a reported total while it is still ahead of the loaded files", () => { + expect(getPullRequestFileLoadState(2, 80, true)).toEqual({ + displayedFileCount: 80, + knownTotalFileCount: 80, + displayedCountIsLowerBound: false, + }); + }); + + it("treats an exhausted reported count as a lower bound while more files remain", () => { + expect(getPullRequestFileLoadState(1_000, 1_000, true)).toEqual({ + displayedFileCount: 1_000, + knownTotalFileCount: null, + displayedCountIsLowerBound: true, + }); + expect(getPullRequestFileLoadState(1_001, 1_000, true)).toEqual({ + displayedFileCount: 1_001, + knownTotalFileCount: null, + displayedCountIsLowerBound: true, + }); + }); + + it("uses the loaded count as a lower bound when the host reports no total", () => { + expect(getPullRequestFileLoadState(23, null, true)).toEqual({ + displayedFileCount: 23, + knownTotalFileCount: null, + displayedCountIsLowerBound: true, + }); + }); +}); diff --git a/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts b/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts index b3c19c4fe9c2..a293ce009859 100644 --- a/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts @@ -1,6 +1,29 @@ import type { FileDiffMetadata } from "@pierre/diffs"; import type { PullRequestDiffSide } from "@t3tools/contracts"; +/** + * Reconciles the host's aggregate file count with the files and cursor actually loaded. + * + * Some hosts report a lower bound such as GitLab's `1000+`. Once the loaded diff reaches that + * bound while another slice remains, the count is no longer a usable total: presenting it as a + * denominator would make an incomplete diff look finished. + */ +export function getPullRequestFileLoadState( + loadedFileCount: number, + reportedTotalFileCount: number | null, + hasMore: boolean, +) { + const displayedFileCount = Math.max(reportedTotalFileCount ?? 0, loadedFileCount); + const reportedCountIsExhausted = + reportedTotalFileCount === null || loadedFileCount >= reportedTotalFileCount; + const displayedCountIsLowerBound = hasMore && reportedCountIsExhausted; + return { + displayedFileCount, + knownTotalFileCount: displayedCountIsLowerBound ? null : displayedFileCount, + displayedCountIsLowerBound, + }; +} + /** * Whether a conversation's line is really in this file's hunks. * @@ -29,16 +52,15 @@ export type DiffFoldOverride = "expanded" | "folded" | null; * * A diff arrives a slice at a time, so the reader's own choices are kept as the difference from * what the toolbar last said rather than as the set of folded files: a file that has not loaded - * yet cannot be in a set, and would otherwise land expanded moments after the reader folded - * everything. Folded is the starting point whatever the change's size, because laying out every - * file of it costs the reader the seconds before the tab is usable and buries the file they came - * for among the ones they did not. + * yet cannot be in a set, and would otherwise ignore the reader's last all-files choice. Files + * begin expanded so opening Code immediately shows the change; the toolbar can still fold every + * loaded and future slice in one action. */ export function isFileDiffCollapsed( fileKey: string, foldOverride: DiffFoldOverride, toggledFileKeys: ReadonlySet, ): boolean { - const foldedByDefault = foldOverride !== "expanded"; + const foldedByDefault = foldOverride === "folded"; return toggledFileKeys.has(fileKey) ? !foldedByDefault : foldedByDefault; } diff --git a/docs/user/source-control.md b/docs/user/source-control.md index c64a63f7bc49..5e0012a57d48 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -42,7 +42,11 @@ T3 Code works with the platforms your team already uses: - While working in a thread, open linked reviews in the same compact right-panel tabs without leaving the conversation - Open the review directly in your browser with one click -- Command-click (Control-click on Windows and Linux) a pull request number in the sidebar to open it in your browser instead of in T3 Code +- Command-click (Control-click on Windows and Linux) a pull request number in the sidebar to open + it in your browser instead of in T3 Code +- **Code** opens with every directory in the file tree and every individual file diff expanded. + Use **Files** to hide or show the tree, or the tree header control to collapse and expand all + folders at once - Check out a teammate's branch to review code locally **Fix what you wrote, in place** From 09c904f5567f783f461eb470acacd6c229ed1c31 Mon Sep 17 00:00:00 2001 From: Shpetim <32248437+ShpetimA@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:14:50 +0200 Subject: [PATCH 2/9] fix(web): move pull request files toggle to toolbar --- .../pullRequest/PullRequestCodeTab.tsx | 37 ++++++++++--------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 5cc8e9e2e9c9..01cb46b08635 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -15,6 +15,7 @@ import { ChevronsDownUpIcon, ChevronsUpDownIcon, Columns2Icon, + FolderTreeIcon, MessageSquareIcon, MessageSquareOffIcon, Rows3Icon, @@ -73,7 +74,6 @@ import { PendingReviewCommentCard, ReviewThreadCard } from "./PullRequestReviewA import { PullRequestDiffFileTree } from "./PullRequestDiffFileTree"; import { PullRequestReviewBar } from "./PullRequestReviewBar"; import { - getPullRequestFileLoadState, isFileDiffCollapsed, isLineInFileDiff, type DiffFoldOverride, @@ -1019,11 +1019,6 @@ export function PullRequestCodeTab({ } }, [commit, onSelectedCommitChange, selectedCommit]); const scopeLabel = selectedCommit ? selectedCommit.messageHeadline : "All commits"; - const fileLoadState = getPullRequestFileLoadState( - files.length, - commit === null ? detail.changedFiles : null, - nextCursor !== null, - ); /** * The same controls the thread diff panel carries, in the same order, minus the * ignore-whitespace toggle: that is `git diff -w` on the server, and no host's pull request @@ -1081,18 +1076,6 @@ export function PullRequestCodeTab({ ) : null} - {/* Caveats stay compact: spelling them out in this strip makes every control truncate. */} {withheldContent || (commit !== null && review.inlineComment) ? ( @@ -1194,6 +1177,24 @@ export function PullRequestCodeTab({ {wordWrap ? "Disable line wrapping" : "Enable line wrapping"} + + setFileTreeOpen(Boolean(pressed))} + /> + } + > + + + + {fileTreeOpen ? "Hide file explorer" : "Show file explorer"} + + ); From b9c70f8502ec780c4b8ff48526899473654ce51e Mon Sep 17 00:00:00 2001 From: Shpetim <32248437+ShpetimA@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:29:59 +0200 Subject: [PATCH 3/9] refactor(web): share Pierre tree theme --- .../src/components/files/FileBrowserPanel.tsx | 20 +++----------- .../pullRequest/PullRequestDiffFileTree.tsx | 20 +++----------- apps/web/src/pierre-tree-theme.ts | 26 +++++++++++++++++++ 3 files changed, 32 insertions(+), 34 deletions(-) create mode 100644 apps/web/src/pierre-tree-theme.ts diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index e3280c99caa3..e42a0207073d 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -18,6 +18,7 @@ import { useTheme } from "~/hooks/useTheme"; import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; import { T3_PIERRE_ICONS } from "~/pierre-icons"; +import { PIERRE_TREE_UNSAFE_CSS, pierreTreeStyle } from "~/pierre-tree-theme"; import { createFileTreeDragMentionController } from "./fileTreeDragMention"; import { useProjectEntriesQuery } from "./projectFilesQueryState"; @@ -33,18 +34,6 @@ interface FileBrowserPanelProps { onOpenFile: (relativePath: string) => void; } -const TREE_UNSAFE_CSS = ` - :host { - --trees-bg-override: transparent; - --trees-selected-bg-override: color-mix(in srgb, currentColor 12%, transparent); - --trees-hover-bg-override: color-mix(in srgb, currentColor 7%, transparent); - --trees-border-color-override: color-mix(in srgb, currentColor 14%, transparent); - --trees-font-family-override: var(--font-sans); - --trees-font-size-override: 12px; - } - button[data-type='item'] { border-radius: 5px; } -`; - function treePath(entry: ProjectEntry): string { return entry.kind === "directory" ? `${entry.path}/` : entry.path; } @@ -244,7 +233,7 @@ export default function FileBrowserPanel({ }, paths: [], search: false, - unsafeCSS: TREE_UNSAFE_CSS, + unsafeCSS: PIERRE_TREE_UNSAFE_CSS, }); const search = useFileTreeSearch(model); const handleSearchValueChange = (value: string) => { @@ -370,10 +359,7 @@ export default function FileBrowserPanel({ model={model} aria-label={`${projectName} files`} className="min-h-0 flex-1 overflow-hidden" - style={{ - colorScheme: resolvedTheme, - ["--trees-fg-override" as string]: "var(--foreground)", - }} + style={pierreTreeStyle(resolvedTheme)} /> )} diff --git a/apps/web/src/components/pullRequest/PullRequestDiffFileTree.tsx b/apps/web/src/components/pullRequest/PullRequestDiffFileTree.tsx index 7e8d06d9cb12..79614722c062 100644 --- a/apps/web/src/components/pullRequest/PullRequestDiffFileTree.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDiffFileTree.tsx @@ -7,23 +7,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTheme } from "~/hooks/useTheme"; import { resolveFileDiffPath } from "~/lib/diffRendering"; import { T3_PIERRE_ICONS } from "~/pierre-icons"; +import { PIERRE_TREE_UNSAFE_CSS, pierreTreeStyle } from "~/pierre-tree-theme"; import { Button } from "../ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { getPullRequestFileLoadState } from "./pullRequestDiff.logic"; -const TREE_UNSAFE_CSS = ` - :host { - --trees-bg-override: transparent; - --trees-selected-bg-override: color-mix(in srgb, currentColor 12%, transparent); - --trees-hover-bg-override: color-mix(in srgb, currentColor 7%, transparent); - --trees-border-color-override: color-mix(in srgb, currentColor 14%, transparent); - --trees-font-family-override: var(--font-sans); - --trees-font-size-override: 12px; - } - button[data-type='item'] { border-radius: 5px; } -`; - function toGitStatus(file: FileDiffMetadata): GitStatusEntry { const path = resolveFileDiffPath(file); switch (file.type) { @@ -110,7 +99,7 @@ export function PullRequestDiffFileTree({ }, paths, search: false, - unsafeCSS: TREE_UNSAFE_CSS, + unsafeCSS: PIERRE_TREE_UNSAFE_CSS, }); const selectAllDirectoriesExpanded = useCallback( @@ -200,10 +189,7 @@ export function PullRequestDiffFileTree({ model={model} aria-label="Pull request files" className="min-h-0 flex-1 overflow-hidden" - style={{ - colorScheme: resolvedTheme, - ["--trees-fg-override" as string]: "var(--foreground)", - }} + style={pierreTreeStyle(resolvedTheme)} /> {hasMore ? (
diff --git a/apps/web/src/pierre-tree-theme.ts b/apps/web/src/pierre-tree-theme.ts new file mode 100644 index 000000000000..1c06f665a574 --- /dev/null +++ b/apps/web/src/pierre-tree-theme.ts @@ -0,0 +1,26 @@ +import type { CSSProperties } from "react"; + +interface PierreTreeStyle extends CSSProperties { + readonly "--trees-fg-override": string; +} + +/** Shared shadow-root theme overrides for Pierre file-tree surfaces. */ +export const PIERRE_TREE_UNSAFE_CSS = ` + :host { + --trees-bg-override: transparent; + --trees-selected-bg-override: color-mix(in srgb, currentColor 12%, transparent); + --trees-hover-bg-override: color-mix(in srgb, currentColor 7%, transparent); + --trees-border-color-override: color-mix(in srgb, currentColor 14%, transparent); + --trees-font-family-override: var(--font-sans); + --trees-font-size-override: 12px; + } + button[data-type='item'] { border-radius: 5px; } +`; + +/** Resolves the host styles that keep a Pierre tree aligned with the active app theme. */ +export function pierreTreeStyle(colorScheme: CSSProperties["colorScheme"]): PierreTreeStyle { + return { + colorScheme, + "--trees-fg-override": "var(--foreground)", + }; +} From 84058bfe75d9575a625c270a8955eb066c47dd64 Mon Sep 17 00:00:00 2001 From: Shpetim <32248437+ShpetimA@users.noreply.github.com> Date: Sat, 15 Aug 2026 14:54:19 +0200 Subject: [PATCH 4/9] fix(web): keep pull request file sidebar compact --- apps/web/src/components/pullRequest/PullRequestCodeTab.tsx | 2 +- .../src/components/pullRequest/PullRequestDiffFileTree.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 01cb46b08635..e422c56bb80f 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -1401,7 +1401,7 @@ export function PullRequestCodeTab({ {reviewOverlay}
{fileTreeOpen ? ( -