authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-02 20:58:10-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-02 20:58:10-07:00
log1aa44a071f66bdfd9430356ab824b5a6985fb459
treeae4d8ed23b40fe326845a0608c3350200b429ecd
parentf5fbb1bcb0db378c61addd5b49ef2d95bd888168
signature Signed by PGP key B5690EEEBB952194

feat(web): add a file tree to the diff panel and pull request code tab (#9330)

Co-authored-by: Julius Marminge <julius@mac.lan> Co-authored-by: Shpetim <32248437+ShpetimA@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

8 files changed, 686 insertions(+), 168 deletions(-)

apps/web/src/components/DiffPanel.tsx+159-87
...@@ -15,12 +15,14 @@ import {...@@ -15,12 +15,14 @@ import {
15 ChevronsDownUpIcon,15 ChevronsDownUpIcon,
16 ChevronsUpDownIcon,16 ChevronsUpDownIcon,
17 Columns2Icon,17 Columns2Icon,
18 FolderTreeIcon,
18 PilcrowIcon,19 PilcrowIcon,
19 RefreshCwIcon,20 RefreshCwIcon,
20 Rows3Icon,21 Rows3Icon,
21 SearchIcon,22 SearchIcon,
22 TextWrapIcon,23 TextWrapIcon,
23} from "lucide-react";24} from "lucide-react";
25import * as Schema from "effect/Schema";
24import { useCallback, useEffect, useMemo, useRef, useState } from "react";26import { useCallback, useEffect, useMemo, useRef, useState } from "react";
25import { useOpenInPreferredEditor } from "../editorPreferences";27import { useOpenInPreferredEditor } from "../editorPreferences";
26import { type DraftId } from "../composerDraftStore";28import { type DraftId } from "../composerDraftStore";
...@@ -28,6 +30,7 @@ import { openDiffFilePrimaryAction } from "../diffFileActions";...@@ -28,6 +30,7 @@ import { openDiffFilePrimaryAction } from "../diffFileActions";
28import { useCheckpointDiff } from "~/lib/checkpointDiffState";30import { useCheckpointDiff } from "~/lib/checkpointDiffState";
29import { cn } from "~/lib/utils";31import { cn } from "~/lib/utils";
30import { selectThreadDiffPanelSelection, useDiffPanelStore } from "../diffPanelStore";32import { selectThreadDiffPanelSelection, useDiffPanelStore } from "../diffPanelStore";
33import { useLocalStorage } from "../hooks/useLocalStorage";
31import { useTheme } from "../hooks/useTheme";34import { useTheme } from "../hooks/useTheme";
32import {35import {
33 buildFileDiffContentVersion,36 buildFileDiffContentVersion,
...@@ -50,6 +53,8 @@ import { DiffFilePathCopyButton } from "./DiffFilePathCopyButton";...@@ -50,6 +53,8 @@ import { DiffFilePathCopyButton } from "./DiffFilePathCopyButton";
50import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell";53import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell";
51import { DiffStatLabel } from "./chat/DiffStatLabel";54import { DiffStatLabel } from "./chat/DiffStatLabel";
52import { AnnotatableCodeView, type AnnotatableCodeViewHandle } from "./diffs/AnnotatableCodeView";55import { AnnotatableCodeView, type AnnotatableCodeViewHandle } from "./diffs/AnnotatableCodeView";
56import { DiffFileTree } from "./diffs/DiffFileTree";
57import { diffFileTreeEntries } from "./diffs/diffFileTree.logic";
53import { Button } from "./ui/button";58import { Button } from "./ui/button";
54import { ToggleGroup, Toggle } from "./ui/toggle-group";59import { ToggleGroup, Toggle } from "./ui/toggle-group";
55import { Switch } from "./ui/switch";60import { Switch } from "./ui/switch";
...@@ -82,6 +87,7 @@ import { createGitDiffFileContentsLoader } from "../lib/diffFileContents";...@@ -82,6 +87,7 @@ import { createGitDiffFileContentsLoader } from "../lib/diffFileContents";
8287
83type DiffThemeType = "light" | "dark";88type DiffThemeType = "light" | "dark";
84const AUTOMATIC_BASE_REF = "__automatic_base_ref__";89const AUTOMATIC_BASE_REF = "__automatic_base_ref__";
90const DIFF_FILE_TREE_STORAGE_KEY = "t3code.diffFileTreeOpen";
8591
86interface CollapsedDiffFilesState {92interface CollapsedDiffFilesState {
87 readonly scopeKey: string | null;93 readonly scopeKey: string | null;
...@@ -112,6 +118,11 @@ export default function DiffPanel({...@@ -112,6 +118,11 @@ export default function DiffPanel({
112 const updateClientSettings = useUpdateClientSettings();118 const updateClientSettings = useUpdateClientSettings();
113 const [wordWrap, setWordWrap] = useState(settings.wordWrap);119 const [wordWrap, setWordWrap] = useState(settings.wordWrap);
114 const [diffIgnoreWhitespace, setDiffIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace);120 const [diffIgnoreWhitespace, setDiffIgnoreWhitespace] = useState(settings.diffIgnoreWhitespace);
121 const [fileTreeOpen, setFileTreeOpen] = useLocalStorage(
122 DIFF_FILE_TREE_STORAGE_KEY,
123 false,
124 Schema.Boolean,
125 );
115 const [baseRefQuery, setBaseRefQuery] = useState("");126 const [baseRefQuery, setBaseRefQuery] = useState("");
116 const [collapsedDiffFiles, setCollapsedDiffFiles] = useState<CollapsedDiffFilesState>(() => ({127 const [collapsedDiffFiles, setCollapsedDiffFiles] = useState<CollapsedDiffFilesState>(() => ({
117 scopeKey: null,128 scopeKey: null,
...@@ -431,6 +442,7 @@ export default function DiffPanel({...@@ -431,6 +442,7 @@ export default function DiffPanel({
431 const diffFileKeys = useMemo(() => codeViewFiles.map((file) => file.fileKey), [codeViewFiles]);442 const diffFileKeys = useMemo(() => codeViewFiles.map((file) => file.fileKey), [codeViewFiles]);
432 const allDiffFilesCollapsed = areAllDiffFilesCollapsed(diffFileKeys, collapsedDiffFileKeys);443 const allDiffFilesCollapsed = areAllDiffFilesCollapsed(diffFileKeys, collapsedDiffFileKeys);
433 const diffLineStat = useMemo(() => getDiffLineStat(renderableFiles), [renderableFiles]);444 const diffLineStat = useMemo(() => getDiffLineStat(renderableFiles), [renderableFiles]);
445 const fileTreeEntries = useMemo(() => diffFileTreeEntries(renderableFiles), [renderableFiles]);
434 const selectedDiffFileKey = selectedFilePath446 const selectedDiffFileKey = selectedFilePath
435 ? (codeViewFiles.find((candidate) => candidate.filePath === selectedFilePath)?.fileKey ?? null)447 ? (codeViewFiles.find((candidate) => candidate.filePath === selectedFilePath)?.fileKey ?? null)
436 : null;448 : null;
...@@ -440,6 +452,29 @@ export default function DiffPanel({...@@ -440,6 +452,29 @@ export default function DiffPanel({
440 codeViewRef.current?.scrollTo({ type: "item", id: selectedDiffFileKey, align: "start" });452 codeViewRef.current?.scrollTo({ type: "item", id: selectedDiffFileKey, align: "start" });
441 }, [codeViewMountKey, selectedDiffFileKey, selectedFileRevealRequestId]);453 }, [codeViewMountKey, selectedDiffFileKey, selectedFileRevealRequestId]);
442454
455 // Held as state so the scroll runs after a collapsed file has been drawn open again; scrolling
456 // in the same tick would land on the folded header's position.
457 const [treeReveal, setTreeReveal] = useState<{ fileKey: string; id: number } | null>(null);
458 useEffect(() => {
459 if (treeReveal === null) return;
460 codeViewRef.current?.scrollTo({ type: "item", id: treeReveal.fileKey, align: "start" });
461 }, [treeReveal]);
462 const revealDiffFile = useCallback(
463 (filePath: string) => {
464 const file = codeViewFiles.find((candidate) => candidate.filePath === filePath);
465 if (!file) return;
466 if (file.collapsed) {
467 setCollapsedDiffFiles((current) => {
468 const next = new Set(current.scopeKey === collapseScopeKey ? current.fileKeys : []);
469 next.delete(file.fileKey);
470 return { scopeKey: collapseScopeKey, fileKeys: next };
471 });
472 }
473 setTreeReveal((current) => ({ fileKey: file.fileKey, id: (current?.id ?? 0) + 1 }));
474 },
475 [codeViewFiles, collapseScopeKey],
476 );
477
443 const openDiffFile = useCallback(478 const openDiffFile = useCallback(
444 (filePath: string) => {479 (filePath: string) => {
445 openDiffFilePrimaryAction({480 openDiffFilePrimaryAction({
...@@ -825,6 +860,26 @@ export default function DiffPanel({...@@ -825,6 +860,26 @@ export default function DiffPanel({
825 {diffIgnoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"}860 {diffIgnoreWhitespace ? "Show whitespace changes" : "Hide whitespace changes"}
826 </TooltipPopup>861 </TooltipPopup>
827 </Tooltip>862 </Tooltip>
863 {codeViewFiles.length > 0 && (
864 <Tooltip>
865 <TooltipTrigger
866 render={
867 <Toggle
868 aria-label={fileTreeOpen ? "Hide file tree" : "Show file tree"}
869 variant="ghost"
870 size="sm"
871 pressed={fileTreeOpen}
872 onPressedChange={(pressed) => setFileTreeOpen(Boolean(pressed))}
873 />
874 }
875 >
876 <FolderTreeIcon className="size-3.5" />
877 </TooltipTrigger>
878 <TooltipPopup side="top">
879 {fileTreeOpen ? "Hide file tree" : "Show file tree"}
880 </TooltipPopup>
881 </Tooltip>
882 )}
828 </div>883 </div>
829 </>884 </>
830 );885 );
...@@ -878,97 +933,114 @@ export default function DiffPanel({...@@ -878,97 +933,114 @@ export default function DiffPanel({
878 </div>933 </div>
879 )934 )
880 ) : renderablePatch.kind === "files" ? (935 ) : renderablePatch.kind === "files" ? (
881 <div936 <div className="flex min-h-0 flex-1 overflow-hidden">
882 className="min-h-0 flex-1"937 <div
883 onClickCapture={(event) => {938 className="min-h-0 min-w-0 flex-1"
884 const composedPath = event.nativeEvent.composedPath?.() ?? [];939 onClickCapture={(event) => {
885 for (const node of composedPath) {940 const composedPath = event.nativeEvent.composedPath?.() ?? [];
886 if (!(node instanceof HTMLElement)) continue;941 for (const node of composedPath) {
887 // Header controls keep their own actions. In particular, the chevron must942 if (!(node instanceof HTMLElement)) continue;
888 // not also trigger the row handler or the two toggles cancel each other.943 // Header controls keep their own actions. In particular, the chevron must
889 if (node instanceof HTMLButtonElement || node instanceof HTMLAnchorElement) {944 // not also trigger the row handler or the two toggles cancel each other.
945 if (node instanceof HTMLButtonElement || node instanceof HTMLAnchorElement) {
946 return;
947 }
948 }
949 const title = composedPath.find(
950 (node): node is HTMLElement =>
951 node instanceof HTMLElement && node.hasAttribute("data-title"),
952 );
953 const filePath = title?.textContent?.trim();
954 // The filename remains the explicit "open in editor" affordance.
955 if (filePath) {
956 openDiffFile(filePath);
890 return;957 return;
891 }958 }
892 }959 const header = composedPath.find(
893 const title = composedPath.find(960 (node): node is HTMLElement =>
894 (node): node is HTMLElement =>961 node instanceof HTMLElement && node.hasAttribute("data-diffs-header"),
895 node instanceof HTMLElement && node.hasAttribute("data-title"),
896 );
897 const filePath = title?.textContent?.trim();
898 // The filename remains the explicit "open in editor" affordance.
899 if (filePath) {
900 openDiffFile(filePath);
901 return;
902 }
903 const header = composedPath.find(
904 (node): node is HTMLElement =>
905 node instanceof HTMLElement && node.hasAttribute("data-diffs-header"),
906 );
907 const headerFilePath = header?.querySelector("[data-title]")?.textContent?.trim();
908 if (!headerFilePath) return;
909 const file = codeViewFiles.find(
910 (candidate) => candidate.filePath === headerFilePath,
911 );
912 if (file) toggleDiffFileCollapsed(file.fileKey);
913 }}
914 >
915 <AnnotatableCodeView
916 key={collapseScopeKey ?? reviewSectionId}
917 viewerRef={codeViewRef}
918 codeViewKey={codeViewMountKey}
919 className="h-full min-h-0 overflow-auto"
920 files={codeViewFiles}
921 sectionId={reviewSectionId}
922 sectionTitle={reviewSectionTitle}
923 composerDraftTarget={composerDraftTarget}
924 renderHeaderFilenameSuffix={(fileDiff) => (
925 <DiffFilePathCopyButton filePath={resolveFileDiffPath(fileDiff)} />
926 )}
927 renderHeaderPrefix={(fileDiff, fileKey, collapsed) => {
928 const filePath = resolveFileDiffPath(fileDiff);
929 return (
930 <Tooltip>
931 <TooltipTrigger
932 render={
933 <Button
934 size="icon-micro"
935 variant="ghost"
936 className={cn(
937 "-ms-0.5 [--control-icon-color:currentColor] bg-transparent hover:bg-foreground/10",
938 getDiffCollapseIconClassName(fileDiff),
939 )}
940 aria-label={collapsed ? `Expand ${filePath}` : `Collapse ${filePath}`}
941 aria-expanded={!collapsed}
942 onClick={(event) => {
943 event.stopPropagation();
944 toggleDiffFileCollapsed(fileKey);
945 }}
946 />
947 }
948 >
949 {collapsed ? (
950 <ChevronRightIcon className="size-4" />
951 ) : (
952 <ChevronDownIcon className="size-4" />
953 )}
954 </TooltipTrigger>
955 <TooltipPopup side="top">
956 {collapsed ? "Expand diff" : "Collapse diff"}
957 </TooltipPopup>
958 </Tooltip>
959 );962 );
963 const headerFilePath = header
964 ?.querySelector("[data-title]")
965 ?.textContent?.trim();
966 if (!headerFilePath) return;
967 const file = codeViewFiles.find(
968 (candidate) => candidate.filePath === headerFilePath,
969 );
970 if (file) toggleDiffFileCollapsed(file.fileKey);
960 }}971 }}
961 options={{972 >
962 diffStyle: diffLayout === "split" ? "split" : "unified",973 <AnnotatableCodeView
963 lineDiffType: "none",974 key={collapseScopeKey ?? reviewSectionId}
964 overflow: wordWrap ? "wrap" : "scroll",975 viewerRef={codeViewRef}
965 theme: resolveDiffThemeName(resolvedTheme),976 codeViewKey={codeViewMountKey}
966 preferredHighlighter: PREFERRED_HIGHLIGHTER,977 className="h-full min-h-0 overflow-auto"
967 themeType: resolvedTheme as DiffThemeType,978 files={codeViewFiles}
968 stickyHeaders: true,979 sectionId={reviewSectionId}
969 ...(currentLoadDiffFiles ? { loadDiffFiles } : {}),980 sectionTitle={reviewSectionTitle}
970 }}981 composerDraftTarget={composerDraftTarget}
971 />982 renderHeaderFilenameSuffix={(fileDiff) => (
983 <DiffFilePathCopyButton filePath={resolveFileDiffPath(fileDiff)} />
984 )}
985 renderHeaderPrefix={(fileDiff, fileKey, collapsed) => {
986 const filePath = resolveFileDiffPath(fileDiff);
987 return (
988 <Tooltip>
989 <TooltipTrigger
990 render={
991 <Button
992 size="icon-micro"
993 variant="ghost"
994 className={cn(
995 "-ms-0.5 [--control-icon-color:currentColor] bg-transparent hover:bg-foreground/10",
996 getDiffCollapseIconClassName(fileDiff),
997 )}
998 aria-label={
999 collapsed ? `Expand ${filePath}` : `Collapse ${filePath}`
1000 }
1001 aria-expanded={!collapsed}
1002 onClick={(event) => {
1003 event.stopPropagation();
1004 toggleDiffFileCollapsed(fileKey);
1005 }}
1006 />
1007 }
1008 >
1009 {collapsed ? (
1010 <ChevronRightIcon className="size-4" />
1011 ) : (
1012 <ChevronDownIcon className="size-4" />
1013 )}
1014 </TooltipTrigger>
1015 <TooltipPopup side="top">
1016 {collapsed ? "Expand diff" : "Collapse diff"}
1017 </TooltipPopup>
1018 </Tooltip>
1019 );
1020 }}
1021 options={{
1022 diffStyle: diffLayout === "split" ? "split" : "unified",
1023 lineDiffType: "none",
1024 overflow: wordWrap ? "wrap" : "scroll",
1025 theme: resolveDiffThemeName(resolvedTheme),
1026 preferredHighlighter: PREFERRED_HIGHLIGHTER,
1027 themeType: resolvedTheme as DiffThemeType,
1028 stickyHeaders: true,
1029 ...(currentLoadDiffFiles ? { loadDiffFiles } : {}),
1030 }}
1031 />
1032 </div>
1033 {fileTreeOpen ? (
1034 <aside className="flex w-[min(16rem,40%)] min-w-40 shrink-0 border-l border-border/60">
1035 <DiffFileTree
1036 ariaLabel={`${reviewSectionTitle} files`}
1037 entries={fileTreeEntries}
1038 selectedPath={selectedFilePath}
1039 revealRequestId={selectedFileRevealRequestId}
1040 onSelectFile={revealDiffFile}
1041 />
1042 </aside>
1043 ) : null}
972 </div>1044 </div>
973 ) : (1045 ) : (
974 <div className="min-h-0 flex-1 overflow-auto p-2">1046 <div className="min-h-0 flex-1 overflow-auto p-2">
apps/web/src/components/diffs/DiffFileTree.tsx created+186
...@@ -0,0 +1,186 @@
1import type { GitStatusEntry } from "@pierre/trees";
2import { FileTree, useFileTree, useFileTreeSelector } from "@pierre/trees/react";
3import { ChevronsDownUpIcon, ChevronsUpDownIcon } from "lucide-react";
4import { useEffect, useMemo, useRef, type ReactNode } from "react";
5
6import { useTheme } from "~/hooks/useTheme";
7import { cn } from "~/lib/utils";
8import { T3_PIERRE_ICONS } from "~/pierre-icons";
9import { PIERRE_TREE_UNSAFE_CSS, pierreTreeStyle } from "~/pierre-tree-theme";
10
11import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "../files/fileTreeExpansion";
12import { Button } from "../ui/button";
13import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
14import {
15 buildDiffFileTreeUpdates,
16 collectDirectoryPaths,
17 type DiffFileTreeEntry,
18} from "./diffFileTree.logic";
19
20export type { DiffFileTreeEntry } from "./diffFileTree.logic";
21
22interface DiffFileTreeProps {
23 readonly entries: ReadonlyArray<DiffFileTreeEntry>;
24 /** Called with the file's path when the reader picks a file row. */
25 readonly onSelectFile: (path: string) => void;
26 /**
27 * The file the diff is currently showing, kept selected in the tree. Bump `revealRequestId` to
28 * scroll the tree to the same path again.
29 */
30 readonly selectedPath?: string | null;
31 readonly revealRequestId?: number;
32 readonly ariaLabel: string;
33 /** Right-aligned content in the header row, after the file count. */
34 readonly headerAccessory?: ReactNode;
35 /** Rendered under the tree, for a host that still has files to fetch. */
36 readonly footer?: ReactNode;
37 readonly className?: string;
38}
39
40/**
41 * A directory tree of the files in a diff. Every directory starts open: a diff is a short list
42 * compared to a workspace, and the reader came for the files, not the folders.
43 */
44export function DiffFileTree({
45 entries,
46 onSelectFile,
47 selectedPath = null,
48 revealRequestId = 0,
49 ariaLabel,
50 headerAccessory,
51 footer,
52 className,
53}: DiffFileTreeProps) {
54 const { resolvedTheme } = useTheme();
55 const paths = useMemo(() => entries.map((entry) => entry.path), [entries]);
56 const directoryPaths = useMemo(() => collectDirectoryPaths(paths), [paths]);
57 const gitStatus = useMemo<ReadonlyArray<GitStatusEntry>>(
58 () => entries.map((entry) => ({ path: entry.path, status: entry.status })),
59 [entries],
60 );
61 const filePathsRef = useRef<ReadonlySet<string>>(new Set(paths));
62 const onSelectFileRef = useRef(onSelectFile);
63 // Selection driven by `selectedPath` below is an echo of a file already on screen, not a
64 // request to scroll to it again.
65 const syncingSelectionRef = useRef(false);
66 const handledRevealRef = useRef<{ path: string; revealRequestId: number } | null>(null);
67 const mountedPathsRef = useRef<ReadonlyArray<string> | null>(null);
68
69 useEffect(() => {
70 filePathsRef.current = new Set(paths);
71 onSelectFileRef.current = onSelectFile;
72 }, [onSelectFile, paths]);
73
74 const { model } = useFileTree({
75 density: "compact",
76 flattenEmptyDirectories: true,
77 initialExpansion: "open",
78 icons: T3_PIERRE_ICONS,
79 onSelectionChange: (selectedPaths) => {
80 if (syncingSelectionRef.current) return;
81 const path = selectedPaths.at(-1)?.replace(/\/$/, "");
82 if (path && filePathsRef.current.has(path)) onSelectFileRef.current(path);
83 },
84 paths: [],
85 search: false,
86 unsafeCSS: PIERRE_TREE_UNSAFE_CSS,
87 });
88 const allDirectoriesExpanded = useFileTreeSelector(model, (currentModel) =>
89 areAllDirectoriesExpanded(currentModel, directoryPaths),
90 );
91
92 useEffect(() => {
93 const mountedPaths = mountedPathsRef.current;
94 if (mountedPaths === paths) return;
95 mountedPathsRef.current = paths;
96 if (mountedPaths === null) {
97 model.resetPaths(paths);
98 } else {
99 const updates = buildDiffFileTreeUpdates(mountedPaths, paths);
100 if (updates.length > 0) model.batch(updates);
101 }
102 model.setGitStatus(gitStatus);
103 }, [gitStatus, model, paths]);
104
105 useEffect(() => {
106 if (selectedPath === null) {
107 handledRevealRef.current = null;
108 return;
109 }
110 // A path list that changes under an already-revealed file (a refresh, a later slice) must
111 // not pull the tree back to it over whatever the reader has picked since.
112 const item = model.getItem(selectedPath);
113 if (item === null || item.isDirectory()) {
114 // A file that left the diff has to be revealed again when it comes back.
115 handledRevealRef.current = null;
116 return;
117 }
118 const handled = handledRevealRef.current;
119 if (handled?.path === selectedPath && handled.revealRequestId === revealRequestId) return;
120 handledRevealRef.current = { path: selectedPath, revealRequestId };
121 syncingSelectionRef.current = true;
122 for (const path of model.getSelectedPaths()) {
123 if (path !== selectedPath) model.getItem(path)?.deselect();
124 }
125 let ancestor = "";
126 for (const segment of selectedPath.split("/").slice(0, -1)) {
127 ancestor += `${segment}/`;
128 const directory = model.getItem(ancestor);
129 if (directory !== null && "expand" in directory) directory.expand();
130 }
131 item.select();
132 model.scrollToPath(selectedPath, { offset: "nearest" });
133 queueMicrotask(() => {
134 syncingSelectionRef.current = false;
135 });
136 // `paths` is a dependency so a file that arrives after it was asked for is still revealed.
137 }, [model, paths, revealRequestId, selectedPath]);
138
139 return (
140 <div className={cn("flex min-h-0 flex-1 flex-col bg-background", className)}>
141 <div
142 className="flex h-10 min-h-10 shrink-0 items-center gap-1 border-b border-border/60 bg-background px-2 text-xs text-muted-foreground in-data-[preview-panel-mode=inline]:mb-3 in-data-[preview-panel-mode=inline]:h-7 in-data-[preview-panel-mode=inline]:min-h-7 in-data-[preview-panel-mode=inline]:border-b-transparent"
143 data-surface-subheader
144 >
145 <span className="px-1 font-medium text-foreground">Files</span>
146 <span className="ml-auto tabular-nums">{entries.length}</span>
147 {headerAccessory}
148 {directoryPaths.length > 0 ? (
149 <Tooltip>
150 <TooltipTrigger
151 render={
152 <Button
153 type="button"
154 size="icon-xs"
155 variant="ghost"
156 aria-label={
157 allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"
158 }
159 onClick={() =>
160 setAllDirectoriesExpanded(model, directoryPaths, !allDirectoriesExpanded)
161 }
162 />
163 }
164 >
165 {allDirectoriesExpanded ? (
166 <ChevronsDownUpIcon className="size-3.5" />
167 ) : (
168 <ChevronsUpDownIcon className="size-3.5" />
169 )}
170 </TooltipTrigger>
171 <TooltipPopup>
172 {allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"}
173 </TooltipPopup>
174 </Tooltip>
175 ) : null}
176 </div>
177 <FileTree
178 model={model}
179 aria-label={ariaLabel}
180 className="min-h-0 flex-1 overflow-hidden"
181 style={pierreTreeStyle(resolvedTheme)}
182 />
183 {footer}
184 </div>
185 );
186}
apps/web/src/components/diffs/diffFileTree.logic.test.ts created+69
...@@ -0,0 +1,69 @@
1import type { FileDiffMetadata } from "@pierre/diffs";
2import { describe, expect, it } from "vite-plus/test";
3
4import {
5 buildDiffFileTreeUpdates,
6 collectDirectoryPaths,
7 diffFileTreeEntries,
8} from "./diffFileTree.logic";
9
10function file(type: FileDiffMetadata["type"], name: string, prevName = name): FileDiffMetadata {
11 return { type, name: `b/${name}`, prevName: `a/${prevName}` } as FileDiffMetadata;
12}
13
14describe("diffFileTreeEntries", () => {
15 it("maps each change type to its git status under the file's current path", () => {
16 expect(
17 diffFileTreeEntries([
18 file("new", "src/a.ts"),
19 file("deleted", "src/b.ts"),
20 file("rename-pure", "src/c.ts", "src/old-c.ts"),
21 file("rename-changed", "src/d.ts", "src/old-d.ts"),
22 file("change", "README.md"),
23 ]),
24 ).toEqual([
25 { path: "src/a.ts", status: "added" },
26 { path: "src/b.ts", status: "deleted" },
27 { path: "src/c.ts", status: "renamed" },
28 { path: "src/d.ts", status: "renamed" },
29 { path: "README.md", status: "modified" },
30 ]);
31 });
32});
33
34describe("collectDirectoryPaths", () => {
35 it("lists every ancestor once, parents first, with Pierre's trailing slash", () => {
36 expect(collectDirectoryPaths(["apps/web/src/a.ts", "apps/web/b.ts", "README.md"])).toEqual([
37 "apps/",
38 "apps/web/",
39 "apps/web/src/",
40 ]);
41 });
42});
43
44describe("buildDiffFileTreeUpdates", () => {
45 it("adds a new file's directories before the file", () => {
46 expect(buildDiffFileTreeUpdates(["README.md"], ["README.md", "src/lib/a.ts"])).toEqual([
47 { type: "add", path: "src/" },
48 { type: "add", path: "src/lib/" },
49 { type: "add", path: "src/lib/a.ts" },
50 ]);
51 });
52
53 it("removes files before their now-empty directories, deepest first", () => {
54 expect(buildDiffFileTreeUpdates(["src/lib/a.ts", "src/b.ts"], ["src/b.ts"])).toEqual([
55 { type: "remove", path: "src/lib/a.ts" },
56 { type: "remove", path: "src/lib/", recursive: true },
57 ]);
58 });
59
60 it("keeps a directory that still holds a file", () => {
61 expect(buildDiffFileTreeUpdates(["src/a.ts", "src/b.ts"], ["src/b.ts"])).toEqual([
62 { type: "remove", path: "src/a.ts" },
63 ]);
64 });
65
66 it("produces nothing when the paths are unchanged", () => {
67 expect(buildDiffFileTreeUpdates(["src/a.ts"], ["src/a.ts"])).toEqual([]);
68 });
69});
apps/web/src/components/diffs/diffFileTree.logic.ts created+93
...@@ -0,0 +1,93 @@
1import type { FileDiffMetadata } from "@pierre/diffs";
2import type { FileTreeBatchOperation, GitStatus } from "@pierre/trees";
3
4import { resolveFileDiffPath } from "~/lib/diffRendering";
5
6/** One changed file as the tree shows it: its current path and how it changed. */
7export interface DiffFileTreeEntry {
8 readonly path: string;
9 readonly status: GitStatus;
10}
11
12function toGitStatus(file: FileDiffMetadata): GitStatus {
13 switch (file.type) {
14 case "new":
15 return "added";
16 case "deleted":
17 return "deleted";
18 case "rename-pure":
19 case "rename-changed":
20 return "renamed";
21 case "change":
22 return "modified";
23 }
24}
25
26/** Maps parsed diff files to tree entries, keeping the diff's own order. */
27export function diffFileTreeEntries(
28 files: ReadonlyArray<FileDiffMetadata>,
29): ReadonlyArray<DiffFileTreeEntry> {
30 return files.map((file) => ({ path: resolveFileDiffPath(file), status: toGitStatus(file) }));
31}
32
33/**
34 * Every directory on the way to each file, registered with the trailing slash Pierre uses for
35 * directory ids. Parents come before children so the tree can add them in order.
36 */
37export function collectDirectoryPaths(paths: ReadonlyArray<string>): ReadonlyArray<string> {
38 const directories = new Set<string>();
39 for (const path of paths) {
40 const segments = path.split("/");
41 let directory = "";
42 for (const segment of segments.slice(0, -1)) {
43 directory += `${segment}/`;
44 directories.add(directory);
45 }
46 }
47 return [...directories];
48}
49
50function pathDepth(path: string): number {
51 return path.split("/").filter(Boolean).length;
52}
53
54/**
55 * The adds and removes that turn one set of file paths into another, so a diff that changes
56 * under the reader (a new slice, a refresh after an agent edit) keeps the directories they
57 * have already opened or closed instead of rebuilding the tree from scratch.
58 *
59 * Directories are removed only once no file needs them; a directory that gains its first file
60 * is added before that file.
61 */
62export function buildDiffFileTreeUpdates(
63 previousPaths: ReadonlyArray<string>,
64 nextPaths: ReadonlyArray<string>,
65): FileTreeBatchOperation[] {
66 const previousDirectories = new Set(collectDirectoryPaths(previousPaths));
67 const nextDirectories = new Set(collectDirectoryPaths(nextPaths));
68 const previous = new Set(previousPaths);
69 const next = new Set(nextPaths);
70 const updates: FileTreeBatchOperation[] = [];
71
72 for (const path of previousPaths) {
73 if (!next.has(path)) updates.push({ type: "remove", path });
74 }
75 // Deepest first: a directory can only go once everything under it has.
76 const removedDirectories = [...previousDirectories]
77 .filter((directory) => !nextDirectories.has(directory))
78 .toSorted((left, right) => pathDepth(right) - pathDepth(left));
79 for (const directory of removedDirectories) {
80 updates.push({ type: "remove", path: directory, recursive: true });
81 }
82
83 // Shallowest first: a file's directory has to exist before the file does.
84 const addedDirectories = [...nextDirectories]
85 .filter((directory) => !previousDirectories.has(directory))
86 .toSorted((left, right) => pathDepth(left) - pathDepth(right));
87 for (const directory of addedDirectories) updates.push({ type: "add", path: directory });
88 for (const path of nextPaths) {
89 if (!previous.has(path)) updates.push({ type: "add", path });
90 }
91
92 return updates;
93}
apps/web/src/components/files/FileBrowserPanel.tsx+3-17
...@@ -19,6 +19,7 @@ import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh...@@ -19,6 +19,7 @@ import { useWorkspaceMutationRefresh } from "~/hooks/useWorkspaceMutationRefresh
19import { cn } from "~/lib/utils";19import { cn } from "~/lib/utils";
20import { readLocalApi } from "~/localApi";20import { readLocalApi } from "~/localApi";
21import { T3_PIERRE_ICONS } from "~/pierre-icons";21import { T3_PIERRE_ICONS } from "~/pierre-icons";
22import { PIERRE_TREE_UNSAFE_CSS, pierreTreeStyle } from "~/pierre-tree-theme";
2223
23import { createFileTreeDragMentionController } from "./fileTreeDragMention";24import { createFileTreeDragMentionController } from "./fileTreeDragMention";
24import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "./fileTreeExpansion";25import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "./fileTreeExpansion";
...@@ -38,18 +39,6 @@ interface FileBrowserPanelProps {...@@ -38,18 +39,6 @@ interface FileBrowserPanelProps {
38 workspaceMutationId: string | null;39 workspaceMutationId: string | null;
39}40}
4041
41const TREE_UNSAFE_CSS = `
42 :host {
43 --trees-bg-override: transparent;
44 --trees-selected-bg-override: color-mix(in srgb, currentColor 12%, transparent);
45 --trees-hover-bg-override: color-mix(in srgb, currentColor 7%, transparent);
46 --trees-border-color-override: color-mix(in srgb, currentColor 14%, transparent);
47 --trees-font-family-override: var(--font-sans);
48 --trees-font-size-override: 12px;
49 }
50 button[data-type='item'] { border-radius: 5px; }
51`;
52
53function treePath(entry: ProjectEntry): string {42function treePath(entry: ProjectEntry): string {
54 return entry.kind === "directory" ? `${entry.path}/` : entry.path;43 return entry.kind === "directory" ? `${entry.path}/` : entry.path;
55}44}
...@@ -255,7 +244,7 @@ export default function FileBrowserPanel({...@@ -255,7 +244,7 @@ export default function FileBrowserPanel({
255 },244 },
256 paths: [],245 paths: [],
257 search: false,246 search: false,
258 unsafeCSS: TREE_UNSAFE_CSS,247 unsafeCSS: PIERRE_TREE_UNSAFE_CSS,
259 });248 });
260 const search = useFileTreeSearch(model);249 const search = useFileTreeSearch(model);
261 const allDirectoriesExpanded = useFileTreeSelector(model, (currentModel) =>250 const allDirectoriesExpanded = useFileTreeSelector(model, (currentModel) =>
...@@ -429,10 +418,7 @@ export default function FileBrowserPanel({...@@ -429,10 +418,7 @@ export default function FileBrowserPanel({
429 model={model}418 model={model}
430 aria-label={`${projectName} files`}419 aria-label={`${projectName} files`}
431 className="min-h-0 flex-1 overflow-hidden"420 className="min-h-0 flex-1 overflow-hidden"
432 style={{421 style={pierreTreeStyle(resolvedTheme)}
433 colorScheme: resolvedTheme,
434 ["--trees-fg-override" as string]: "var(--contrast-foreground)",
435 }}
436 />422 />
437 )}423 )}
438 </div>424 </div>
apps/web/src/components/pullRequest/PullRequestCodeTab.tsx+151-64
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import type { CodeViewItem, DiffLineAnnotation, SelectedLineRange } from "@pierre/diffs";1import type { CodeViewItem, DiffLineAnnotation, SelectedLineRange } from "@pierre/diffs";
2import type { CodeViewDiffItem } from "@pierre/diffs/react";2import type { CodeViewDiffItem, CodeViewHandle } from "@pierre/diffs/react";
3import type {3import type {
4 EnvironmentId,4 EnvironmentId,
5 PullRequestDetailView,5 PullRequestDetailView,
...@@ -16,6 +16,7 @@ import {...@@ -16,6 +16,7 @@ import {
16 ChevronsDownUpIcon,16 ChevronsDownUpIcon,
17 ChevronsUpDownIcon,17 ChevronsUpDownIcon,
18 Columns2Icon,18 Columns2Icon,
19 FolderTreeIcon,
19 MessageSquareIcon,20 MessageSquareIcon,
20 MessageSquareOffIcon,21 MessageSquareOffIcon,
21 Rows3Icon,22 Rows3Icon,
...@@ -24,8 +25,10 @@ import {...@@ -24,8 +25,10 @@ import {
24 XIcon,25 XIcon,
25} from "lucide-react";26} from "lucide-react";
26import { useAtomRefresh } from "@effect/atom-react";27import { useAtomRefresh } from "@effect/atom-react";
28import * as Schema from "effect/Schema";
27import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";29import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
2830
31import { useLocalStorage } from "~/hooks/useLocalStorage";
29import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings";32import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings";
30import { useTheme } from "~/hooks/useTheme";33import { useTheme } from "~/hooks/useTheme";
31import { areAllDiffFilesCollapsed } from "~/lib/diffCollapse";34import { areAllDiffFilesCollapsed } from "~/lib/diffCollapse";
...@@ -57,6 +60,8 @@ import { useAtomCommand } from "~/state/use-atom-command";...@@ -57,6 +60,8 @@ import { useAtomCommand } from "~/state/use-atom-command";
57import { DiffPanelLoadingState } from "../DiffPanelShell";60import { DiffPanelLoadingState } from "../DiffPanelShell";
58import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider";61import { DiffWorkerPoolProvider } from "../DiffWorkerPoolProvider";
59import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation";62import { DiffCommentAnnotation } from "../diffs/DiffCommentAnnotation";
63import { DiffFileTree } from "../diffs/DiffFileTree";
64import { diffFileTreeEntries } from "../diffs/diffFileTree.logic";
60import { StyledDiffCodeView } from "../diffs/StyledDiffCodeView";65import { StyledDiffCodeView } from "../diffs/StyledDiffCodeView";
61import { Button } from "../ui/button";66import { Button } from "../ui/button";
62import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";67import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible";
...@@ -97,6 +102,8 @@ type ReviewAnnotation = DiffLineAnnotation<ReviewAnnotationGroup>;...@@ -97,6 +102,8 @@ type ReviewAnnotation = DiffLineAnnotation<ReviewAnnotationGroup>;
97/** Commits per press of "Show more" in the scope menu. */102/** Commits per press of "Show more" in the scope menu. */
98const COMMIT_PAGE_SIZE = 10;103const COMMIT_PAGE_SIZE = 10;
99104
105const PULL_REQUEST_FILE_TREE_STORAGE_KEY = "t3code.pullRequestFileTreeOpen";
106
100/** One answer from the host: a whole number of files, and where the next one carries on. */107/** One answer from the host: a whole number of files, and where the next one carries on. */
101interface DiffSlice {108interface DiffSlice {
102 /** What was asked for, null being the first slice. Identifies the slice among the loaded ones. */109 /** What was asked for, null being the first slice. Identifies the slice among the loaded ones. */
...@@ -220,6 +227,11 @@ export function PullRequestCodeTab({...@@ -220,6 +227,11 @@ export function PullRequestCodeTab({
220 const diffLayout = settings.diffLayout;227 const diffLayout = settings.diffLayout;
221 const updateClientSettings = useUpdateClientSettings();228 const updateClientSettings = useUpdateClientSettings();
222 const [wordWrap, setWordWrap] = useState(settings.wordWrap);229 const [wordWrap, setWordWrap] = useState(settings.wordWrap);
230 const [fileTreeOpen, setFileTreeOpen] = useLocalStorage(
231 PULL_REQUEST_FILE_TREE_STORAGE_KEY,
232 false,
233 Schema.Boolean,
234 );
223 const [selectedLines, setSelectedLines] = useState<{235 const [selectedLines, setSelectedLines] = useState<{
224 id: string;236 id: string;
225 range: SelectedLineRange;237 range: SelectedLineRange;
...@@ -238,6 +250,7 @@ export function PullRequestCodeTab({...@@ -238,6 +250,7 @@ export function PullRequestCodeTab({
238 readonly slices: ReadonlyArray<DiffSlice>;250 readonly slices: ReadonlyArray<DiffSlice>;
239 }>({ key: "", cursor: null, slices: NO_SLICES });251 }>({ key: "", cursor: null, slices: NO_SLICES });
240 const parseCache = useRef(new Map<string, RenderablePatch>());252 const parseCache = useRef(new Map<string, RenderablePatch>());
253 const viewerRef = useRef<CodeViewHandle<ReviewAnnotationGroup> | null>(null);
241254
242 const referenceKey = pullRequestReviewKey(reference);255 const referenceKey = pullRequestReviewKey(reference);
243 const commit = selectedCommitOid;256 const commit = selectedCommitOid;
...@@ -544,34 +557,35 @@ export function PullRequestCodeTab({...@@ -544,34 +557,35 @@ export function PullRequestCodeTab({
544 [items],557 [items],
545 );558 );
546 const allFilesCollapsed = areAllDiffFilesCollapsed(fileKeys, collapsedFileKeys);559 const allFilesCollapsed = areAllDiffFilesCollapsed(fileKeys, collapsedFileKeys);
560 const fileTreeEntries = useMemo(() => diffFileTreeEntries(files), [files]);
561
562 // A failed slice must not be asked for again on its own. The files already loaded keep the
563 // sentinel on screen, so re-arming it after a failure would request the same slice forever.
564 const canLoadNextSlice =
565 nextCursor !== null &&
566 nextCursor !== cursor &&
567 !diffQuery.isPending &&
568 diffQuery.error === null;
569 const loadNextSlice = useCallback(() => {
570 if (nextCursor === null) return;
571 setSliceState((previous) => ({ ...previous, cursor: nextCursor }));
572 }, [nextCursor]);
547573
548 // The sentinel is held as state rather than a ref because the viewer mounts its own footer:574 // The sentinel is held as state rather than a ref because the viewer mounts its own footer:
549 // an effect reading a ref could run before that node exists and would never arm the observer.575 // an effect reading a ref could run before that node exists and would never arm the observer.
550 const [sentinel, setSentinel] = useState<HTMLDivElement | null>(null);576 const [sentinel, setSentinel] = useState<HTMLDivElement | null>(null);
551 useEffect(() => {577 useEffect(() => {
552 // A failed slice must stop the observer. The files already loaded keep the sentinel on578 if (sentinel === null || !canLoadNextSlice) return;
553 // screen, so re-arming it after a failure would ask for the same slice again, forever.
554 if (
555 sentinel === null ||
556 nextCursor === null ||
557 nextCursor === cursor ||
558 diffQuery.isPending ||
559 diffQuery.error !== null
560 ) {
561 return;
562 }
563 const observer = new IntersectionObserver(579 const observer = new IntersectionObserver(
564 (observed) => {580 (observed) => {
565 if (observed.some((entry) => entry.isIntersecting)) {581 if (observed.some((entry) => entry.isIntersecting)) loadNextSlice();
566 setSliceState((previous) => ({ ...previous, cursor: nextCursor }));
567 }
568 },582 },
569 // Start the next slice slightly before the sentinel is on screen.583 // Start the next slice slightly before the sentinel is on screen.
570 { rootMargin: "240px" },584 { rootMargin: "240px" },
571 );585 );
572 observer.observe(sentinel);586 observer.observe(sentinel);
573 return () => observer.disconnect();587 return () => observer.disconnect();
574 }, [cursor, diffQuery.error, diffQuery.isPending, nextCursor, sentinel]);588 }, [canLoadNextSlice, loadNextSlice, sentinel]);
575589
576 // A stable identity: the viewer's SlotPortals memoizes each file's header/annotation portal on590 // A stable identity: the viewer's SlotPortals memoizes each file's header/annotation portal on
577 // these render props, so a fresh function here would recreate every visible file's portal on591 // these render props, so a fresh function here would recreate every visible file's portal on
...@@ -589,6 +603,23 @@ export function PullRequestCodeTab({...@@ -589,6 +603,23 @@ export function PullRequestCodeTab({
589 [],603 [],
590 );604 );
591605
606 // Held as state so the scroll runs after a folded file has been drawn open; scrolling in the
607 // same tick would land on the folded header's position.
608 const [treeReveal, setTreeReveal] = useState<{ fileKey: string; id: number } | null>(null);
609 useEffect(() => {
610 if (treeReveal === null) return;
611 viewerRef.current?.scrollTo({ type: "item", id: treeReveal.fileKey, align: "start" });
612 }, [treeReveal]);
613 const revealFile = useCallback(
614 (path: string) => {
615 const item = items.find((candidate) => resolveFileDiffPath(candidate.fileDiff) === path);
616 if (item === undefined) return;
617 if (item.collapsed === true) toggleFile(item.id);
618 setTreeReveal((current) => ({ fileKey: item.id, id: (current?.id ?? 0) + 1 }));
619 },
620 [items, toggleFile],
621 );
622
592 const toggleAllFiles = () => {623 const toggleAllFiles = () => {
593 // Held as an override of the default rather than as the file keys on screen: a diff that is624 // Held as an override of the default rather than as the file keys on screen: a diff that is
594 // still paging would otherwise bring its next slice in folded, moments after the reader625 // still paging would otherwise bring its next slice in folded, moments after the reader
...@@ -1150,6 +1181,26 @@ export function PullRequestCodeTab({...@@ -1150,6 +1181,26 @@ export function PullRequestCodeTab({
1150 {wordWrap ? "Disable line wrapping" : "Enable line wrapping"}1181 {wordWrap ? "Disable line wrapping" : "Enable line wrapping"}
1151 </TooltipPopup>1182 </TooltipPopup>
1152 </Tooltip>1183 </Tooltip>
1184 {fileKeys.length > 0 ? (
1185 <Tooltip>
1186 <TooltipTrigger
1187 render={
1188 <Toggle
1189 aria-label={fileTreeOpen ? "Hide file tree" : "Show file tree"}
1190 variant="ghost"
1191 size="sm"
1192 pressed={fileTreeOpen}
1193 onPressedChange={(pressed) => setFileTreeOpen(Boolean(pressed))}
1194 />
1195 }
1196 >
1197 <FolderTreeIcon className="size-3.5" />
1198 </TooltipTrigger>
1199 <TooltipPopup side="top">
1200 {fileTreeOpen ? "Hide file tree" : "Show file tree"}
1201 </TooltipPopup>
1202 </Tooltip>
1203 ) : null}
1153 </div>1204 </div>
1154 </div>1205 </div>
1155 );1206 );
...@@ -1305,58 +1356,94 @@ export function PullRequestCodeTab({...@@ -1305,58 +1356,94 @@ export function PullRequestCodeTab({
1305 </CollapsiblePanel>1356 </CollapsiblePanel>
1306 </Collapsible>1357 </Collapsible>
1307 ) : null}1358 ) : null}
1308 {/* Relative wrapper so the review overlay floats over the diff rather than pushing it1359 <div className="flex min-h-0 flex-1 overflow-hidden">
1360 {/* Relative wrapper so the review overlay floats over the diff rather than pushing it
1309 up; the viewer inside still owns its own scrolling. */}1361 up; the viewer inside still owns its own scrolling. */}
1310 <div1362 <div
1311 className="relative min-h-0 flex-1"1363 className="relative min-h-0 min-w-0 flex-1"
1312 // The chevron answers this too, but the whole header row is the target a reader1364 // The chevron answers this too, but the whole header row is the target a reader
1313 // actually aims for. The header lives in the viewer's shadow tree, so the capture1365 // actually aims for. The header lives in the viewer's shadow tree, so the capture
1314 // listener walks `composedPath` — the only way to see through the shadow boundary.1366 // listener walks `composedPath` — the only way to see through the shadow boundary.
1315 onClickCapture={(event) => {1367 onClickCapture={(event) => {
1316 const composedPath = event.nativeEvent.composedPath?.() ?? [];1368 const composedPath = event.nativeEvent.composedPath?.() ?? [];
1317 for (const node of composedPath) {1369 for (const node of composedPath) {
1318 if (!(node instanceof HTMLElement)) continue;1370 if (!(node instanceof HTMLElement)) continue;
1319 // A control inside the header — the collapse chevron — handles itself, and1371 // A control inside the header — the collapse chevron — handles itself, and
1320 // this capture listener fires before its own click does. Leave it alone or1372 // this capture listener fires before its own click does. Leave it alone or
1321 // the two toggles cancel out.1373 // the two toggles cancel out.
1322 if (node instanceof HTMLButtonElement || node instanceof HTMLAnchorElement) {1374 if (node instanceof HTMLButtonElement || node instanceof HTMLAnchorElement) {
1323 return;1375 return;
1324 }1376 }
1325 if (node.hasAttribute("data-diffs-header")) {1377 if (node.hasAttribute("data-diffs-header")) {
1326 const filePath = node.querySelector("[data-title]")?.textContent?.trim();1378 const filePath = node.querySelector("[data-title]")?.textContent?.trim();
1327 if (filePath === undefined || filePath === "") return;1379 if (filePath === undefined || filePath === "") return;
1328 const item = items.find(1380 const item = items.find(
1329 (candidate) => resolveFileDiffPath(candidate.fileDiff) === filePath,1381 (candidate) => resolveFileDiffPath(candidate.fileDiff) === filePath,
1330 );1382 );
1331 if (item !== undefined) toggleFile(item.id);1383 if (item !== undefined) toggleFile(item.id);
1332 return;1384 return;
1385 }
1333 }1386 }
1334 }1387 }}
1335 }}1388 >
1336 >1389 {/* The viewer virtualizes against the element it is told is scrolling and places its
1337 {/* The viewer virtualizes against the element it is told is scrolling and places its
1338 rows absolutely, so it has to own that element — the thread diff panel hands it the1390 rows absolutely, so it has to own that element — the thread diff panel hands it the
1339 same one. Scrolling from a parent instead leaves it painting over its neighbours. */}1391 same one. Scrolling from a parent instead leaves it painting over its neighbours. */}
1340 <StyledDiffCodeView<ReviewAnnotationGroup>1392 <StyledDiffCodeView<ReviewAnnotationGroup>
1341 // Keep scrollbar space stable so file metadata and line numbers do not shift as a1393 // Keep scrollbar space stable so file metadata and line numbers do not shift as a
1342 // diff crosses the overflow boundary. The viewer is itself focusable for keyboard1394 // diff crosses the overflow boundary. The viewer is itself focusable for keyboard
1343 // interaction, but its native host outline clips and competes with the focus1395 // interaction, but its native host outline clips and competes with the focus
1344 // indicators on its actual controls.1396 // indicators on its actual controls.
1345 className="h-full overflow-auto [scrollbar-gutter:stable]"1397 className="h-full overflow-auto [scrollbar-gutter:stable]"
1346 items={items}1398 viewerRef={viewerRef}
1347 selectedLines={selectedLines}1399 items={items}
1348 onSelectedLinesChange={setSelectedLines}1400 selectedLines={selectedLines}
1349 options={diffViewOptions}1401 onSelectedLinesChange={setSelectedLines}
1350 // The viewer owns the scroll container, so the sentinel that asks for the next slice1402 options={diffViewOptions}
1351 // has to live inside it — at the end of the files, where reaching it means the reader1403 // The viewer owns the scroll container, so the sentinel that asks for the next slice
1352 // is running out of diff.1404 // has to live inside it — at the end of the files, where reaching it means the reader
1353 renderCodeViewFooter={renderCodeViewFooter}1405 // is running out of diff.
1354 renderHeaderPrefix={renderHeaderPrefix}1406 renderCodeViewFooter={renderCodeViewFooter}
1355 renderHeaderMetadata={renderHeaderMetadata}1407 renderHeaderPrefix={renderHeaderPrefix}
1356 renderAnnotation={renderAnnotation}1408 renderHeaderMetadata={renderHeaderMetadata}
1357 unsafeCSSExtra={REPLACE_FILE_COUNTS_CSS}1409 renderAnnotation={renderAnnotation}
1358 />1410 unsafeCSSExtra={REPLACE_FILE_COUNTS_CSS}
1359 {reviewOverlay}1411 />
1412 {reviewOverlay}
1413 </div>
1414 {fileTreeOpen ? (
1415 <aside className="flex w-[min(20rem,40%)] min-w-48 shrink-0 border-l border-border/60">
1416 <DiffFileTree
1417 ariaLabel={`Pull request #${detail.number} files`}
1418 entries={fileTreeEntries}
1419 onSelectFile={revealFile}
1420 // The tree lists only what has arrived; a footer says so while the diff is still
1421 // paging, and lets the reader pull the rest in without scrolling for it.
1422 footer={
1423 nextCursor === null ? null : (
1424 <div className="shrink-0 border-t border-border/60 p-2">
1425 <Button
1426 type="button"
1427 size="xs"
1428 variant="outline"
1429 className="w-full"
1430 disabled={diffQuery.isPending}
1431 onClick={
1432 diffQuery.error !== null ? () => diffQuery.refresh() : loadNextSlice
1433 }
1434 >
1435 {diffQuery.error !== null
1436 ? "Retry"
1437 : diffQuery.isPending
1438 ? "Loading more files..."
1439 : "Load more files"}
1440 </Button>
1441 </div>
1442 )
1443 }
1444 />
1445 </aside>
1446 ) : null}
1360 </div>1447 </div>
1361 {unstructured}1448 {unstructured}
1362 </div>1449 </div>
apps/web/src/pierre-tree-theme.ts created+22
...@@ -0,0 +1,22 @@
1import type { CSSProperties } from "react";
2
3/** Shadow-root overrides that make a Pierre file tree read as part of the app chrome. */
4export const PIERRE_TREE_UNSAFE_CSS = `
5 :host {
6 --trees-bg-override: transparent;
7 --trees-selected-bg-override: color-mix(in srgb, currentColor 12%, transparent);
8 --trees-hover-bg-override: color-mix(in srgb, currentColor 7%, transparent);
9 --trees-border-color-override: color-mix(in srgb, currentColor 14%, transparent);
10 --trees-font-family-override: var(--font-sans);
11 --trees-font-size-override: 12px;
12 }
13 button[data-type='item'] { border-radius: 5px; }
14`;
15
16/** Host styles that keep a Pierre tree on the active color scheme and foreground. */
17export function pierreTreeStyle(colorScheme: "light" | "dark"): CSSProperties {
18 return {
19 colorScheme,
20 ["--trees-fg-override" as string]: "var(--contrast-foreground)",
21 };
22}
docs/user/source-control.md+3
...@@ -56,6 +56,9 @@ T3 Code works with the platforms your team already uses:...@@ -56,6 +56,9 @@ T3 Code works with the platforms your team already uses:
56 brought in from the base branch56 brought in from the base branch
57- While working in a thread, open linked reviews in the same compact right-panel tabs without57- While working in a thread, open linked reviews in the same compact right-panel tabs without
58 leaving the conversation58 leaving the conversation
59- Show a file tree next to a review's **Code** tab, or a thread's **Diff** panel, to browse the
60 changed files as folders and jump straight to any of them. The toolbar toggle remembers your
61 choice.
59- Enable **Settings → General → Proactive panels** to open a newly linked review automatically and62- Enable **Settings → General → Proactive panels** to open a newly linked review automatically and
60 switch to the completed turn's diff when agent work finishes63 switch to the completed turn's diff when agent work finishes
61- Open the review directly in your browser with one click64- Open the review directly in your browser with one click