diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.browser.test.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.browser.test.tsx index b06f3d117b9ba..3dceab3fd0166 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.browser.test.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.browser.test.tsx @@ -85,7 +85,12 @@ vi.mock(import("./usePullRequestFilesViewed"), async () => { enabled: viewedPaths !== null, isViewed: (path: string) => viewedPaths?.has(path) === true, isStale: () => false, - setViewed: () => {}, + setViewed: (path: string, viewed: boolean) => { + const next = new Set(mocks.viewedPaths); + if (viewed) next.add(path); + else next.delete(path); + deliverViewedPaths(next); + }, truncated: false, error: null, refresh: () => {}, @@ -163,12 +168,12 @@ const detail: PullRequestDetailView = { }; function setup({ paged }: { paged: boolean }) { - window.localStorage.removeItem("t3code.diffHideTestFiles"); __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS); mocks.paged = paged; mocks.diffCommits = []; mocks.viewedPaths = null; mocks.viewedListeners.clear(); + localStorage.clear(); } const tab = ( @@ -176,12 +181,13 @@ const tab = ( revealRequest: { readonly path: string; readonly nonce: number } | null = null, revealUrl?: string, onRevealSettled?: (url: string) => void, + tabDetail: PullRequestDetailView = detail, ) => ( {}} environmentId={EnvironmentId.make("environment")} - reference={detail} - detail={detail} + reference={tabDetail} + detail={tabDetail} selectedCommitOid={null} onSelectedCommitChange={() => {}} onRefresh={() => {}} @@ -265,7 +271,6 @@ it("pages forward to a file asked for that is not loaded yet, then scrolls to it __setClientSettingsForTests({ ...DEFAULT_CLIENT_SETTINGS, diffFilesCollapsed: true }); using _preferences = { [Symbol.dispose]() { - window.localStorage.removeItem("t3code.diffHideTestFiles"); __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS); }, }; @@ -288,6 +293,80 @@ it("pages forward to a file asked for that is not loaded yet, then scrolls to it await expectFileHeaderShown(screen, container, "late.test.ts"); }); +it("hides viewed files and shows them again", async () => { + setup({ paged: true }); + mocks.viewedPaths = new Set(["early-0.ts"]); + await using screen = await render(tab(0), { container: pagedContainer() }); + await expect.element(screen.getByText("early-0.ts", { exact: true }).first()).toBeVisible(); + const hideViewed = screen.getByRole("button", { name: "Hide viewed files" }); + + await hideViewed.click(); + + await expect.element(screen.getByText("early-0.ts", { exact: true })).not.toBeInTheDocument(); + await expect.element(screen.getByText("early-2.ts", { exact: true }).first()).toBeVisible(); + await hideViewed.click(); + await expect.element(screen.getByText("early-0.ts", { exact: true }).first()).toBeVisible(); +}); + +it("keeps the Hide viewed files toggle while its saved filter is on and nothing is viewed", async () => { + setup({ paged: true }); + mocks.viewedPaths = new Set(); + localStorage.setItem("t3code.pullRequestHideViewedFiles", "true"); + await using screen = await render(tab(0), { container: pagedContainer() }); + await screen.getByRole("button", { name: "Hide viewed files" }).click(); + + await vi.waitFor(() => + expect(localStorage.getItem("t3code.pullRequestHideViewedFiles")).toBe("false"), + ); +}); + +it("marks a Shift-clicked range viewed after the first file of it was hidden", async () => { + setup({ paged: true }); + mocks.viewedPaths = new Set(["early-0.ts"]); + const container = document.createElement("div"); + container.className = "h-[500px] w-[400px]"; + await using screen = await render(tab(0), { container }); + await screen.getByRole("button", { name: "Hide viewed files" }).click(); + await expect.element(screen.getByText("early-10.ts", { exact: true }).first()).toBeVisible(); + + await screen.getByRole("checkbox", { name: "Viewed" }).first().click(); + await expect.element(screen.getByText("early-10.ts", { exact: true })).not.toBeInTheDocument(); + await screen + .getByRole("checkbox", { name: "Viewed" }) + .nth(1) + .click({ modifiers: ["Shift"] }); + + await expect.element(screen.getByText("early-11.ts", { exact: true })).not.toBeInTheDocument(); +}); + +it("keeps conversations reachable when every file is hidden", async () => { + setup({ paged: false }); + mocks.viewedPaths = new Set(["a.ts"]); + const threadDetail: PullRequestDetailView = { + ...detail, + reviewThreads: [ + { + id: "thread", + path: "a.ts", + line: 1, + side: "right", + isResolved: false, + isOutdated: false, + comments: [], + }, + ], + }; + const container = document.createElement("div"); + container.className = "h-[500px] w-[400px]"; + await using screen = await render(tab(0, null, undefined, undefined, threadDetail), { + container, + }); + await screen.getByRole("button", { name: "Hide viewed files" }).click(); + await expect.element(screen.getByText("All files are hidden.")).toBeVisible(); + + await expect.element(screen.getByText("Conversations not on the current diff")).toBeVisible(); +}); + it("never requests the diff of a selected commit the pull request no longer has", async () => { setup({ paged: false }); const container = document.createElement("div"); diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 68525756e2d99..7617fccae65a1 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -24,6 +24,7 @@ import { ChevronDownIcon, ChevronRightIcon, Columns2Icon, + EyeOffIcon, FlaskConicalOffIcon, FolderTreeIcon, InfoIcon, @@ -149,6 +150,7 @@ type ReviewAnnotation = DiffLineAnnotation; const COMMIT_PAGE_SIZE = 10; const PULL_REQUEST_FILE_TREE_STORAGE_KEY = "t3code.pullRequestFileTreeOpen"; +const PULL_REQUEST_HIDE_VIEWED_FILES_STORAGE_KEY = "t3code.pullRequestHideViewedFiles"; interface DiffSlice { readonly source: PullRequestDiffResult; @@ -401,6 +403,11 @@ function PullRequestCodeTab({ Schema.Boolean, ); const [hideTestFiles, setHideTestFiles] = useHideTestFiles(); + const [hideViewedFiles, setHideViewedFiles] = useLocalStorage( + PULL_REQUEST_HIDE_VIEWED_FILES_STORAGE_KEY, + false, + Schema.Boolean, + ); const [selectedLines, setSelectedLines] = useState<{ id: string; range: SelectedLineRange; @@ -603,15 +610,28 @@ function PullRequestCodeTab({ const testFileCount = loadedFiles.filter((file) => isTestChangedFile(resolveFileDiffPath(file)), ).length; - // A requested file stays visible so a jump to a hidden test still lands. - const files = hideTestFiles + // A filter never hides the file being jumped to or one holding unsent review comments. + const isPinned = (path: string) => + path === revealRequest?.path || + path === linkPath || + path === draft?.path || + pendingComments.some((comment) => comment.path === path); + const reviewFiles = hideTestFiles ? loadedFiles.filter((file) => { const path = resolveFileDiffPath(file); - return path === revealRequest?.path || path === linkPath || !isTestChangedFile(path); + return isPinned(path) || !isTestChangedFile(path); }) : loadedFiles; - const hiddenTestFileCount = loadedFiles.length - files.length; - const filePaths = files.map((file) => resolveFileDiffPath(file)); + const hiddenTestFileCount = loadedFiles.length - reviewFiles.length; + const viewedFileCount = reviewFiles.filter((file) => + isFileViewed(resolveFileDiffPath(file)), + ).length; + const files = hideViewedFiles + ? reviewFiles.filter((file) => { + const path = resolveFileDiffPath(file); + return isPinned(path) || !isFileViewed(path); + }) + : reviewFiles; const omittedFileStats = new Map( loadedSlices.flatMap((slice) => slice.omittedFileStats.map((file) => [file.path, file] as const), @@ -805,11 +825,12 @@ function PullRequestCodeTab({ }; const setFileViewed = (fileKey: string, viewed: boolean, extendRange: boolean) => { - const range = getFileViewedRange( - annotatedFiles, - fileKey, - extendRange ? lastViewedFileKey : null, - ); + // Include files hidden as viewed: the Shift-click anchor is usually one of them. + const rangeFiles = reviewFiles.map((file) => ({ + fileKey: buildFileDiffRenderKey(file), + path: resolveFileDiffPath(file), + })); + const range = getFileViewedRange(rangeFiles, fileKey, extendRange ? lastViewedFileKey : null); setLastViewedFileKey(fileKey); for (const file of range) setViewed(file.path, viewed); setFoldChoices((current) => @@ -1190,10 +1211,10 @@ function PullRequestCodeTab({ {hiddenTestFileCount} test {hiddenTestFileCount === 1 ? "file" : "files"} hidden ) : null} - {filesViewed.enabled && files.length > 0 ? ( + {filesViewed.enabled && reviewFiles.length > 0 ? ( - {filePaths.filter(isFileViewed).length} / {files.length} + {viewedFileCount} / {reviewFiles.length} {viewedFilesStore === "environment" ? `viewed in ${APP_BASE_NAME}` : "viewed"} @@ -1423,6 +1444,26 @@ function PullRequestCodeTab({ ) : null} + {viewedFileCount > 0 || hideViewedFiles ? ( + + setHideViewedFiles(Boolean(pressed))} + /> + } + > + + + + {hideViewedFiles ? "Show viewed files" : "Hide viewed files"} + + + ) : null} {fileKeys.length > 0 ? ( - {commit === null ? `This ${term} has no file changes.` : "This commit has no file changes."} -

, - ); - } - const orphanThreads = detail.reviewThreads.filter((thread) => !placedThreadIds.has(thread.id)); const orphanFiles = new Map(); for (const thread of orphanThreads) { @@ -1554,63 +1587,76 @@ function PullRequestCodeTab({ if (existing) existing.push(thread); else orphanFiles.set(thread.path, [thread]); } + const orphanConversations = + orphanFiles.size > 0 ? ( +
+ +

+ + + {nextCursor === null + ? "Conversations not on the current diff" + : "Conversations not on the diff loaded so far"} + + + + {orphanThreads.length} + + + {orphanThreads.length === 1 + ? "1 conversation" + : `${orphanThreads.length} conversations`} + + +

+ +
+ {[...orphanFiles].map(([path, threads]) => ( +
+ + {path}

} + /> + {path} +
+
+ {threads.map((thread) => ( +
+ {thread.line === null ? null : ( +

Line {thread.line}

+ )} + {renderThreadCard(thread)} +
+ ))} +
+
+ ))} +
+
+
+
+ ) : null; + + if (items.length === 0 && nextCursor === null) { + const noFilesMessage = + commit === null ? `This ${term} has no file changes.` : "This commit has no file changes."; + return withToolbar( + <> + {orphanConversations} +

+ {loadedFiles.length > 0 ? "All files are hidden." : noFilesMessage} +

+ , + ); + } return (
{toolbar} - {orphanFiles.size > 0 ? ( -
- -

- - - {nextCursor === null - ? "Conversations not on the current diff" - : "Conversations not on the diff loaded so far"} - - - - {orphanThreads.length} - - - {orphanThreads.length === 1 - ? "1 conversation" - : `${orphanThreads.length} conversations`} - - -

- -
- {[...orphanFiles].map(([path, threads]) => ( -
- - {path}

- } - /> - {path} -
-
- {threads.map((thread) => ( -
- {thread.line === null ? null : ( -

Line {thread.line}

- )} - {renderThreadCard(thread)} -
- ))} -
-
- ))} -
-
-
-
- ) : null} + {orphanConversations}