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/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 4ffb1cbd6e90..5e2f26b2ba20 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, @@ -15,6 +15,7 @@ import { ChevronsDownUpIcon, ChevronsUpDownIcon, Columns2Icon, + FolderTreeIcon, MessageSquareIcon, MessageSquareOffIcon, Rows3Icon, @@ -22,10 +23,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,11 +71,15 @@ 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, + type PullRequestDiffFileTreeHandle, +} from "./PullRequestDiffFileTree"; import { PullRequestReviewBar } from "./PullRequestReviewBar"; import { isFileDiffCollapsed, isLineInFileDiff, - type DiffFoldOverride, + type DiffFoldPreference, } from "./pullRequestDiff.logic"; import { PullRequestDiffStat, PullRequestMetaLine } from "./pullRequestPresentation"; import { @@ -95,6 +102,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. */ @@ -213,10 +222,14 @@ export function PullRequestCodeTab({ // A change of any size can carry hundreds of commits, and a menu that long is a scroll rather // than a choice. The rest arrive ten at a time, on request. const [visibleCommitCount, setVisibleCommitCount] = useState(COMMIT_PAGE_SIZE); - /** Set once the reader has asked for every file at once, until they pick a file apart again. */ - const [foldOverride, setFoldOverride] = useState(null); + const [foldPreference, setFoldPreference] = useState("expanded"); 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 +248,8 @@ export function PullRequestCodeTab({ readonly slices: ReadonlyArray; }>({ key: "", cursor: null, slices: NO_SLICES }); const parseCache = useRef(new Map()); + const viewerRef = useRef | null>(null); + const fileTreeRef = useRef(null); const referenceKey = pullRequestReviewKey(reference); const commit = selectedCommitOid; @@ -247,7 +262,8 @@ export function PullRequestCodeTab({ setDraft(null); setSelectedLines(null); setToggledFiles(new Set()); - setFoldOverride(null); + setFoldPreference("expanded"); + fileTreeRef.current?.setAllDirectoriesExpanded(true); setVisibleCommitCount(COMMIT_PAGE_SIZE); setOrphansOpen(false); setSliceState({ key: scopeKey, cursor: null, slices: NO_SLICES }); @@ -466,7 +482,7 @@ export function PullRequestCodeTab({ groupAt(anchor.side, anchor.line).draft = true; } - const collapsed = isFileDiffCollapsed(fileKey, foldOverride, toggledFiles); + const collapsed = isFileDiffCollapsed(fileKey, foldPreference, toggledFiles); const annotations: ReviewAnnotation[] = [...groups.values()].map((group) => ({ side: toViewerSide(group.side), @@ -516,7 +532,7 @@ export function PullRequestCodeTab({ detail.reviewThreads, draft, files, - foldOverride, + foldPreference, pendingComments, placedThreadIds, toggledFiles, @@ -539,6 +555,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 +589,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 +597,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,12 +615,33 @@ export function PullRequestCodeTab({ [], ); - const toggleAllFiles = () => { + 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 toggleAllFilesAndDirectories = () => { + const expand = allFilesCollapsed; // 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 // asked for everything to be open. - setFoldOverride(areAllDiffFilesCollapsed(fileKeys, collapsedFileKeys) ? "expanded" : "folded"); + setFoldPreference(expand ? "expanded" : "folded"); setToggledFiles(new Set()); + fileTreeRef.current?.setAllDirectoriesExpanded(expand); }; // Newest first: the last commit is the one a reader coming back to a change is looking for. @@ -1029,41 +1082,38 @@ 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}
} > @@ -1133,6 +1183,24 @@ export function PullRequestCodeTab({ {wordWrap ? "Disable line wrapping" : "Enable line wrapping"} + + setFileTreeOpen(Boolean(pressed))} + /> + } + > + + + + {fileTreeOpen ? "Hide file explorer" : "Show file explorer"} + +
); @@ -1285,56 +1353,75 @@ export function PullRequestCodeTab({ ) : null} {/* Relative wrapper so the review overlay floats over the diff rather than pushing it up; the viewer inside still owns its own scrolling. */} -
{ - 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..6a03a345b0e3 --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestDiffFileTree.test.tsx @@ -0,0 +1,68 @@ +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).not.toContain("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..5bee0d618a6a --- /dev/null +++ b/apps/web/src/components/pullRequest/PullRequestDiffFileTree.tsx @@ -0,0 +1,219 @@ +import type { FileDiffMetadata } from "@pierre/diffs"; +import type { GitStatusEntry } from "@pierre/trees"; +import { FileTree, useFileTree } from "@pierre/trees/react"; +import { + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, + type Ref, +} 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 { getPullRequestFileLoadState } from "./pullRequestDiff.logic"; + +const NO_EXPANDED_DIRECTORIES: ReadonlyArray = []; + +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]; +} + +/** The one bulk tree action coordinated by the pull request diff toolbar. */ +export interface PullRequestDiffFileTreeHandle { + /** Expands or collapses every directory currently loaded in the tree. */ + readonly setAllDirectoriesExpanded: (expanded: boolean) => void; +} + +/** A path-first Pierre tree for the portion of a pull-request diff loaded so far. */ +export function PullRequestDiffFileTree({ + ref, + files, + totalFileCount, + hasMore, + isLoadingMore, + loadMoreFailed, + initiallyExpanded, + onLoadMore, + onSelectFile, +}: { + readonly ref?: Ref; + 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; + /** The persisted expansion used when the sidebar mounts. */ + readonly initiallyExpanded: 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">(initiallyExpanded ? "open" : "closed"); + 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: initiallyExpanded ? directoryPaths : NO_EXPANDED_DIRECTORIES, + 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: PIERRE_TREE_UNSAFE_CSS, + }); + + 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 = useCallback( + (expanded: boolean) => { + newDirectoryExpansionRef.current = expanded ? "open" : "closed"; + model.resetPaths(paths, { + initialExpandedPaths: expanded ? directoryPaths : NO_EXPANDED_DIRECTORIES, + }); + model.setGitStatus(gitStatus); + }, + [directoryPaths, gitStatus, model, paths], + ); + useImperativeHandle(ref, () => ({ setAllDirectoriesExpanded }), [setAllDirectoriesExpanded]); + + return ( +
+
+ Files + + {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..76b2902942c3 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,12 +54,7 @@ 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 once the toolbar has asked for it", () => { + it("opens every file when the preference is expanded", () => { // Pressing the toolbar clears the reader's own toggles, which is why the set is empty here. expect(isFileDiffCollapsed("a.ts", "expanded", NO_TOGGLES)).toBe(false); expect(isFileDiffCollapsed("b.ts", "expanded", NO_TOGGLES)).toBe(false); @@ -66,12 +65,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", "expanded", toggled)).toBe(true); + expect(isFileDiffCollapsed("c.ts", "expanded", toggled)).toBe(false); }); it("still answers to a toggle after either toolbar press", () => { @@ -79,3 +78,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..7aa1e09113bc 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. * @@ -21,24 +44,23 @@ export function isLineInFileDiff( ); } -/** What the toolbar last asked of every file at once, null being the reader asking nothing yet. */ -export type DiffFoldOverride = "expanded" | "folded" | null; +/** The current bulk expansion preference for pull request diffs. */ +export type DiffFoldPreference = "expanded" | "folded"; /** * Whether a file is drawn folded. * * 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, + foldPreference: DiffFoldPreference, toggledFileKeys: ReadonlySet, ): boolean { - const foldedByDefault = foldOverride !== "expanded"; + const foldedByDefault = foldPreference === "folded"; return toggledFileKeys.has(fileKey) ? !foldedByDefault : foldedByDefault; } 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)", + }; +} 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**