From c021011f712fb84fc8d3567a19266ac22f83f75a Mon Sep 17 00:00:00 2001 From: Defmon3 <111503233+Defmon3@users.noreply.github.com> Date: Fri, 18 Sep 2026 10:54:26 +0200 Subject: [PATCH 1/2] feat(#6315): add Git History client --- apps/web/src/components/ChatView.tsx | 18 + .../GitHistoryPanel.filters-details.test.tsx | 666 ++++++++++++++ .../GitHistoryPanel.layout.test.tsx | 280 ++++++ .../GitHistoryPanel.paging.test.tsx | 586 +++++++++++++ .../GitHistoryPanel.test-fixture.tsx | 580 +++++++++++++ apps/web/src/components/GitHistoryPanel.tsx | 821 ++++++++++++++++++ apps/web/src/components/RightPanelTabs.tsx | 31 + .../git-history/GitHistoryCommitDetails.tsx | 277 ++++++ .../git-history/GitHistoryCommitDiff.tsx | 142 +++ .../git-history/GitHistoryCommitList.tsx | 356 ++++++++ .../GitHistoryPaneResizeHandle.tsx | 92 ++ .../git-history/GitHistoryRefsPane.test.ts | 81 ++ .../git-history/GitHistoryRefsPane.tsx | 498 +++++++++++ .../git-history/GitHistoryVisualTypes.ts | 9 + .../git-history/gitHistoryClipboard.ts | 11 + .../git-history/useGitHistoryRefs.test.tsx | 445 ++++++++++ .../git-history/useGitHistoryRefs.ts | 225 +++++ apps/web/src/gitHistoryCapability.test.ts | 12 + apps/web/src/gitHistoryCapability.ts | 7 + apps/web/src/hooks/useLocalStorage.ts | 7 +- apps/web/src/lib/gitHistoryGraph.test.ts | 291 +++++++ apps/web/src/lib/gitHistoryGraph.ts | 231 +++++ apps/web/src/lib/gitRefTree.test.ts | 58 ++ apps/web/src/lib/gitRefTree.ts | 88 ++ apps/web/src/rightPanelStore.test.ts | 9 + apps/web/src/rightPanelStore.ts | 4 + apps/web/src/state/environments.ts | 5 + apps/web/src/state/queries.test.tsx | 309 +++++++ apps/web/src/state/queries.ts | 111 ++- apps/web/src/state/query.ts | 2 + apps/web/src/state/snapshotPages.test.tsx | 177 ++++ apps/web/src/state/snapshotPages.ts | 171 ++++ apps/web/src/state/vcs.ts | 3 +- apps/web/src/test/reactHookHarness.ts | 21 +- docs/user/source-control.md | 13 + .../client-runtime/src/state/runtime.test.ts | 151 +++- packages/client-runtime/src/state/runtime.ts | 76 +- packages/client-runtime/src/state/vcs.test.ts | 87 +- packages/client-runtime/src/state/vcs.ts | 40 +- .../src/state/vcsAction.test.ts | 6 +- .../src/state/vcsRefInvalidation.ts | 17 +- 41 files changed, 6984 insertions(+), 30 deletions(-) create mode 100644 apps/web/src/components/GitHistoryPanel.filters-details.test.tsx create mode 100644 apps/web/src/components/GitHistoryPanel.layout.test.tsx create mode 100644 apps/web/src/components/GitHistoryPanel.paging.test.tsx create mode 100644 apps/web/src/components/GitHistoryPanel.test-fixture.tsx create mode 100644 apps/web/src/components/GitHistoryPanel.tsx create mode 100644 apps/web/src/components/git-history/GitHistoryCommitDetails.tsx create mode 100644 apps/web/src/components/git-history/GitHistoryCommitDiff.tsx create mode 100644 apps/web/src/components/git-history/GitHistoryCommitList.tsx create mode 100644 apps/web/src/components/git-history/GitHistoryPaneResizeHandle.tsx create mode 100644 apps/web/src/components/git-history/GitHistoryRefsPane.test.ts create mode 100644 apps/web/src/components/git-history/GitHistoryRefsPane.tsx create mode 100644 apps/web/src/components/git-history/GitHistoryVisualTypes.ts create mode 100644 apps/web/src/components/git-history/gitHistoryClipboard.ts create mode 100644 apps/web/src/components/git-history/useGitHistoryRefs.test.tsx create mode 100644 apps/web/src/components/git-history/useGitHistoryRefs.ts create mode 100644 apps/web/src/gitHistoryCapability.test.ts create mode 100644 apps/web/src/gitHistoryCapability.ts create mode 100644 apps/web/src/lib/gitHistoryGraph.test.ts create mode 100644 apps/web/src/lib/gitHistoryGraph.ts create mode 100644 apps/web/src/lib/gitRefTree.test.ts create mode 100644 apps/web/src/lib/gitRefTree.ts create mode 100644 apps/web/src/state/queries.test.tsx create mode 100644 apps/web/src/state/snapshotPages.test.tsx create mode 100644 apps/web/src/state/snapshotPages.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 4b714dd9b9ef..d815176beb52 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -544,6 +544,7 @@ import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, recallableComposerPrompt, } from "./chat/composerPromptHistory"; +import { hasGitHistoryCapability } from "../gitHistoryCapability"; const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_QUEUED_MESSAGES: QueuedComposerMessage[] = []; @@ -632,6 +633,7 @@ const selectAutoShowFloatingPreview = (settings: { browserAutoShowFloatingPrevie const DevicePanel = lazy(() => import("./device/DevicePanel").then((module) => ({ default: module.DevicePanel })), ); +const GitHistoryPanel = lazy(() => import("./GitHistoryPanel")); const FilePreviewPanel = lazy(() => import("./files/FilePreviewPanel")); const EMPTY_PENDING_FILE_SURFACE_IDS: ReadonlySet = new Set(); const TYPE_TO_FOCUS_EDITABLE_SELECTOR = [ @@ -2629,6 +2631,7 @@ export default function ChatView(props: ChatViewProps) { const issuesCapabilityKnown = serverConfig !== null; const supportsPullRequests = serverConfig?.environment.capabilities.pullRequests === true; const supportsIssues = serverConfig?.environment.capabilities.issues === true; + const supportsGitHistory = hasGitHistoryCapability(serverConfig?.environment.capabilities); const issuesSurfaceCapabilityState = resolveSourceControlSurfaceCapability({ capabilityKnown: issuesCapabilityKnown, supported: supportsIssues, @@ -4586,6 +4589,10 @@ export default function ChatView(props: ChatViewProps) { useRightPanelStore.getState().open(activeThreadRef, "diff"); onDiffPanelOpen?.(); }, [activeThreadRef, isGitRepo, isServerThread, onDiffPanelOpen]); + const addGitHistorySurface = useCallback(() => { + if (!activeThreadRef || !isGitRepo || !supportsGitHistory) return; + useRightPanelStore.getState().open(activeThreadRef, "git-history"); + }, [activeThreadRef, isGitRepo, supportsGitHistory]); const addFilesSurface = useCallback(() => { if (!activeThreadRef || !activeProject) return; useRightPanelStore.getState().open(activeThreadRef, "files"); @@ -9741,6 +9748,13 @@ export default function ChatView(props: ChatViewProps) { workspaceMutationId={workspaceMutationId} /> + ) : renderedRightPanelSurface?.kind === "git-history" && + supportsGitHistory && + isGitRepo && + gitCwd !== null ? ( + + + ) : renderedRightPanelSurface?.kind === "pull-request" && !pullRequestsCapabilityKnown ? ( ) : renderedRightPanelSurface?.kind === "pull-request" && !supportsPullRequests ? ( @@ -10506,6 +10520,7 @@ export default function ChatView(props: ChatViewProps) { onAddBrowserInProfile={createBrowserSurface} onAddTerminal={addTerminalSurface} onAddDiff={addDiffSurface} + onAddGitHistory={addGitHistorySurface} onAddFiles={addFilesSurface} onAddPullRequest={addPullRequestSurface} onAddIssue={addIssueSurface} @@ -10515,6 +10530,7 @@ export default function ChatView(props: ChatViewProps) { browserAvailable={isPreviewSupportedInRuntime()} terminalAvailable={activeProject !== null} diffAvailable={isServerThread && isGitRepo} + gitHistoryAvailable={isGitRepo && supportsGitHistory} filesAvailable={activeProject !== null} pullRequestAvailable={pullRequestSurfaceAvailable} issueAvailable={issueSurfaceAvailable} @@ -10567,6 +10583,7 @@ export default function ChatView(props: ChatViewProps) { onAddBrowserInProfile={createBrowserSurface} onAddTerminal={addTerminalSurface} onAddDiff={addDiffSurface} + onAddGitHistory={addGitHistorySurface} onAddFiles={addFilesSurface} onAddPullRequest={addPullRequestSurface} onAddIssue={addIssueSurface} @@ -10576,6 +10593,7 @@ export default function ChatView(props: ChatViewProps) { browserAvailable={isPreviewSupportedInRuntime()} terminalAvailable={activeProject !== null} diffAvailable={isServerThread && isGitRepo} + gitHistoryAvailable={isGitRepo && supportsGitHistory} filesAvailable={activeProject !== null} pullRequestAvailable={pullRequestSurfaceAvailable} issueAvailable={issueSurfaceAvailable} diff --git a/apps/web/src/components/GitHistoryPanel.filters-details.test.tsx b/apps/web/src/components/GitHistoryPanel.filters-details.test.tsx new file mode 100644 index 000000000000..731f411e36f9 --- /dev/null +++ b/apps/web/src/components/GitHistoryPanel.filters-details.test.tsx @@ -0,0 +1,666 @@ +import type { ReactElement } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { reactHookHarness as hooks } from "../test/reactHookHarness"; +import { visitElements } from "../test/reactElementTree"; + +import { + componentElement, + componentTree, + commit, + environmentId, + flushEffects, + gitRef, + historyList, + historyPageSize, + historyState, + newestMatchingCommitHash, + page, + primaryCommitHash, + renderComponent, + renderPanel, + secondaryCommitHash, + workspacePath, +} from "./GitHistoryPanel.test-fixture"; +import { CommitDiffView } from "./git-history/GitHistoryCommitDiff"; + +describe("GitHistoryPanel filters and details", () => { + it("filters history by commit message", () => { + historyState.pages.set( + undefined, + page([ + commit(primaryCommitHash, "Prepare release"), + commit(secondaryCommitHash, "Fix graph layout"), + ]), + ); + + const panel = renderPanel(); + const filter = visitElements( + panel, + (element) => element.props["aria-label"] === "Filter Git history", + ); + expect(filter).not.toBeNull(); + ( + filter?.props.onChange as + | ((event: { readonly target: { readonly value: string } }) => void) + | undefined + )?.({ + target: { value: "release" }, + }); + + const filtered = historyList(renderPanel()); + expect(filtered.props.data.map((row) => row.commit.subject)).toEqual(["Prepare release"]); + }); + + it("clears the history filter when the target changes and does not restore it", () => { + historyState.pages.set(undefined, page([commit(primaryCommitHash, "Prepare release")])); + const search = visitElements( + renderPanel(), + (element) => element.props["aria-label"] === "Filter Git history", + ); + ( + search?.props.onChange as + | ((event: { readonly target: { readonly value: string } }) => void) + | undefined + )?.({ target: { value: "release" } }); + expect( + visitElements( + renderPanel(), + (element) => element.props["aria-label"] === "Filter Git history", + )?.props.value, + ).toBe("release"); + + historyState.connection = { phase: "connected", generation: 2 }; + renderPanel(); + historyState.connection = { phase: "connected", generation: 1 }; + + expect( + visitElements( + renderPanel(), + (element) => element.props["aria-label"] === "Filter Git history", + )?.props.value, + ).toBe(""); + }); + + it("keeps a history search to the loaded page until the user requests older commits", () => { + historyState.pages.set( + undefined, + page([commit(primaryCommitHash, "Fix graph layout")], { + hasMore: true, + nextCursor: "history-page-2", + }), + ); + historyState.pages.set("history-page-2", page([commit(secondaryCommitHash, "Release notes")])); + + const initialPanel = renderPanel(); + flushEffects(); + const filter = visitElements( + initialPanel, + (element) => element.props["aria-label"] === "Filter Git history", + ); + ( + filter?.props.onChange as + | ((event: { readonly target: { readonly value: string } }) => void) + | undefined + )?.({ target: { value: "release" } }); + renderPanel(); + flushEffects(); + const filteredPanel = renderPanel(); + + expect(historyState.getHistory).toHaveBeenCalledTimes(1); + expect(historyState.getHistory).not.toHaveBeenCalledWith({ + cacheKey: 0, + environmentId, + input: { + cwd: workspacePath, + cursor: "history-page-2", + limit: historyPageSize, + }, + }); + const searchOlder = visitElements( + filteredPanel, + (element) => element.props.children === "Search older commits", + ); + expect(searchOlder).not.toBeNull(); + }); + + it("clears a hash search from the clear button or Escape key", () => { + const matchingCommitHash = "0acf007c21111111111111111111111111111111"; + historyState.pages.set( + undefined, + page([ + commit(matchingCommitHash, "Matching commit"), + commit(secondaryCommitHash, "Other commit"), + ]), + ); + + const search = visitElements( + renderPanel(), + (element) => element.props["aria-label"] === "Filter Git history", + ); + const changeSearch = search?.props.onChange as + | ((event: { readonly target: { readonly value: string } }) => void) + | undefined; + changeSearch?.({ target: { value: "0acf007c2" } }); + expect(historyList(renderPanel()).props.data).toHaveLength(1); + + const clear = visitElements( + renderPanel(), + (element) => element.props["aria-label"] === "Clear Git history search", + ); + (clear?.props.onClick as (() => void) | undefined)?.(); + expect(historyList(renderPanel()).props.data).toHaveLength(2); + + changeSearch?.({ target: { value: "0acf007c2" } }); + const filteredSearch = visitElements( + renderPanel(), + (element) => element.props["aria-label"] === "Filter Git history", + ); + const preventDefault = vi.fn(); + ( + filteredSearch?.props.onKeyDown as + | ((event: { readonly key: string; readonly preventDefault: () => void }) => void) + | undefined + )?.({ key: "Escape", preventDefault }); + expect(preventDefault).toHaveBeenCalledOnce(); + expect(historyList(renderPanel()).props.data).toHaveLength(2); + }); + + it("keeps a trailing-space search visible, literal, and clearable", () => { + historyState.pages.set(undefined, page([commit(primaryCommitHash, "Add Git history panel")])); + + const search = visitElements( + renderPanel(), + (element) => element.props["aria-label"] === "Filter Git history", + ); + const changeSearch = search?.props.onChange as + | ((event: { readonly target: { readonly value: string } }) => void) + | undefined; + const historyQueryCallCount = historyState.getHistory.mock.calls.length; + changeSearch?.({ target: { value: "fix " } }); + + const filteredPanel = renderPanel(); + const filteredSearch = visitElements( + filteredPanel, + (element) => element.props["aria-label"] === "Filter Git history", + ); + expect(filteredSearch).not.toBeNull(); + expect(filteredSearch?.props.value).toBe("fix "); + expect(historyState.getHistory).toHaveBeenCalledTimes(historyQueryCallCount); + const clear = visitElements( + filteredPanel, + (element) => element.props["aria-label"] === "Clear Git history search", + ); + expect(clear).not.toBeNull(); + (clear?.props.onClick as (() => void) | undefined)?.(); + expect(historyList(renderPanel()).props.data).toHaveLength(1); + }); + + it("rebuilds the graph from the text-filtered commits", () => { + historyState.pages.set( + undefined, + page([ + { + ...commit(newestMatchingCommitHash, "Match newest"), + parentHashes: ["b"], + }, + { + ...commit(secondaryCommitHash, "Hidden parent"), + parentHashes: ["a"], + }, + commit(primaryCommitHash, "Match oldest"), + ]), + ); + + const filter = visitElements( + renderPanel(), + (element) => element.props["aria-label"] === "Filter Git history", + ); + ( + filter?.props.onChange as + | ((event: { readonly target: { readonly value: string } }) => void) + | undefined + )?.({ target: { value: "match" } }); + + const filtered = historyList(renderPanel()); + expect(filtered.props.data.map((row) => row.commit.hash)).toEqual([ + newestMatchingCommitHash, + primaryCommitHash, + ]); + expect(filtered.props.data.flatMap((row) => row.graph.edges)).not.toContainEqual( + expect.objectContaining({ kind: "parent" }), + ); + }); + + it("deduplicates overlapping pages and keeps Load more in the scrolling column footer", () => { + const duplicate = commit(primaryCommitHash, "Initial commit"); + historyState.pages.set( + undefined, + page([duplicate], { hasMore: true, nextCursor: "next-page" }), + ); + historyState.pages.set( + "next-page", + page([duplicate, commit(secondaryCommitHash, "Second page commit")]), + ); + + const panel = renderPanel(); + const scrollingColumn = visitElements( + panel, + (element) => element.props.className === "flex h-full min-w-0 flex-col", + ); + expect(scrollingColumn).not.toBeNull(); + const footer = visitElements( + scrollingColumn, + (element) => + element.props.className === "flex shrink-0 justify-center border-t border-border/50 p-2", + ); + expect(footer).not.toBeNull(); + const loadMore = visitElements(footer, (element) => element.props.children === "Load more"); + expect(loadMore).not.toBeNull(); + (loadMore?.props.onClick as (() => void) | undefined)?.(); + + const expanded = historyList(renderPanel()); + expect(expanded.props.data.map((row) => row.commit.hash)).toEqual([ + primaryCommitHash, + secondaryCommitHash, + ]); + expect(historyState.getHistory).toHaveBeenLastCalledWith({ + cacheKey: 0, + environmentId, + input: { + cwd: workspacePath, + cursor: "next-page", + limit: historyPageSize, + }, + }); + }); + + it("filters, expands, and selects nested branches while showing the branch commit count", () => { + historyState.pages.set(undefined, page([commit(primaryCommitHash, "Initial")])); + historyState.refs = [ + gitRef("feature/api"), + gitRef("feature/ui", { + aheadCount: 10, + current: true, + upstreamName: "origin/feature/ui", + }), + gitRef("development", { + aheadCount: 3, + behindCount: 2, + upstreamName: "origin/development", + }), + gitRef("main"), + ]; + historyState.status = { aheadCount: 3, behindCount: 2 }; + + const initial = renderPanel(); + expect(historyState.getHistory).toHaveBeenLastCalledWith({ + cacheKey: 0, + environmentId, + input: { + cwd: workspacePath, + limit: historyPageSize, + revision: "refs/heads/feature/ui", + }, + }); + const initialPane = componentTree(initial, "GitRefsPane"); + const initialList = componentElement(initialPane, "LegendList"); + expect(initialList.props.recycleItems).toBe(true); + const initialRows = initialList.props.data as Array<{ + readonly key: string; + readonly open?: boolean; + }>; + const featureFolder = initialRows.find((row) => row.key === "local:feature"); + expect(featureFolder?.open).toBe(false); + + const renderInitialRow = initialList.props.renderItem as (props: { + readonly item: (typeof initialRows)[number]; + }) => ReactElement>; + const featureFolderRow = renderInitialRow({ item: featureFolder! }); + const featureFolderButton = visitElements( + featureFolderRow, + (element) => element.props["aria-expanded"] === false, + ); + (featureFolderButton?.props.onClick as (() => void) | undefined)?.(); + const expanded = renderPanel(); + const expandedPane = componentTree(expanded, "GitRefsPane"); + const expandedList = componentElement(expandedPane, "LegendList"); + const expandedRows = expandedList.props.data as Array<{ readonly key: string }>; + expect(expandedRows.map((row) => row.key)).toContain("refs/heads/feature/ui"); + const renderExpandedRow = expandedList.props.renderItem as (props: { + readonly item: (typeof expandedRows)[number]; + }) => ReactElement>; + const uiBranch = renderExpandedRow({ + item: expandedRows.find((row) => row.key === "refs/heads/feature/ui")!, + }); + expect( + visitElements( + uiBranch, + (element) => element.props.children === "10 commits ahead of origin/feature/ui", + ), + ).not.toBeNull(); + const developmentBranch = renderExpandedRow({ + item: expandedRows.find((row) => row.key === "refs/heads/development")!, + }); + expect( + visitElements( + developmentBranch, + (element) => element.props.children === "3 commits ahead of origin/development", + ), + ).not.toBeNull(); + const developmentButton = visitElements( + developmentBranch, + (element) => + element.props["aria-label"] === + "development. 3 commits ahead of upstream origin/development. 2 commits behind upstream origin/development.", + ); + expect(developmentButton?.props["aria-label"]).toBe( + "development. 3 commits ahead of upstream origin/development. 2 commits behind upstream origin/development.", + ); + expect( + visitElements( + developmentBranch, + (element) => element.props.children === "2 commits behind origin/development", + ), + ).not.toBeNull(); + const uiBranchButton = visitElements( + uiBranch, + (element) => + element.props["aria-label"] === + "feature/ui. 10 commits ahead of upstream origin/feature/ui.", + ); + (uiBranchButton?.props.onClick as (() => void) | undefined)?.(); + + renderPanel(); + expect(historyState.getHistory).toHaveBeenLastCalledWith({ + cacheKey: 0, + environmentId, + input: { + cwd: workspacePath, + limit: historyPageSize, + revision: "refs/heads/feature/ui", + }, + }); + + const filter = visitElements( + expandedPane, + (element) => element.props["aria-label"] === "Filter branches and tags", + ); + ( + filter?.props.onChange as + | ((event: { readonly target: { readonly value: string } }) => void) + | undefined + )?.({ + target: { value: "api" }, + }); + const filtered = renderPanel(); + const filteredPane = componentTree(filtered, "GitRefsPane"); + const filteredList = componentElement(filteredPane, "LegendList"); + const filteredRows = filteredList.props.data as Array<{ readonly key: string }>; + expect(filteredRows.map((row) => row.key)).toContain("refs/heads/feature/api"); + expect(filteredRows.map((row) => row.key)).not.toContain("refs/heads/feature/ui"); + }); + + it("lists and selects tags from the refs snapshot even when history has no tag decorations", () => { + historyState.pages.set(undefined, page([commit(primaryCommitHash, "Initial")])); + historyState.tags = [gitRef("v1.2.3", { kind: "tag" })]; + + const initial = renderPanel(); + const initialPane = componentTree(initial, "GitRefsPane"); + const initialList = componentElement(initialPane, "LegendList"); + const initialRows = initialList.props.data as Array<{ readonly key: string }>; + const renderInitialRow = initialList.props.renderItem as (props: { + readonly item: (typeof initialRows)[number]; + }) => ReactElement>; + const tagsSectionRow = renderInitialRow({ + item: initialRows.find((row) => row.key === "section:tags")!, + }); + const tagsSectionButton = visitElements( + tagsSectionRow, + (element) => element.props["aria-expanded"] === false, + ); + (tagsSectionButton?.props.onClick as (() => void) | undefined)?.(); + + const expanded = renderPanel(); + const expandedPane = componentTree(expanded, "GitRefsPane"); + const expandedList = componentElement(expandedPane, "LegendList"); + const expandedRows = expandedList.props.data as Array<{ readonly key: string }>; + expect(expandedRows.map((row) => row.key)).toContain("refs/tags/v1.2.3"); + const renderExpandedRow = expandedList.props.renderItem as (props: { + readonly item: (typeof expandedRows)[number]; + }) => ReactElement>; + const tagRow = renderExpandedRow({ + item: expandedRows.find((row) => row.key === "refs/tags/v1.2.3")!, + }); + const tagButton = visitElements(tagRow, (element) => element.props["aria-label"] === "v1.2.3"); + (tagButton?.props.onClick as (() => void) | undefined)?.(); + + renderPanel(); + expect(historyState.getHistory).toHaveBeenLastCalledWith({ + cacheKey: 0, + environmentId, + input: { + cwd: workspacePath, + limit: historyPageSize, + revision: "refs/tags/v1.2.3", + }, + }); + }); + + it("opens the full commit diff from selected commit details", () => { + const historyCommit = commit(primaryCommitHash, "Add panel"); + historyState.pages.set(undefined, page([historyCommit])); + historyState.commitDetails = { ...historyCommit, body: "" }; + + const list = historyList(renderPanel()); + const historyRow = renderComponent(list.props.renderItem({ item: list.props.data[0]! })); + const selectCommit = visitElements( + historyRow, + (element) => element.props["data-commit-hash"] === historyCommit.hash, + ); + (selectCommit?.props.onClick as (() => void) | undefined)?.(); + + const detailsPane = componentTree(renderPanel(), "CommitDetailsPane"); + const showDiff = visitElements( + detailsPane, + (element) => + typeof element.props.onClick === "function" && + JSON.stringify(element.props.children).includes("View all changes"), + ); + expect(showDiff).not.toBeNull(); + (showDiff?.props.onClick as (() => void) | undefined)?.(); + + renderPanel(); + expect(historyState.getCommitDiff).toHaveBeenLastCalledWith({ + environmentId, + input: { cwd: workspacePath, hash: historyCommit.hash }, + }); + }); + + it("shows the short commit hash in every history row", () => { + const historyCommit = commit(primaryCommitHash, "Add panel"); + historyState.pages.set(undefined, page([historyCommit])); + + const list = historyList(renderPanel()); + const historyRow = renderComponent(list.props.renderItem({ item: list.props.data[0]! })); + const shortHash = visitElements( + historyRow, + (element) => element.props.children === historyCommit.hash.slice(0, 8), + ); + const shortHashTooltip = visitElements( + historyRow, + (element) => element.props.children === `Copy full commit hash ${historyCommit.hash}`, + ); + + expect(shortHash).not.toBeNull(); + expect(shortHash?.props["aria-label"]).toBe(`Copy commit hash ${historyCommit.hash}`); + expect(shortHashTooltip).not.toBeNull(); + }); + + it("shows an error toast when copying a history hash is rejected", () => { + const historyCommit = commit(primaryCommitHash, "Add panel"); + historyState.pages.set(undefined, page([historyCommit])); + + const list = historyList(renderPanel()); + const historyRow = renderComponent(list.props.renderItem({ item: list.props.data[0]! })); + const copyHash = visitElements( + historyRow, + (element) => element.props["aria-label"] === `Copy commit hash ${historyCommit.hash}`, + ); + (copyHash?.props.onClick as (() => void) | undefined)?.(); + + expect(historyState.toastAdd).toHaveBeenCalledWith({ + type: "error", + title: "Could not copy commit hash", + description: "Clipboard permission was denied.", + }); + }); + + it("gives every selectable commit its author, date, and parent topology", () => { + const historyCommit = { + ...commit(primaryCommitHash, "Merge release", "Grace Hopper"), + parentHashes: ["parent-one", "parent-two"], + }; + historyState.pages.set(undefined, page([historyCommit])); + + const list = historyList(renderPanel()); + const historyRow = renderComponent(list.props.renderItem({ item: list.props.data[0]! })); + const selectableRow = visitElements( + historyRow, + (element) => element.props["data-commit-hash"] === historyCommit.hash, + ); + + expect(selectableRow?.props["aria-label"]).toContain("Author Grace Hopper"); + expect(selectableRow?.props["aria-label"]).toContain("2-parent merge commit"); + }); + + it("refreshes history and every ref namespace", () => { + historyState.pages.set(undefined, page([commit(primaryCommitHash, "Initial")])); + + const refresh = visitElements( + renderPanel(), + (element) => element.props["aria-label"] === "Refresh Git history", + ); + (refresh?.props.onClick as (() => void) | undefined)?.(); + + expect(historyState.refreshRefs).toHaveBeenCalledOnce(); + expect(historyState.refreshRemoteRefs).toHaveBeenCalledOnce(); + expect(historyState.refreshTags).toHaveBeenCalledOnce(); + }); + + it("opens a changed file diff from selected commit details", () => { + const historyCommit = commit(primaryCommitHash, "Add panel"); + historyState.pages.set(undefined, page([historyCommit])); + historyState.commitDetails = { + ...historyCommit, + body: "", + }; + historyState.diff = { + diff: "diff --git a/src/panel.tsx b/src/panel.tsx\n+added line\n", + isRepo: true, + truncated: false, + }; + + const initial = renderPanel(); + const list = historyList(initial); + const historyRow = renderComponent(list.props.renderItem({ item: list.props.data[0]! })); + const selectCommit = visitElements( + historyRow, + (element) => element.props["data-commit-hash"] === historyCommit.hash, + ); + (selectCommit?.props.onClick as (() => void) | undefined)?.(); + + const details = renderPanel(); + const detailsPane = componentTree(details, "CommitDetailsPane"); + const fileTree = visitElements( + detailsPane, + (element) => typeof element.type === "function" && element.type.name === "CommitFilesTree", + ); + expect(fileTree).not.toBeNull(); + expect(renderComponent(fileTree!).props.recycleItems).toBe(true); + (fileTree?.props.onShowDiff as ((path: string) => void) | undefined)?.("src/panel.tsx"); + + const diff = renderPanel(); + expect(historyState.getCommitDetails).toHaveBeenLastCalledWith({ + cacheKey: 0, + environmentId, + input: { cwd: workspacePath, hash: historyCommit.hash }, + }); + expect(historyState.getCommitDiff).toHaveBeenLastCalledWith({ + environmentId, + input: { + cwd: workspacePath, + hash: historyCommit.hash, + filePath: "src/panel.tsx", + }, + }); + const diffView = visitElements( + diff, + (element) => typeof element.type === "function" && element.type.name === "CommitDiffView", + ); + expect(diffView?.props).toMatchObject({ hash: historyCommit.hash, filePath: "src/panel.tsx" }); + }); + + it("lets the diff load changed files beyond the first page", () => { + const historyCommit = commit(primaryCommitHash, "Add panel"); + historyState.pages.set(undefined, page([historyCommit])); + historyState.commitDetails = { ...historyCommit, body: "" }; + historyState.commitFiles = { + files: [{ status: "A", path: "first.ts" }], + isRepo: true, + nextCursor: "files-page-2", + hasMore: true, + capped: false, + }; + + const list = historyList(renderPanel()); + const historyRow = renderComponent(list.props.renderItem({ item: list.props.data[0]! })); + const selectCommit = visitElements( + historyRow, + (element) => element.props["data-commit-hash"] === historyCommit.hash, + ); + (selectCommit?.props.onClick as (() => void) | undefined)?.(); + renderPanel(); + flushEffects(); + const detailsPane = componentElement(renderPanel(), "CommitDetailsPane"); + (detailsPane.props.onShowDiff as ((hash: string, filePath?: string) => void) | undefined)?.( + historyCommit.hash, + ); + + const diffView = componentElement(renderPanel(), "CommitDiffView"); + expect(diffView.props).toMatchObject({ filesHasMore: true, filesLoading: false }); + const diff = renderComponent(diffView); + const loadMore = visitElements(diff, (element) => element.props.children === "Load more files"); + + expect(loadMore).not.toBeNull(); + expect(loadMore?.props.onClick).toBe(diffView.props.onLoadMoreFiles); + }); + + it("lets the diff retry a failed changed-file continuation", () => { + const retryFiles = vi.fn(); + hooks.beginRender(); + const diff = CommitDiffView({ + hash: primaryCommitHash, + files: [{ status: "A", path: "first.ts" }], + filesError: true, + filesHasMore: true, + filesLoading: false, + diff: null, + truncated: false, + isPending: false, + error: null, + onBack: vi.fn(), + onSelectFile: vi.fn(), + onRetry: vi.fn(), + onLoadMoreFiles: vi.fn(), + onRetryFiles: retryFiles, + }); + const retry = visitElements( + diff, + (element) => element.props.children === "Retry loading files", + ); + + expect(retry).not.toBeNull(); + expect(retry?.props.onClick).toBe(retryFiles); + }); +}); diff --git a/apps/web/src/components/GitHistoryPanel.layout.test.tsx b/apps/web/src/components/GitHistoryPanel.layout.test.tsx new file mode 100644 index 000000000000..ad9b99995a5f --- /dev/null +++ b/apps/web/src/components/GitHistoryPanel.layout.test.tsx @@ -0,0 +1,280 @@ +import type { ReactElement } from "react"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { reactHookHarness as hooks } from "../test/reactHookHarness"; +import { visitElements } from "../test/reactElementTree"; + +import { + componentElement, + commit, + effectQueue, + flushEffects, + historyState, + page, + primaryCommitHash, + renderComponent, + renderPanel, + stubResizeObserver, +} from "./GitHistoryPanel.test-fixture"; +import { isWideHistoryLayout } from "./GitHistoryPanel"; +import { PaneResizeHandle } from "./git-history/GitHistoryPaneResizeHandle"; + +describe("GitHistoryPanel layout", () => { + it("keeps the desktop refs and details workflow available at ordinary desktop widths", () => { + expect(isWideHistoryLayout(1119)).toBe(false); + expect(isWideHistoryLayout(1120)).toBe(true); + }); + + it("coalesces pane pointer moves per frame and flushes the final move on pointer up", () => { + const onMove = vi.fn(); + const frames = new Map(); + const cancelAnimationFrame = vi.fn((id: number) => frames.delete(id)); + let nextFrameId = 0; + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + const frameId = ++nextFrameId; + frames.set(frameId, callback); + return frameId; + }); + vi.stubGlobal("cancelAnimationFrame", cancelAnimationFrame); + hooks.beginRender(); + const handle = PaneResizeHandle({ + label: "Resize branches pane", + value: 320, + min: 240, + max: 480, + onMove, + onReset: vi.fn(), + }) as ReactElement>; + const target = { releasePointerCapture: vi.fn(), setPointerCapture: vi.fn() }; + const onPointerDown = handle.props.onPointerDown as (event: { + readonly clientX: number; + readonly currentTarget: typeof target; + readonly pointerId: number; + }) => void; + const onPointerMove = handle.props.onPointerMove as (event: { + readonly clientX: number; + readonly pointerId: number; + }) => void; + const onPointerUp = handle.props.onPointerUp as (event: { + readonly clientX: number; + readonly currentTarget: typeof target; + readonly pointerId: number; + }) => void; + + onPointerDown({ clientX: 100, currentTarget: target, pointerId: 1 }); + onPointerMove({ clientX: 104, pointerId: 1 }); + onPointerMove({ clientX: 110, pointerId: 1 }); + + expect(onMove).not.toHaveBeenCalled(); + expect(frames).toHaveLength(1); + const firstFrame = frames.get(1); + expect(firstFrame).toBeDefined(); + frames.delete(1); + firstFrame?.(0); + expect(onMove).toHaveBeenCalledOnce(); + expect(onMove).toHaveBeenLastCalledWith(10); + + onPointerMove({ clientX: 114, pointerId: 1 }); + onPointerUp({ clientX: 120, currentTarget: target, pointerId: 1 }); + + expect(cancelAnimationFrame).toHaveBeenCalledWith(2); + expect(onMove).toHaveBeenCalledTimes(2); + expect(onMove).toHaveBeenLastCalledWith(10); + expect(frames).toHaveLength(0); + }); + + it("flushes pending pane movement once when pointer capture is lost before its frame", () => { + const onMove = vi.fn(); + const frames = new Map(); + const cancelAnimationFrame = vi.fn((id: number) => frames.delete(id)); + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + frames.set(1, callback); + return 1; + }); + vi.stubGlobal("cancelAnimationFrame", cancelAnimationFrame); + hooks.beginRender(); + const handle = PaneResizeHandle({ + label: "Resize branches pane", + value: 320, + min: 240, + max: 480, + onMove, + onReset: vi.fn(), + }) as ReactElement>; + const target = { setPointerCapture: vi.fn() }; + const onPointerDown = handle.props.onPointerDown as (event: { + readonly clientX: number; + readonly currentTarget: typeof target; + readonly pointerId: number; + }) => void; + const onPointerMove = handle.props.onPointerMove as (event: { + readonly clientX: number; + readonly pointerId: number; + }) => void; + const onLostPointerCapture = handle.props.onLostPointerCapture as (event: { + readonly pointerId: number; + }) => void; + + onPointerDown({ clientX: 100, currentTarget: target, pointerId: 1 }); + onPointerMove({ clientX: 110, pointerId: 1 }); + const pendingFrame = frames.get(1); + onLostPointerCapture({ pointerId: 1 }); + pendingFrame?.(0); + + expect(cancelAnimationFrame).toHaveBeenCalledWith(1); + expect(onMove).toHaveBeenCalledTimes(1); + expect(onMove).toHaveBeenLastCalledWith(10); + }); + + it("does not rerender history children for repeated wide widths without pane clamping", () => { + const notify = stubResizeObserver(1400); + historyState.pages.set(undefined, page([commit(primaryCommitHash, "Initial")])); + + const initial = renderPanel(); + (initial.props.ref as { current: object | null }).current = {}; + flushEffects(); + effectQueue.stateUpdates = 0; + + notify(1399); + notify(1398); + notify(1397); + + expect(effectQueue.stateUpdates).toBe(0); + }); + + it("constrains both side panes when widening branches at the minimum wide layout", () => { + stubResizeObserver(1120); + historyState.pages.set(undefined, page([commit(primaryCommitHash, "Initial")])); + + const initial = renderPanel(); + (initial.props.ref as { current: object | null }).current = {}; + flushEffects(); + const branchHandle = visitElements( + renderPanel(), + (element) => + typeof element.type === "function" && + element.type.name === "PaneResizeHandle" && + element.props.label === "Resize branches pane", + ); + (branchHandle?.props.onMove as ((delta: number) => void) | undefined)?.(224); + + const constrained = renderPanel(); + const refsPane = componentElement(constrained, "GitRefsPane"); + const detailsPane = componentElement(constrained, "CommitDetailsPane"); + + expect((refsPane.props.style as { width: number }).width).toBe(480); + expect((detailsPane.props.style as { width: number }).width).toBe(304); + }); + + it("constrains commit details when resetting at the minimum wide layout", () => { + stubResizeObserver(1120); + historyState.pages.set(undefined, page([commit(primaryCommitHash, "Initial")])); + + const initial = renderPanel(); + (initial.props.ref as { current: object | null }).current = {}; + flushEffects(); + const branchHandle = visitElements( + renderPanel(), + (element) => + typeof element.type === "function" && + element.type.name === "PaneResizeHandle" && + element.props.label === "Resize branches pane", + ); + (branchHandle?.props.onMove as ((delta: number) => void) | undefined)?.(224); + const detailsHandle = visitElements( + renderPanel(), + (element) => + typeof element.type === "function" && + element.type.name === "PaneResizeHandle" && + element.props.label === "Resize commit details pane", + ); + (detailsHandle?.props.onReset as (() => void) | undefined)?.(); + + const constrained = renderPanel(); + const refsPane = componentElement(constrained, "GitRefsPane"); + const detailsPane = componentElement(constrained, "CommitDetailsPane"); + + expect((refsPane.props.style as { width: number }).width).toBe(480); + expect((detailsPane.props.style as { width: number }).width).toBe(304); + }); + + it("clamps expanded side panes when a wide history panel shrinks", () => { + const notify = stubResizeObserver(1400); + historyState.pages.set(undefined, page([commit(primaryCommitHash, "Initial")])); + + const initial = renderPanel(); + (initial.props.ref as { current: object | null }).current = {}; + flushEffects(); + const expanded = renderPanel(); + const branchHandle = visitElements( + expanded, + (element) => + typeof element.type === "function" && + element.type.name === "PaneResizeHandle" && + element.props.label === "Resize branches pane", + ); + const detailsHandle = visitElements( + expanded, + (element) => + typeof element.type === "function" && + element.type.name === "PaneResizeHandle" && + element.props.label === "Resize commit details pane", + ); + expect(branchHandle).not.toBeNull(); + expect(detailsHandle).not.toBeNull(); + expect(renderComponent(branchHandle!).props.className).not.toContain("hidden"); + (branchHandle?.props.onMove as ((delta: number) => void) | undefined)?.(224); + (detailsHandle?.props.onMove as ((delta: number) => void) | undefined)?.(-336); + renderPanel(); + + notify(1120); + renderPanel(); + flushEffects(); + const shrunken = renderPanel(); + const refsPane = componentElement(shrunken, "GitRefsPane"); + const detailsPane = componentElement(shrunken, "CommitDetailsPane"); + const refsWidth = (refsPane.props.style as { width: number }).width; + const detailsWidth = (detailsPane.props.style as { width: number }).width; + + expect(refsWidth + detailsWidth).toBeLessThanOrEqual(784); + }); + + it("preserves wide pane widths through a narrow layout transition", () => { + const notify = stubResizeObserver(1600); + historyState.pages.set(undefined, page([commit(primaryCommitHash, "Initial")])); + + const initial = renderPanel(); + (initial.props.ref as { current: object | null }).current = {}; + flushEffects(); + const expanded = renderPanel(); + const branchHandle = visitElements( + expanded, + (element) => + typeof element.type === "function" && + element.type.name === "PaneResizeHandle" && + element.props.label === "Resize branches pane", + ); + const detailsHandle = visitElements( + expanded, + (element) => + typeof element.type === "function" && + element.type.name === "PaneResizeHandle" && + element.props.label === "Resize commit details pane", + ); + (branchHandle?.props.onMove as ((delta: number) => void) | undefined)?.(224); + (detailsHandle?.props.onMove as ((delta: number) => void) | undefined)?.(-336); + + notify(1119); + renderPanel(); + flushEffects(); + notify(1600); + renderPanel(); + flushEffects(); + const restored = renderPanel(); + const refsPane = componentElement(restored, "GitRefsPane"); + const detailsPane = componentElement(restored, "CommitDetailsPane"); + + expect((refsPane.props.style as { width: number }).width).toBe(480); + expect((detailsPane.props.style as { width: number }).width).toBe(720); + }); +}); diff --git a/apps/web/src/components/GitHistoryPanel.paging.test.tsx b/apps/web/src/components/GitHistoryPanel.paging.test.tsx new file mode 100644 index 000000000000..21419e397898 --- /dev/null +++ b/apps/web/src/components/GitHistoryPanel.paging.test.tsx @@ -0,0 +1,586 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { visitElements } from "../test/reactElementTree"; + +import { + componentElement, + componentTree, + commit, + environmentId, + expiredHistoryPage, + expiredSnapshotCause, + flushEffects, + fontState, + gitRef, + historyList, + historyPageSize, + historyState, + loadMoreHistory, + page, + primaryCommitHash, + renderComponent, + renderPanel, + secondaryCommitHash, + stubResizeObserver, + workspacePath, +} from "./GitHistoryPanel.test-fixture"; + +describe("GitHistoryPanel paging", () => { + it("does not start an all-refs history request while the current ref is unresolved", () => { + historyState.refsResolved = false; + + const panel = renderPanel(); + + expect(historyState.getHistory).not.toHaveBeenCalled(); + expect( + visitElements(panel, (element) => element.props.children === "Loading history…"), + ).not.toBeNull(); + }); + + it("shows an initial ref failure with a reachable retry", () => { + historyState.refsResolved = false; + historyState.refsError = "Could not load refs."; + + const panel = renderPanel(); + const refsPane = componentElement(panel, "GitRefsPane"); + + expect(historyState.getHistory).not.toHaveBeenCalled(); + expect(refsPane.props.refPaginationError).toBe("Could not load refs."); + expect( + visitElements(panel, (element) => element.props.children === "Could not load refs."), + ).not.toBeNull(); + const retry = visitElements(panel, (element) => element.props.children === "Retry refs"); + expect(retry).not.toBeNull(); + (retry!.props.onClick as () => void)(); + expect(historyState.retryRefs).toHaveBeenCalledOnce(); + }); + + it("starts current branch history after an initial ref failure recovers", () => { + historyState.refsResolved = false; + historyState.refsError = "Could not load refs."; + + renderPanel(); + expect(historyState.getHistory).not.toHaveBeenCalled(); + + historyState.refsError = null; + historyState.refsResolved = true; + historyState.refs = [gitRef("main", { current: true })]; + + renderPanel(); + + expect(historyState.getHistory).toHaveBeenCalledWith({ + cacheKey: 0, + environmentId, + input: { cwd: workspacePath, limit: historyPageSize, revision: "refs/heads/main" }, + }); + }); + + it("keeps the current branch history visible when a remote refs request fails", () => { + historyState.refs = [gitRef("main", { current: true })]; + historyState.remoteRefsError = "Could not load remote refs."; + + renderPanel(); + + expect(historyState.getHistory).toHaveBeenCalledWith({ + cacheKey: 0, + environmentId, + input: { cwd: workspacePath, limit: historyPageSize, revision: "refs/heads/main" }, + }); + }); + + it("keeps the current branch history visible when a tag refs request fails", () => { + historyState.refs = [gitRef("main", { current: true })]; + historyState.tagsError = "Could not load tags."; + + renderPanel(); + + expect(historyState.getHistory).toHaveBeenCalledWith({ + cacheKey: 0, + environmentId, + input: { cwd: workspacePath, limit: historyPageSize, revision: "refs/heads/main" }, + }); + }); + + it("waits for a capped history snapshot to exhaust before showing its notice", () => { + historyState.pages.set( + undefined, + page([commit(primaryCommitHash, "Initial")], { + capped: true, + hasMore: true, + nextCursor: "next", + }), + ); + + let panel = renderPanel(); + + expect( + visitElements( + panel, + (element) => element.props.children === "History results were capped by the server.", + ), + ).toBeNull(); + + historyState.pages.set("next", page([commit(secondaryCommitHash, "Last")], { capped: true })); + loadMoreHistory(panel); + panel = renderPanel(); + + expect( + visitElements( + panel, + (element) => element.props.children === "History results were capped by the server.", + ), + ).not.toBeNull(); + }); + + it("restarts history after the environment connection generation changes", () => { + historyState.pages.set(undefined, page([commit(primaryCommitHash, "Initial")])); + + renderPanel(); + historyState.getHistory.mockClear(); + historyState.connection = { phase: "connected", generation: 2 }; + + renderPanel(); + + expect(historyState.getHistory).toHaveBeenCalledWith({ + cacheKey: 0, + environmentId, + input: { cwd: workspacePath, limit: historyPageSize }, + }); + }); + + it("keeps the narrow branches sheet open when the history target rekeys", () => { + stubResizeObserver(539); + historyState.pages.set(undefined, page([commit(primaryCommitHash, "Initial")])); + + const initial = renderPanel(); + (initial.props.ref as { current: object | null }).current = {}; + flushEffects(); + const branches = visitElements( + renderPanel(), + (element) => element.props["aria-controls"] === "git-history-refs-panel", + ); + (branches?.props.onClick as (() => void) | undefined)?.(); + + expect( + visitElements( + renderPanel(), + (element) => element.props["aria-controls"] === "git-history-refs-panel", + )?.props["aria-expanded"], + ).toBe(true); + + historyState.connection = { phase: "connected", generation: 2 }; + renderPanel(); + flushEffects(); + + expect( + visitElements( + renderPanel(), + (element) => element.props["aria-controls"] === "git-history-refs-panel", + )?.props["aria-expanded"], + ).toBe(true); + }); + + it("closes the narrow details sheet when the history target rekeys", () => { + stubResizeObserver(539); + const historyCommit = commit(primaryCommitHash, "Initial"); + historyState.pages.set(undefined, page([historyCommit])); + + const initial = renderPanel(); + (initial.props.ref as { current: object | null }).current = {}; + flushEffects(); + const list = historyList(renderPanel()); + const historyRow = renderComponent(list.props.renderItem({ item: list.props.data[0]! })); + const selectCommit = visitElements( + historyRow, + (element) => element.props["data-commit-hash"] === historyCommit.hash, + ); + (selectCommit?.props.onClick as (() => void) | undefined)?.(); + const details = visitElements( + renderPanel(), + (element) => element.props["aria-controls"] === "git-history-details-panel", + ); + (details?.props.onClick as (() => void) | undefined)?.(); + + expect( + visitElements( + renderPanel(), + (element) => element.props["aria-controls"] === "git-history-details-panel", + )?.props["aria-expanded"], + ).toBe(true); + + historyState.connection = { phase: "connected", generation: 2 }; + renderPanel(); + flushEffects(); + + expect( + visitElements( + renderPanel(), + (element) => element.props["aria-controls"] === "git-history-details-panel", + )?.props["aria-expanded"], + ).toBe(false); + }); + + it("restores the history header when opening a diff from narrow details", () => { + stubResizeObserver(539); + const historyCommit = commit(primaryCommitHash, "Initial"); + historyState.pages.set(undefined, page([historyCommit])); + historyState.commitDetails = { ...historyCommit, body: "" }; + + const initial = renderPanel(); + (initial.props.ref as { current: object | null }).current = {}; + flushEffects(); + const list = historyList(renderPanel()); + const row = renderComponent(list.props.renderItem({ item: list.props.data[0]! })); + const selectCommit = visitElements( + row, + (element) => element.props["data-commit-hash"] === historyCommit.hash, + ); + (selectCommit?.props.onClick as (() => void) | undefined)?.(); + const details = visitElements( + renderPanel(), + (element) => element.props["aria-controls"] === "git-history-details-panel", + ); + (details?.props.onClick as (() => void) | undefined)?.(); + const detailsPane = componentElement(renderPanel(), "CommitDetailsPane"); + (detailsPane.props.onShowDiff as ((hash: string) => void) | undefined)?.(historyCommit.hash); + const panel = renderPanel(); + + expect( + visitElements(panel, (element) => element.type === "header")?.props.inert, + ).toBeUndefined(); + expect( + visitElements( + panel, + (element) => typeof element.type === "function" && element.type.name === "CommitDiffView", + ), + ).not.toBeNull(); + const branches = visitElements( + panel, + (element) => element.props["aria-controls"] === "git-history-refs-panel", + ); + (branches?.props.onClick as (() => void) | undefined)?.(); + const history = renderPanel(); + + expect( + visitElements( + history, + (element) => element.props["aria-controls"] === "git-history-refs-panel", + )?.props["aria-expanded"], + ).toBe(true); + expect( + visitElements( + history, + (element) => typeof element.type === "function" && element.type.name === "CommitDiffView", + ), + ).toBeNull(); + }); + + it("restarts the first history page after a typed continuation expiry", () => { + historyState.pages.set( + undefined, + page([commit(primaryCommitHash, "First")], { + hasMore: true, + nextCursor: "history-page-2", + }), + ); + historyState.pages.set("history-page-2", expiredHistoryPage()); + + const first = renderPanel(); + loadMoreHistory(first); + renderPanel(); + flushEffects(); + renderPanel(); + + const requests = historyState.getHistory.mock.calls.map(([target]) => target); + expect(requests).toContainEqual({ + cacheKey: 0, + environmentId, + input: { cwd: workspacePath, cursor: "history-page-2", limit: historyPageSize }, + }); + expect(requests.at(-1)).toEqual({ + cacheKey: 1, + environmentId, + input: { cwd: workspacePath, limit: historyPageSize }, + }); + }); + + it("recovers a second continuation expiry once after a successful recovery", () => { + const firstPage = page([commit(primaryCommitHash, "First")], { + hasMore: true, + nextCursor: "history-page-2", + }); + historyState.pages.set(undefined, firstPage); + historyState.pages.set("history-page-2", expiredHistoryPage()); + + const first = renderPanel(); + loadMoreHistory(first); + renderPanel(); + flushEffects(); + renderPanel(); + flushEffects(); + + const recovered = renderPanel(); + loadMoreHistory(recovered); + renderPanel(); + flushEffects(); + renderPanel(); + flushEffects(); + renderPanel(); + flushEffects(); + + const generations = historyState.getHistory.mock.calls.map(([target]) => target.cacheKey); + expect(generations).toContain(2); + expect(generations).not.toContain(3); + expect(generations.at(-1)).toBe(2); + }); + + it("clears an open commit selection when the history target changes", () => { + const historyCommit = commit(primaryCommitHash, "Add panel"); + historyState.pages.set(undefined, page([historyCommit])); + historyState.commitDetails = { ...historyCommit, body: "" }; + + const list = historyList(renderPanel()); + const historyRow = renderComponent(list.props.renderItem({ item: list.props.data[0]! })); + const selectCommit = visitElements( + historyRow, + (element) => element.props["data-commit-hash"] === historyCommit.hash, + ); + (selectCommit?.props.onClick as (() => void) | undefined)?.(); + const details = renderPanel(); + const detailsPane = componentTree(details, "CommitDetailsPane"); + const showDiff = visitElements( + detailsPane, + (element) => + typeof element.props.onClick === "function" && + JSON.stringify(element.props.children).includes("View all changes"), + ); + (showDiff?.props.onClick as (() => void) | undefined)?.(); + renderPanel(); + + historyState.historyRevision = 1; + renderPanel(); + + expect(historyState.getHistory).toHaveBeenLastCalledWith({ + cacheKey: 1, + environmentId, + input: { cwd: workspacePath, limit: historyPageSize }, + }); + expect(componentElement(renderPanel(), "CommitDetailsPane").props.hasSelection).toBe(false); + expect( + visitElements( + renderPanel(), + (element) => typeof element.type === "function" && element.type.name === "CommitDiffView", + ), + ).toBeNull(); + }); + + it("discards loaded history cursor pages when the environment reconnects", () => { + historyState.pages.set( + undefined, + page([commit(primaryCommitHash, "First")], { + hasMore: true, + nextCursor: "history-page-2", + }), + ); + historyState.pages.set("history-page-2", page([commit(secondaryCommitHash, "Second")])); + + const initial = renderPanel(); + loadMoreHistory(initial); + renderPanel(); + historyState.connection = { phase: "connected", generation: 2 }; + renderPanel(); + + expect( + historyState.getHistory.mock.calls.slice(-3).map(([target]) => target.input.cursor), + ).toEqual([undefined, "history-page-2", undefined]); + }); + + it("keeps graph rows stable when history query results have not changed", () => { + historyState.pages.set(undefined, page([commit(primaryCommitHash, "First")])); + + const first = historyList(renderPanel()); + const second = historyList(renderPanel()); + + expect(second.props.data).toBe(first.props.data); + }); + + it("keeps row separators out of the graph column", () => { + const historyCommit = commit(primaryCommitHash, "Add panel"); + historyState.pages.set(undefined, page([historyCommit])); + + const list = historyList(renderPanel()); + const historyRow = renderComponent(list.props.renderItem({ item: list.props.data[0]! })); + const graph = visitElements( + historyRow, + (element) => typeof element.type === "function" && element.type.name === "GraphCell", + ); + const content = visitElements( + historyRow, + (element) => + typeof element.props.className === "string" && + element.props.className.includes("grid-cols-") && + element.props.className.includes("border-b"), + ); + + expect(historyRow.props.className).not.toContain("border-b"); + expect(graph).not.toBeNull(); + expect(content).not.toBeNull(); + + const graphRoot = renderComponent(graph!); + const graphSvg = visitElements(graphRoot, (element) => element.type === "svg"); + expect(graphRoot.props.className).not.toContain("overflow-visible"); + expect(graphSvg).not.toBeNull(); + expect(graphSvg!.props.className).toBe("absolute inset-0"); + expect(graphSvg!.props.height).toBe(30); + expect(graphSvg!.props.viewBox).toBe("0 0 44 30"); + }); + + it("scales the list and graph geometry with the interface font size", () => { + fontState.interfaceSize = 20; + const historyCommit = commit(primaryCommitHash, "Add panel"); + historyState.pages.set(undefined, page([historyCommit])); + + const list = historyList(renderPanel()); + const historyRow = renderComponent(list.props.renderItem({ item: list.props.data[0]! })); + const graph = visitElements( + historyRow, + (element) => typeof element.type === "function" && element.type.name === "GraphCell", + ); + expect(graph).not.toBeNull(); + const graphRoot = renderComponent(graph!); + const graphSvg = visitElements(graphRoot, (element) => element.type === "svg"); + + expect(list.props.estimatedItemSize).toBe(37.5); + expect(historyRow.props.style).toMatchObject({ height: 37.5 }); + expect(graphSvg!.props.height).toBe(37.5); + expect(graphSvg!.props.viewBox).toBe("0 0 44 37.5"); + }); + + it("keeps graph paths within each paint-contained row while joining adjacent lanes", () => { + fontState.interfaceSize = 20; + const parent = commit(secondaryCommitHash, "Parent"); + const child = { + ...commit(primaryCommitHash, "Child"), + parentHashes: [parent.hash], + }; + historyState.pages.set(undefined, page([child, parent])); + + const list = historyList(renderPanel()); + const graphRoots = list.props.data.map((row) => { + const historyRow = renderComponent(list.props.renderItem({ item: row })); + const graph = visitElements( + historyRow, + (element) => typeof element.type === "function" && element.type.name === "GraphCell", + ); + expect(graph).not.toBeNull(); + return renderComponent(graph!); + }); + const childSvg = visitElements(graphRoots[0], (element) => element.type === "svg"); + const parentSvg = visitElements(graphRoots[1], (element) => element.type === "svg"); + const childParentEdge = visitElements( + graphRoots[0], + (element) => element.props["data-edge-kind"] === "parent", + ); + const parentIncoming = visitElements( + graphRoots[1], + (element) => element.type === "line" && element.props.y1 === "0", + ); + + expect(childSvg).not.toBeNull(); + expect(parentSvg).not.toBeNull(); + expect(childSvg!.props.className).toBe("absolute inset-0"); + expect(childSvg!.props.viewBox).toBe("0 0 44 37.5"); + expect(childSvg!.props.height).toBe(37.5); + expect(childParentEdge!.props.d).toContain("L 11.5 37.5"); + expect(childParentEdge!.props.strokeLinecap).toBe("square"); + expect(parentIncoming).not.toBeNull(); + expect(parentIncoming!.props.strokeLinecap).toBe("square"); + }); + + it("keeps missing-parent graph paths dashed without boundary overlays", () => { + const child = { + ...commit(primaryCommitHash, "Child"), + parentHashes: [secondaryCommitHash], + }; + historyState.pages.set(undefined, page([child])); + + const list = historyList(renderPanel()); + const historyRow = renderComponent(list.props.renderItem({ item: list.props.data[0]! })); + const graph = visitElements( + historyRow, + (element) => typeof element.type === "function" && element.type.name === "GraphCell", + ); + expect(graph).not.toBeNull(); + const graphRoot = renderComponent(graph!); + const missingParent = visitElements( + graphRoot, + (element) => + element.props["data-edge-kind"] === "parent" && element.props.strokeDasharray === "3 2", + ); + + expect(missingParent).not.toBeNull(); + expect(missingParent!.props.strokeLinecap).toBe("butt"); + }); + + it("creates a fresh changed-file first-page generation after each recovered snapshot expiry", () => { + const errorCause = expiredSnapshotCause(); + const historyCommit = commit(primaryCommitHash, "Add panel"); + historyState.pages.set(undefined, page([historyCommit])); + historyState.commitDetails = { ...historyCommit, body: "" }; + historyState.commitFiles = { + files: [{ status: "M", path: "stale.ts" }], + isRepo: true, + nextCursor: "stale-cursor", + hasMore: true, + capped: true, + }; + + const list = historyList(renderPanel()); + const historyRow = renderComponent(list.props.renderItem({ item: list.props.data[0]! })); + const selectCommit = visitElements( + historyRow, + (element) => element.props["data-commit-hash"] === historyCommit.hash, + ); + (selectCommit?.props.onClick as (() => void) | undefined)?.(); + renderPanel(); + flushEffects(); + expect(componentElement(renderPanel(), "CommitDetailsPane").props).toMatchObject({ + files: [{ status: "M", path: "stale.ts" }], + filesCapped: true, + filesHasMore: true, + }); + + historyState.commitFilesErrorCause = errorCause; + renderPanel(); + flushEffects(); + expect(componentElement(renderPanel(), "CommitDetailsPane").props).toMatchObject({ + files: [], + filesCapped: false, + filesHasMore: false, + }); + }); + + it("renders populated history rows through the virtualized list", () => { + historyState.pages.set( + undefined, + page([ + commit(primaryCommitHash, "Add Git history panel"), + commit(secondaryCommitHash, "Expose commit graph", "Grace Hopper"), + ]), + ); + + const panel = renderPanel(); + const list = historyList(panel); + + expect(list.props.data.map((row) => row.commit.subject)).toEqual([ + "Add Git history panel", + "Expose commit graph", + ]); + expect(list.props.recycleItems).toBe(false); + expect(historyState.getHistory).toHaveBeenCalledWith({ + cacheKey: 0, + environmentId, + input: { cwd: workspacePath, limit: historyPageSize }, + }); + }); +}); diff --git a/apps/web/src/components/GitHistoryPanel.test-fixture.tsx b/apps/web/src/components/GitHistoryPanel.test-fixture.tsx new file mode 100644 index 000000000000..17f214c3ec2c --- /dev/null +++ b/apps/web/src/components/GitHistoryPanel.test-fixture.tsx @@ -0,0 +1,580 @@ +import { + EnvironmentId, + VcsSnapshotExpiredError, + type GitCommitDetails, + type GitHistoryCommit, + type VcsGetHistoryResult, + type VcsListCommitFilesResult, + type VcsHistoryRef, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; +import type { ReactElement } from "react"; +import { afterEach, beforeEach, expect, vi } from "vite-plus/test"; + +import { reactHookHarness as hooks } from "../test/reactHookHarness"; +import { visitElements } from "../test/reactElementTree"; + +type PageResult = + | { readonly _tag: "Failure"; readonly cause: Cause.Cause } + | { + readonly _tag: "Success"; + readonly waiting: false; + readonly value: VcsGetHistoryResult | VcsListCommitFilesResult; + }; + +type PageAtom = { readonly result: PageResult }; + +const effectQueue = vi.hoisted(() => ({ + cursor: 0, + dependencies: [] as Array | undefined>, + effects: [] as Array<() => void>, + stateUpdates: 0, +})); + +const historyState = vi.hoisted(() => ({ + commitDetails: null as GitCommitDetails | null, + diff: { diff: "", isRepo: true, truncated: false }, + getCommitDetails: vi.fn(), + listCommitFiles: vi.fn(), + commitFiles: { + files: [], + isRepo: true, + nextCursor: null, + hasMore: false, + capped: false, + } as VcsListCommitFilesResult, + commitFilesErrorCause: null as Cause.Cause | null, + commitFilesRefresh: vi.fn(), + getCommitDiff: vi.fn(), + getHistory: vi.fn(), + historyRevision: 0, + connection: { phase: "connected", generation: 1 } as { + readonly phase: string; + readonly generation: number; + }, + pages: new Map(), + refresh: vi.fn(), + refreshRefs: vi.fn(), + refreshRemoteRefs: vi.fn(), + refreshTags: vi.fn(), + toastAdd: vi.fn(), + refs: [] as ReadonlyArray, + refsResolved: true, + refsError: null as string | null, + remoteRefsError: null as string | null, + retryRefs: vi.fn(), + tags: [] as ReadonlyArray, + tagsError: null as string | null, + status: { aheadCount: 0, behindCount: 0 }, +})); + +function historyCacheGeneration(cacheKey: string | number | undefined): number | undefined { + return typeof cacheKey === "string" + ? cacheKey + .slice(1, -1) + .split(",") + .map(Number) + .reduce((sum, value) => sum + value, 0) + : cacheKey; +} + +const fontState = vi.hoisted(() => ({ interfaceSize: 16 })); + +vi.mock("../hooks/useCopyToClipboard", () => ({ + useCopyToClipboard: (options?: { readonly onError?: (error: Error) => void }) => ({ + copyToClipboard: () => options?.onError?.(new Error("Clipboard permission was denied.")), + isCopied: false, + }), +})); + +vi.mock("../hooks/useSettings", () => ({ + useClientSettings: ( + selector: (settings: { readonly fontSizeInterface: number }) => Value, + ) => selector({ fontSizeInterface: fontState.interfaceSize }), +})); + +vi.mock("./ui/toast", () => ({ + stackedThreadToast: (toast: unknown) => toast, + toastManager: { add: historyState.toastAdd }, +})); + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + const { reactHookHarness } = await import("../test/reactHookHarness"); + return { + ...actual, + useCallback: reactHookHarness.useCallback, + useDeferredValue: (value: Value) => value, + useEffect: (effect: () => void, dependencies?: ReadonlyArray) => { + const index = effectQueue.cursor++; + const previous = effectQueue.dependencies[index]; + if ( + previous !== undefined && + dependencies !== undefined && + previous.length === dependencies.length && + previous.every((value, dependencyIndex) => Object.is(value, dependencies[dependencyIndex])) + ) { + return; + } + effectQueue.dependencies[index] = dependencies; + effectQueue.effects.push(effect); + }, + useMemo: reactHookHarness.useMemo, + useRef: reactHookHarness.useRef, + useState: (initialValue: Value | (() => Value)) => { + const [value, setValue] = reactHookHarness.useState(initialValue); + return [ + value, + (nextValue: Value | ((previous: Value) => Value)) => { + effectQueue.stateUpdates += 1; + setValue(nextValue); + }, + ] as const; + }, + }; +}); + +vi.mock("react/compiler-runtime", async () => { + const { reactHookHarness } = await import("../test/reactHookHarness"); + return { c: reactHookHarness.useMemoCache }; +}); + +vi.mock("../hooks/useLocalStorage", () => ({ + useLocalStorage: () => [[], vi.fn()], +})); + +vi.mock("@effect/atom-react", () => ({ + useAtomValue: (atom: { readonly value: ReadonlyArray }) => atom.value, +})); + +vi.mock("effect/unstable/reactivity", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + AsyncResult: { + ...actual.AsyncResult, + value: (result: PageAtom["result"]) => + result._tag === "Success" ? Option.some(result.value) : Option.none(), + }, + Atom: { + ...actual.Atom, + make: ( + create: (get: (atom: PageAtom) => PageAtom["result"]) => ReadonlyArray, + ) => { + let previous: ReadonlyArray | undefined; + const atom = { + pipe: () => atom, + get value() { + const next = create((pageAtom) => pageAtom.result); + if ( + previous !== undefined && + previous.length === next.length && + previous.every((result, index) => { + const candidate = next[index]!; + return ( + result._tag === candidate._tag && + ("waiting" in result ? result.waiting : false) === + ("waiting" in candidate ? candidate.waiting : false) && + (result._tag === "Success" + ? candidate._tag === "Success" && result.value === candidate.value + : candidate._tag === "Failure" && result.cause === candidate.cause) + ); + }) + ) { + return previous; + } + previous = next; + return next; + }, + }; + return atom; + }, + withLabel: () => (atom: unknown) => atom, + }, + }; +}); + +vi.mock("@legendapp/list/react", () => ({ + LegendList: () => null, +})); + +vi.mock("../hooks/useTheme", () => ({ + useTheme: () => ({ resolvedTheme: "dark" }), +})); + +vi.mock("../rpc/atomRegistry", () => ({ + appAtomRegistry: { refresh: historyState.refresh }, +})); + +vi.mock("../state/queries", () => ({ + useDebouncedValue: (value: Value) => value, + isVcsSnapshotExpiredCause: (cause: Cause.Cause) => { + const error = Cause.squash(cause); + return ( + typeof error === "object" && + error !== null && + "_tag" in error && + error._tag === "VcsSnapshotExpiredError" + ); + }, + makeVcsSnapshotCacheKey: (generation: number, revision: number) => + JSON.stringify([generation, revision]), + usePaginatedHistoryRefs: (_target: unknown, options?: { readonly namespace?: string }) => { + const refs = options?.namespace === "tag" ? historyState.tags : historyState.refs; + return { + data: historyState.refsResolved + ? { + refs, + isRepo: true, + repositoryKey: "C:/repositories/t3code/.git", + nextCursor: null, + currentRef: refs.find((ref) => ref.current) ?? null, + isComplete: true, + } + : null, + refs, + error: + options?.namespace === "local" + ? historyState.refsError + : options?.namespace === "remote" + ? historyState.remoteRefsError + : historyState.tagsError, + isPending: false, + isFetchingNextPage: false, + loadNext: vi.fn(), + retry: historyState.retryRefs, + refresh: + options?.namespace === "tag" + ? historyState.refreshTags + : options?.namespace === "remote" + ? historyState.refreshRemoteRefs + : historyState.refreshRefs, + }; + }, +})); + +vi.mock("../state/query", () => ({ + useEnvironmentQuery: (target: { readonly kind?: string } | null) => { + const base = { error: null, errorCause: null, isPending: false, refresh: vi.fn() }; + if (target?.kind === "status") return { ...base, data: historyState.status }; + if (target?.kind === "commit-details") + return { ...base, data: { commit: historyState.commitDetails } }; + if (target?.kind === "commit-files") { + const errorCause = historyState.commitFilesErrorCause; + return { + ...base, + data: errorCause === null ? historyState.commitFiles : null, + error: errorCause === null ? null : "Git browsing snapshot expired.", + errorCause, + refresh: historyState.commitFilesRefresh, + }; + } + if (target?.kind === "commit-diff") return { ...base, data: historyState.diff }; + return { ...base, data: null }; + }, +})); + +vi.mock("../state/environments", () => ({ + useEnvironmentConnectionState: () => ({ data: historyState.connection }), +})); + +vi.mock("../state/vcs", () => ({ + vcsEnvironment: { + historyRevisionAtom: () => ({ value: historyState.historyRevision }), + getHistory: (target: { + readonly cacheKey?: string | number; + readonly input: { readonly cursor?: string }; + }) => { + historyState.getHistory({ ...target, cacheKey: historyCacheGeneration(target.cacheKey) }); + const value = historyState.pages.get(target.input.cursor); + return { result: value ?? page([]) } satisfies PageAtom; + }, + getCommitDetails: (target: unknown) => { + historyState.getCommitDetails(target); + return { kind: "commit-details" }; + }, + listCommitFiles: (target: { readonly cacheKey?: string | number; readonly input: unknown }) => { + historyState.listCommitFiles({ + ...target, + cacheKey: historyCacheGeneration(target.cacheKey), + }); + return { + get result() { + return historyState.commitFilesErrorCause === null + ? { _tag: "Success" as const, waiting: false as const, value: historyState.commitFiles } + : { + _tag: "Failure" as const, + waiting: false as const, + cause: historyState.commitFilesErrorCause, + }; + }, + }; + }, + getCommitDiff: (target: unknown) => { + historyState.getCommitDiff(target); + return { kind: "commit-diff" }; + }, + status: () => ({ kind: "status" }), + }, +})); + +import GitHistoryPanel from "./GitHistoryPanel"; + +const environmentId = EnvironmentId.make("environment-local"); +const workspacePath = "C:/workspace"; +const historyPageSize = 100; +const primaryCommitHash = "aaaaaaaa11111111111111111111111111111111"; +const secondaryCommitHash = "bbbbbbbb22222222222222222222222222222222"; +const newestMatchingCommitHash = "cccccccc33333333333333333333333333333333"; + +function commit(hash: string, subject: string, authorName = "Ada Lovelace"): GitHistoryCommit { + return { + hash, + parentHashes: [], + subject, + authorName, + authorEmail: "ada@example.com", + authoredAt: "2026-08-01T12:00:00.000Z", + refs: [], + }; +} + +function page( + commits: ReadonlyArray, + options?: { + readonly capped?: boolean; + readonly hasMore?: boolean; + readonly nextCursor?: string | null; + }, +): PageResult { + return { + _tag: "Success", + waiting: false, + value: { + commits, + isRepo: true, + hasMore: options?.hasMore ?? false, + nextCursor: options?.nextCursor ?? null, + capped: options?.capped ?? false, + }, + }; +} + +const expiredHistoryPage = (): PageResult => ({ + _tag: "Failure", + cause: Cause.fail( + Object.assign(new Error("Git browsing snapshot expired."), { _tag: "VcsSnapshotExpiredError" }), + ), +}); + +function expiredSnapshotCause(): Cause.Cause { + return Cause.fail( + new VcsSnapshotExpiredError({ + operation: "GitVcsDriver.listCommitFiles", + cursor: "expired-cursor", + }), + ); +} + +function gitRef( + name: string, + options?: { + readonly aheadCount?: number; + readonly behindCount?: number; + readonly current?: boolean; + readonly kind?: "local" | "remote" | "tag"; + readonly upstreamName?: string; + }, +): VcsHistoryRef { + return { + name, + current: options?.current ?? false, + isDefault: false, + kind: options?.kind ?? "local", + ...(options?.aheadCount === undefined ? {} : { aheadCount: options.aheadCount }), + ...(options?.behindCount === undefined ? {} : { behindCount: options.behindCount }), + ...(options?.upstreamName === undefined ? {} : { upstreamName: options.upstreamName }), + worktreePath: null, + }; +} + +function renderPanel(): ReactElement> { + hooks.beginRender(); + effectQueue.cursor = 0; + const boundary = GitHistoryPanel({ + environmentId, + cwd: workspacePath, + }) as ReactElement>; + return ( + boundary.type as (props: Record) => ReactElement> + )(boundary.props); +} + +function flushEffects(): void { + const effects = effectQueue.effects.splice(0); + for (const effect of effects) effect(); +} + +function stubResizeObserver(initialWidth: number): (width: number) => void { + let notify: ((width: number) => void) | undefined; + vi.stubGlobal( + "ResizeObserver", + class { + constructor(callback: (entries: ReadonlyArray<{ contentRect: { width: number } }>) => void) { + notify = (width) => callback([{ contentRect: { width } }]); + } + + disconnect() {} + + observe() { + notify?.(initialWidth); + } + }, + ); + return (width) => notify?.(width); +} + +function historyList(panel: ReactElement>) { + const list = visitElements( + panel, + (element) => + typeof element.props.estimatedItemSize === "number" && + typeof element.props.keyExtractor === "function", + ); + expect(list).not.toBeNull(); + return list as ReactElement<{ + readonly data: ReadonlyArray<{ + readonly commit: GitHistoryCommit; + readonly graph: { readonly edges: ReadonlyArray<{ readonly kind: string }> }; + }>; + readonly renderItem: (props: { + readonly item: { + readonly commit: GitHistoryCommit; + readonly graph: { readonly edges: ReadonlyArray }; + }; + }) => ReactElement>; + readonly estimatedItemSize: number; + readonly onEndReached?: () => void; + readonly recycleItems?: boolean; + }>; +} + +function loadMoreHistory(panel: ReactElement>): void { + const footer = visitElements( + panel, + (element) => + element.props.className === "flex shrink-0 justify-center border-t border-border/50 p-2", + ); + const loadMore = visitElements(footer, (element) => element.props.children === "Load more"); + expect(loadMore).not.toBeNull(); + (loadMore?.props.onClick as (() => void) | undefined)?.(); +} + +function renderComponent( + element: ReactElement>, +): ReactElement> { + const component = element.type as unknown as ( + props: Record, + ) => ReactElement>; + return component(element.props); +} + +function componentTree( + panel: ReactElement>, + componentName: string, + props?: Partial>, +): ReactElement> { + const component = visitElements( + panel, + (element) => + typeof element.type === "function" && + element.type.name === componentName && + Object.entries(props ?? {}).every(([key, value]) => element.props[key] === value), + ); + expect(component).not.toBeNull(); + return renderComponent(component as ReactElement>); +} + +function componentElement( + panel: ReactElement>, + componentName: string, +): ReactElement> { + const component = visitElements( + panel, + (element) => typeof element.type === "function" && element.type.name === componentName, + ); + expect(component).not.toBeNull(); + return component as ReactElement>; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +beforeEach(() => { + hooks.reset(); + fontState.interfaceSize = 16; + effectQueue.cursor = 0; + effectQueue.dependencies.length = 0; + effectQueue.effects.length = 0; + effectQueue.stateUpdates = 0; + historyState.commitDetails = null; + historyState.diff = { diff: "", isRepo: true, truncated: false }; + historyState.getCommitDetails.mockReset(); + historyState.listCommitFiles.mockReset(); + historyState.commitFiles = { + files: [], + isRepo: true, + nextCursor: null, + hasMore: false, + capped: false, + }; + historyState.commitFilesErrorCause = null; + historyState.commitFilesRefresh.mockReset(); + historyState.getCommitDiff.mockReset(); + historyState.getHistory.mockReset(); + historyState.historyRevision = 0; + historyState.connection = { phase: "connected", generation: 1 }; + historyState.pages.clear(); + historyState.refresh.mockReset(); + historyState.refreshRefs.mockReset(); + historyState.refreshRemoteRefs.mockReset(); + historyState.refreshTags.mockReset(); + historyState.toastAdd.mockReset(); + historyState.refs = []; + historyState.refsResolved = true; + historyState.refsError = null; + historyState.remoteRefsError = null; + historyState.retryRefs.mockReset(); + historyState.tags = []; + historyState.tagsError = null; + historyState.status = { aheadCount: 0, behindCount: 0 }; +}); + +export { + componentElement, + componentTree, + commit, + effectQueue, + environmentId, + expiredHistoryPage, + expiredSnapshotCause, + flushEffects, + fontState, + gitRef, + historyCacheGeneration, + historyList, + historyPageSize, + historyState, + loadMoreHistory, + newestMatchingCommitHash, + page, + primaryCommitHash, + renderComponent, + renderPanel, + secondaryCommitHash, + stubResizeObserver, + visitElements, + workspacePath, +}; diff --git a/apps/web/src/components/GitHistoryPanel.tsx b/apps/web/src/components/GitHistoryPanel.tsx new file mode 100644 index 000000000000..90e9128cd52d --- /dev/null +++ b/apps/web/src/components/GitHistoryPanel.tsx @@ -0,0 +1,821 @@ +import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentId, GitCommitChangedFile, GitHistoryCommit } from "@t3tools/contracts"; +import { LegendList } from "@legendapp/list/react"; +import { FileIcon, GitBranchIcon, RefreshCwIcon, SearchIcon, XIcon } from "lucide-react"; +import { + useCallback, + useDeferredValue, + useEffect, + useMemo, + useRef, + useState, + type ComponentProps, + type RefObject, + type SetStateAction, +} from "react"; + +import { layoutGitHistoryGraph } from "../lib/gitHistoryGraph"; +import { cn } from "../lib/utils"; +import { useClientSettings } from "../hooks/useSettings"; +import { vcsEnvironment } from "../state/vcs"; +import { useEnvironmentConnectionState } from "../state/environments"; +import { useEnvironmentQuery } from "../state/query"; +import { isVcsSnapshotExpiredCause, makeVcsSnapshotCacheKey } from "../state/queries"; +import { usePaginatedSnapshotPages } from "../state/snapshotPages"; +import { CommitDetailsPane } from "./git-history/GitHistoryCommitDetails"; +import { CommitDiffView } from "./git-history/GitHistoryCommitDiff"; +import { + CommitRow, + gitHistoryRowHeight, + currentHeadHash, + firstParentHashes, + graphColumnWidth, + queryErrorMessage, +} from "./git-history/GitHistoryCommitList"; +import { PaneResizeHandle } from "./git-history/GitHistoryPaneResizeHandle"; +import { GitRefsPane } from "./git-history/GitHistoryRefsPane"; +import type { CommitRefKind, GitHistoryRow } from "./git-history/GitHistoryVisualTypes"; +import { useGitHistoryRefs } from "./git-history/useGitHistoryRefs"; +import { Badge } from "./ui/badge"; +import { Button } from "./ui/button"; +import { Sheet, SheetPopup, SheetTitle } from "./ui/sheet"; + +const HISTORY_PAGE_SIZE = 100; +const WIDE_HISTORY_LAYOUT_MIN_WIDTH = 1120; +const REFS_PANE_MIN_WIDTH = 176; +const REFS_PANE_MAX_WIDTH = 480; +const DETAILS_PANE_MIN_WIDTH = 256; +const DETAILS_PANE_MAX_WIDTH = 720; +const HISTORY_CONTENT_MIN_WIDTH = 320; +const PANE_RESIZE_HANDLE_TOTAL_WIDTH = 16; + +interface GitHistoryPanelProps { + environmentId: EnvironmentId; + cwd: string; + active?: boolean; +} + +export function isWideHistoryLayout(width: number): boolean { + return width >= WIDE_HISTORY_LAYOUT_MIN_WIDTH; +} + +function clampHistoryPaneWidths(input: { + readonly panelWidth: number; + readonly refsPaneWidth: number; + readonly detailsPaneWidth: number; +}): { readonly refsPaneWidth: number; readonly detailsPaneWidth: number } { + const maxSidePaneWidth = Math.max( + REFS_PANE_MIN_WIDTH + DETAILS_PANE_MIN_WIDTH, + input.panelWidth - HISTORY_CONTENT_MIN_WIDTH - PANE_RESIZE_HANDLE_TOTAL_WIDTH, + ); + const refsPaneWidth = Math.min( + Math.min(REFS_PANE_MAX_WIDTH, maxSidePaneWidth - DETAILS_PANE_MIN_WIDTH), + Math.max(REFS_PANE_MIN_WIDTH, input.refsPaneWidth), + ); + const detailsPaneWidth = Math.min( + Math.min(DETAILS_PANE_MAX_WIDTH, maxSidePaneWidth - refsPaneWidth), + Math.max(DETAILS_PANE_MIN_WIDTH, input.detailsPaneWidth), + ); + return { refsPaneWidth, detailsPaneWidth }; +} + +function useHistoryPanelLayout( + panelRef: RefObject, + onResize: (width: number) => void, +): { + readonly isWide: boolean; + readonly widthRef: RefObject; +} { + const widthRef = useRef(Number.POSITIVE_INFINITY); + const onResizeRef = useRef(onResize); + const [isWide, setIsWide] = useState(true); + onResizeRef.current = onResize; + + useEffect(() => { + const panel = panelRef.current; + if (!panel || typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(([entry]) => { + const width = entry?.contentRect.width ?? 0; + const nextIsWide = isWideHistoryLayout(width); + if (nextIsWide !== isWideHistoryLayout(widthRef.current)) setIsWide(nextIsWide); + widthRef.current = width; + onResizeRef.current(width); + }); + observer.observe(panel); + return () => observer.disconnect(); + }, [panelRef]); + + return { isWide, widthRef }; +} + +function GitHistoryPanelContent(props: GitHistoryPanelProps) { + const panelRef = useRef(null); + const interfaceFontSize = useClientSettings((settings) => settings.fontSizeInterface); + const timestampFormat = useClientSettings((settings) => settings.timestampFormat); + const rowHeight = gitHistoryRowHeight(interfaceFontSize); + const baseTargetKey = `${props.environmentId}:${props.cwd}`; + const connection = useEnvironmentConnectionState(props.environmentId).data; + const connectionGeneration = connection?.phase === "connected" ? connection.generation : null; + const [paneWidths, setPaneWidths] = useState({ refsPaneWidth: 256, detailsPaneWidth: 384 }); + const { refsPaneWidth, detailsPaneWidth } = paneWidths; + const { isWide: isWideLayout, widthRef: panelWidthRef } = useHistoryPanelLayout( + panelRef, + (panelWidth) => { + if (!isWideHistoryLayout(panelWidth)) return; + const nextWidths = clampHistoryPaneWidths({ panelWidth, ...paneWidths }); + if ( + nextWidths.refsPaneWidth !== paneWidths.refsPaneWidth || + nextWidths.detailsPaneWidth !== paneWidths.detailsPaneWidth + ) { + setPaneWidths(nextWidths); + } + }, + ); + const vcsHistoryRevision = useAtomValue( + vcsEnvironment.historyRevisionAtom({ environmentId: props.environmentId, cwd: props.cwd }), + ); + const historyRefs = useGitHistoryRefs(props.environmentId, props.cwd, vcsHistoryRevision); + const { selectedRevision } = historyRefs; + const refSelectionError = historyRefs.initialLocalRefError; + const targetKey = `${baseTargetKey}:${selectedRevision?.revision ?? "all"}:${vcsHistoryRevision}:${connectionGeneration}`; + const makeHistoryPageAtom = useMemo(() => { + if (selectedRevision === undefined || refSelectionError !== null) return null; + return (cursor: string | undefined, generation: number) => + vcsEnvironment.getHistory({ + environmentId: props.environmentId, + cacheKey: makeVcsSnapshotCacheKey(generation, vcsHistoryRevision), + input: { + cwd: props.cwd, + ...(selectedRevision === null ? {} : { revision: selectedRevision.revision }), + ...(cursor === undefined ? {} : { cursor }), + limit: HISTORY_PAGE_SIZE, + }, + }); + }, [props.cwd, props.environmentId, refSelectionError, selectedRevision, vcsHistoryRevision]); + const pagination = usePaginatedSnapshotPages({ + targetKey: selectedRevision === undefined || refSelectionError !== null ? null : targetKey, + label: "web:vcs-history-pages", + makePageAtom: makeHistoryPageAtom, + getNextCursor: (page) => (page.hasMore ? page.nextCursor : null), + isExpiredError: isVcsSnapshotExpiredCause, + }); + const { results, values } = pagination; + const failed = pagination.failed; + const error = failed?._tag === "Failure" ? queryErrorMessage(failed.cause) : null; + const isPending = + refSelectionError === null && + (selectedRevision === undefined || results.some((result) => result.waiting)); + const isInitialLoad = + refSelectionError === null && + (selectedRevision === undefined || (values.length === 0 && isPending)); + const history = useMemo(() => { + const commitsByHash = new Map(); + for (const value of values) { + for (const commit of value.commits) { + if (!commitsByHash.has(commit.hash)) commitsByHash.set(commit.hash, commit); + } + } + return [...commitsByHash.values()]; + }, [values]); + const isRepo = values[0]?.isRepo ?? true; + const lastPage = values.at(-1) ?? null; + const nextCursor = lastPage?.nextCursor ?? null; + const hasMoreFromServer = lastPage?.hasMore === true && nextCursor !== null; + const historyCapped = values.some((value) => value.capped === true) && !hasMoreFromServer; + const hasMore = hasMoreFromServer; + const isFetchingNextPage = pagination.isFetchingNextPage; + const [filterState, setFilterState] = useState({ targetKey, value: "" }); + const filter = filterState.targetKey === targetKey ? filterState.value : ""; + if (filterState.targetKey !== targetKey) setFilterState({ targetKey, value: "" }); + const setFilter = (value: string) => setFilterState({ targetKey, value }); + const normalizedFilter = filter.trim().toLocaleLowerCase(); + const deferredFilter = useDeferredValue(normalizedFilter); + const activeFilter = normalizedFilter.length === 0 ? "" : deferredFilter; + const searchInputRef = useRef(null); + const [selectionState, setSelectionState] = useState<{ + readonly targetKey: string; + readonly hash: string | null; + readonly diffRequest: { readonly hash: string; readonly filePath?: string } | null; + }>({ targetKey, hash: null, diffRequest: null }); + const selection = + selectionState.targetKey === targetKey + ? selectionState + : { targetKey, hash: null, diffRequest: null }; + if (selectionState.targetKey !== targetKey) setSelectionState(selection); + const selectedHash = selection.hash; + const setSelectedHash = (next: SetStateAction) => { + setSelectionState((current) => { + const previous = current.targetKey === targetKey ? current.hash : null; + return { + targetKey, + hash: typeof next === "function" ? next(previous) : next, + diffRequest: null, + }; + }); + }; + const [mobilePaneState, setMobilePaneState] = useState<{ + readonly targetKey: string; + readonly pane: "refs" | "details" | null; + }>({ targetKey, pane: null }); + const mobilePane = + mobilePaneState.targetKey === targetKey || mobilePaneState.pane === "refs" + ? mobilePaneState.pane + : null; + if (mobilePaneState.targetKey !== targetKey) { + setMobilePaneState({ targetKey, pane: mobilePane }); + } + const setMobilePane = (next: SetStateAction) => { + setMobilePaneState((current) => ({ + targetKey, + pane: + typeof next === "function" + ? next(current.targetKey === targetKey ? current.pane : null) + : next, + })); + }; + const previousMobilePane = useRef(null); + const branchesButtonRef = useRef(null); + const detailsButtonRef = useRef(null); + const commitDiffRequest = selection.diffRequest; + const setCommitDiffRequest = (request: typeof commitDiffRequest) => { + setSelectionState((current) => ({ + targetKey, + hash: current.targetKey === targetKey ? current.hash : null, + diffRequest: request, + })); + }; + const showCommitDiff = (hash: string, filePath?: string) => { + setMobilePane(null); + setCommitDiffRequest(filePath ? { hash, filePath } : { hash }); + }; + const openMobilePane = (pane: "refs" | "details") => { + setCommitDiffRequest(null); + setMobilePane((current) => (current === pane ? null : pane)); + }; + const commitDetailsQuery = useEnvironmentQuery( + selectedHash === null + ? null + : vcsEnvironment.getCommitDetails({ + environmentId: props.environmentId, + cacheKey: vcsHistoryRevision, + input: { cwd: props.cwd, hash: selectedHash }, + }), + ); + const selectedCommitDetails = commitDetailsQuery.data?.commit ?? null; + const makeCommitFilesPageAtom = useMemo(() => { + if (selectedHash === null) return null; + return (cursor: string | undefined, generation: number) => + vcsEnvironment.listCommitFiles({ + environmentId: props.environmentId, + cacheKey: makeVcsSnapshotCacheKey(generation, vcsHistoryRevision), + input: { + cwd: props.cwd, + hash: selectedHash, + limit: 100, + ...(cursor === undefined ? {} : { cursor }), + }, + }); + }, [props.cwd, props.environmentId, selectedHash, vcsHistoryRevision]); + const commitFilesPagination = usePaginatedSnapshotPages({ + targetKey: + selectedHash === null ? null : `${baseTargetKey}:${vcsHistoryRevision}:${selectedHash}`, + label: "web:vcs-commit-files-pages", + makePageAtom: makeCommitFilesPageAtom, + getNextCursor: (page) => (page.hasMore ? page.nextCursor : null), + maxPages: 20, + isExpiredError: isVcsSnapshotExpiredCause, + }); + const selectedCommitFiles = useMemo(() => { + const files = new Map(); + for (const page of commitFilesPagination.values) { + for (const file of page.files) files.set(file.path, file); + } + return [...files.values()].slice(0, 2_000); + }, [commitFilesPagination.values]); + const lastCommitFilesPage = commitFilesPagination.values.at(-1); + const commitFilesHasMore = + lastCommitFilesPage?.hasMore === true && selectedCommitFiles.length < 2_000; + const commitFilesCapped = + lastCommitFilesPage?.capped === true || selectedCommitFiles.length >= 2_000; + const loadMoreCommitFiles = commitFilesPagination.loadNext; + const commitDiffQuery = useEnvironmentQuery( + commitDiffRequest === null + ? null + : vcsEnvironment.getCommitDiff({ + environmentId: props.environmentId, + input: { + cwd: props.cwd, + hash: commitDiffRequest.hash, + ...(commitDiffRequest.filePath ? { filePath: commitDiffRequest.filePath } : {}), + }, + }), + ); + const { + currentRef = null, + expandedRefKeys, + favoriteBranches, + favoriteRefs, + hasMoreRefs, + isFetchingMoreRefs, + isRefSnapshotComplete, + localRefTree, + localRefs, + normalizedRefFilter, + onLoadMoreRefs, + onRetryRefs, + refreshRefs, + refPaginationError, + refFilter, + remoteRefTree, + remoteRefs, + selectAllRefs: selectAllHistoryRefs, + selectRef: selectHistoryRef, + setRefFilter, + tagRefTree, + tagRefs, + toggleFavorite, + toggleRefKey, + } = historyRefs; + const commitRefKinds = useMemo(() => { + const kinds = new Map(); + for (const ref of localRefs) kinds.set(ref.name, "local"); + for (const ref of remoteRefs) kinds.set(ref.name, "remote"); + for (const ref of tagRefs) kinds.set(ref.name, "tag"); + return kinds; + }, [localRefs, remoteRefs, tagRefs]); + const selectRef = useCallback( + (label: string, revision: string) => { + selectHistoryRef(label, revision); + setMobilePane(null); + }, + [selectHistoryRef], + ); + const selectAllRefs = useCallback(() => { + selectAllHistoryRefs(); + setMobilePane(null); + }, [selectAllHistoryRefs]); + const refPaneProps = { + refFilter, + onRefFilterChange: setRefFilter, + selectedRevision: selectedRevision ?? null, + onSelectAll: selectAllRefs, + currentRef, + onSelectRef: selectRef, + normalizedRefFilter, + localRefTree, + favoriteRefs, + favoriteBranches, + onToggleFavorite: toggleFavorite, + remoteRefTree, + tagRefTree, + expandedRefKeys, + onToggleRefKey: toggleRefKey, + hasMoreRefs, + isFetchingMoreRefs, + isRefSnapshotComplete, + onLoadMoreRefs, + onRetryRefs, + refPaginationError, + } satisfies Omit, "className" | "id" | "onClose">; + + useEffect(() => { + if (isWideLayout) setMobilePane(null); + }, [isWideLayout]); + + const headHash = useMemo(() => currentHeadHash(history), [history]); + const primaryHashes = useMemo(() => firstParentHashes(history, headHash), [headHash, history]); + const filteredHistory = useMemo(() => { + const query = activeFilter; + return history.filter( + (commit) => + query.length === 0 || + `${commit.hash} ${commit.subject} ${commit.authorName} ${commit.refs.join(" ")}` + .toLocaleLowerCase() + .includes(query), + ); + }, [activeFilter, history]); + const { laneCount, rows: graphRows } = useMemo( + () => + layoutGitHistoryGraph(filteredHistory, { + includeMissingParents: activeFilter.length === 0, + ...(headHash ? { primaryHash: headHash } : {}), + primaryHashes, + }), + [activeFilter, filteredHistory, headHash, primaryHashes], + ); + const filteredRows = useMemo(() => { + return filteredHistory.map((commit, index) => ({ commit, graph: graphRows[index]! })); + }, [filteredHistory, graphRows]); + + useEffect(() => { + if (selectedHash !== null && !history.some((commit) => commit.hash === selectedHash)) { + setSelectedHash(null); + } + }, [history, selectedHash]); + + useEffect(() => { + if (props.active === false) setMobilePane(null); + }, [props.active]); + + useEffect(() => { + const previous = previousMobilePane.current; + previousMobilePane.current = mobilePane; + if (mobilePane !== null || previous === null) return; + (previous === "refs" ? branchesButtonRef.current : detailsButtonRef.current)?.focus(); + }, [mobilePane]); + + const refresh = useCallback(() => { + pagination.refresh(); + refreshRefs(); + }, [pagination.refresh, refreshRefs]); + const loadNext = pagination.loadNext; + const retryFailedPage = pagination.retry; + + return ( +
+
+
+ + + {selectedRevision?.label ?? "All refs"} + + {history.length > 0 ? ( + + {history.length} commits + + ) : null} +
+ {!isWideLayout ? ( + <> + + + + ) : null} + +
+ {commitDiffRequest ? ( + setCommitDiffRequest(null)} + onSelectFile={(filePath) => + setCommitDiffRequest( + filePath + ? { hash: commitDiffRequest.hash, filePath } + : { hash: commitDiffRequest.hash }, + ) + } + onRetry={commitDiffQuery.refresh} + onLoadMoreFiles={loadMoreCommitFiles} + onRetryFiles={commitFilesPagination.retry} + /> + ) : refSelectionError || isInitialLoad ? ( +
+ {isWideLayout ? ( + + ) : null} +
+ {refSelectionError ? ( + <> +

{refSelectionError}

+ + + ) : ( +
+ + Loading history… +
+ )} +
+
+ ) : error && history.length === 0 ? ( +
+

{error}

+ +
+ ) : refPaginationError && history.length === 0 ? ( +
+

{refPaginationError}

+ +
+ ) : !isRepo ? ( +
+ This folder is not a Git repository. +
+ ) : history.length === 0 ? ( +
+ This repository has no commits yet. +
+ ) : ( +
+ {isWideLayout ? ( + + ) : null} + {isWideLayout ? ( + + setPaneWidths((widths) => + clampHistoryPaneWidths({ + panelWidth: panelWidthRef.current, + refsPaneWidth: widths.refsPaneWidth + delta, + detailsPaneWidth: widths.detailsPaneWidth, + }), + ) + } + onReset={() => + setPaneWidths((widths) => + clampHistoryPaneWidths({ + panelWidth: panelWidthRef.current, + refsPaneWidth: 256, + detailsPaneWidth: widths.detailsPaneWidth, + }), + ) + } + /> + ) : null} +
+
+
+ + setFilter(event.target.value)} + onKeyDown={(event) => { + if (event.key !== "Escape" || filter.length === 0) return; + event.preventDefault(); + setFilter(""); + }} + placeholder="Text or hash" + aria-label="Filter Git history" + /> + {filter.length > 0 ? ( + + ) : null} +
+ {error ? ( +
+ {error} + +
+ ) : null} +
+
+
+ Subject + + Author + Date + Hash +
+
+ {filteredRows.length === 0 ? ( +
+ + {error ?? "No loaded commits match this filter."} + + {error || hasMore ? ( + + ) : null} +
+ ) : ( + + data={filteredRows} + keyExtractor={(row) => row.commit.hash} + renderItem={({ item }) => ( + + )} + estimatedItemSize={rowHeight} + drawDistance={rowHeight * 8} + recycleItems={false} + className="min-h-0 flex-1 overscroll-y-contain" + /> + )} + {filteredRows.length > 0 && (hasMore || isFetchingNextPage) ? ( +
+ +
+ ) : null} + {filteredRows.length > 0 && historyCapped ? ( +
+ History results were capped by the server. +
+ ) : null} +
+
+ {isWideLayout ? ( + + setPaneWidths((widths) => + clampHistoryPaneWidths({ + panelWidth: panelWidthRef.current, + refsPaneWidth: widths.refsPaneWidth, + detailsPaneWidth: widths.detailsPaneWidth - delta, + }), + ) + } + onReset={() => + setPaneWidths((widths) => + clampHistoryPaneWidths({ + panelWidth: panelWidthRef.current, + refsPaneWidth: widths.refsPaneWidth, + detailsPaneWidth: 384, + }), + ) + } + /> + ) : null} + {isWideLayout ? ( + + ) : null} +
+ )} + {!isWideLayout && mobilePane === "refs" ? ( + !open && setMobilePane(null)}> + + Branches and tags + setMobilePane(null)} + /> + + + ) : null} + {!isWideLayout && mobilePane === "details" ? ( + !open && setMobilePane(null)}> + + Commit details + + + + ) : null} +
+ ); +} + +export default function GitHistoryPanel(props: GitHistoryPanelProps) { + return ; +} diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index f3444cb5fa0f..37fff904997a 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -24,6 +24,7 @@ import { ChevronRight, FileDiff, Files, + GitBranch, Globe2, Plus, TerminalSquare, @@ -121,6 +122,7 @@ interface RightPanelTabsProps { onAddBrowserInProfile: (profileId: string) => void; onAddTerminal: () => void; onAddDiff: () => void; + onAddGitHistory?: (() => void) | undefined; onAddFiles: () => void; onAddPullRequest: () => void; onAddPullRequests: () => void; @@ -135,6 +137,7 @@ interface RightPanelTabsProps { browserAvailable: boolean; terminalAvailable: boolean; diffAvailable: boolean; + gitHistoryAvailable?: boolean | undefined; filesAvailable: boolean; pullRequestAvailable: boolean; pullRequestsAvailable: boolean; @@ -176,6 +179,7 @@ const SURFACE_DISABLED_REASONS = { terminal: "Terminal surfaces are only available from a project thread.", files: "Files are only available when a project is open.", diff: "Diff is only available for server threads in Git repositories.", + gitHistory: "Git History is only available for Git repositories on updated servers.", pullRequest: "This thread's branch has no pull request yet.", issue: "Issues are only available from a project checked out from a host.", pullRequests: "No linked pull requests are available for this thread.", @@ -201,6 +205,7 @@ const SURFACE_UNAVAILABLE_HINTS = { terminal: "Available when a project is open.", files: "Available when a project is open.", diff: "Available for Git repositories.", + gitHistory: "Available for Git repositories on updated servers.", pullRequest: "No pull request on this branch yet.", issue: "Available for projects with a host.", pullRequests: "No linked pull requests available.", @@ -341,6 +346,7 @@ function RightPanelEmptyState(props: { browserProfiles: ReadonlyArray<{ readonly id: string; readonly name: string }>; onAddTerminal: () => void; onAddDiff: () => void; + onAddGitHistory?: (() => void) | undefined; onAddFiles: () => void; onAddPullRequest: () => void; onAddIssue: () => void; @@ -350,6 +356,7 @@ function RightPanelEmptyState(props: { browserAvailable: boolean; terminalAvailable: boolean; diffAvailable: boolean; + gitHistoryAvailable?: boolean | undefined; filesAvailable: boolean; pullRequestAvailable: boolean; issueAvailable: boolean; @@ -398,6 +405,16 @@ function RightPanelEmptyState(props: { onClick: props.onAddDiff, badgeCount: 0, }, + { + label: "History", + description: "Browse commits and branches.", + icon: GitBranch, + shortcut: "H", + available: props.gitHistoryAvailable ?? false, + disabledReason: SURFACE_UNAVAILABLE_HINTS.gitHistory, + onClick: props.onAddGitHistory ?? (() => undefined), + badgeCount: 0, + }, { label: "Pull request", icon: PullRequestGlyph.pullRequest, @@ -650,6 +667,8 @@ function surfaceTitle( switch (surface.kind) { case "diff": return "Diff"; + case "git-history": + return "History"; case "files": return "Files"; case "file": @@ -733,6 +752,8 @@ function SurfaceIcon({ } case "diff": return ; + case "git-history": + return ; case "files": return ; case "file": @@ -964,6 +985,14 @@ export function RightPanelTabs(props: RightPanelTabsProps) { disabledReason: SURFACE_DISABLED_REASONS.diff, onClick: props.onAddDiff, }, + { + label: "History", + icon: GitBranch, + shortcut: "H", + available: props.gitHistoryAvailable ?? false, + disabledReason: SURFACE_DISABLED_REASONS.gitHistory, + onClick: props.onAddGitHistory ?? (() => undefined), + }, { label: "Pull request", icon: PullRequestGlyph.pullRequest, @@ -1475,6 +1504,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { browserProfiles={browserProfiles} onAddTerminal={props.onAddTerminal} onAddDiff={props.onAddDiff} + onAddGitHistory={props.onAddGitHistory} onAddFiles={props.onAddFiles} onAddPullRequest={props.onAddPullRequest} onAddIssue={props.onAddIssue} @@ -1484,6 +1514,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { browserAvailable={props.browserAvailable} terminalAvailable={props.terminalAvailable} diffAvailable={props.diffAvailable} + gitHistoryAvailable={props.gitHistoryAvailable} filesAvailable={props.filesAvailable} pullRequestAvailable={props.pullRequestAvailable} issueAvailable={props.issueAvailable} diff --git a/apps/web/src/components/git-history/GitHistoryCommitDetails.tsx b/apps/web/src/components/git-history/GitHistoryCommitDetails.tsx new file mode 100644 index 000000000000..cfe2ddceb427 --- /dev/null +++ b/apps/web/src/components/git-history/GitHistoryCommitDetails.tsx @@ -0,0 +1,277 @@ +import type { GitCommitChangedFile, GitCommitDetails } from "@t3tools/contracts"; +import type { TimestampFormat } from "@t3tools/contracts/settings"; +import { LegendList } from "@legendapp/list/react"; +import { CheckIcon, FileDiffIcon, RefreshCwIcon } from "lucide-react"; +import { type CSSProperties } from "react"; + +import { cn } from "../../lib/utils"; +import { useTheme } from "../../hooks/useTheme"; +import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; +import { PierreEntryIcon } from "../chat/PierreEntryIcon"; +import { Button } from "../ui/button"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { reportCommitHashCopyFailure } from "./gitHistoryClipboard"; +import { formatCommitDate } from "./GitHistoryCommitList"; + +const FILE_STATUS_COLORS = { + A: "text-emerald-500", + M: "text-amber-500", + D: "text-red-500", + R: "text-sky-500", + C: "text-sky-500", + T: "text-sky-500", + U: "text-red-500", + X: "text-muted-foreground", + B: "text-muted-foreground", +} as const; + +const FILE_STATUS_LABELS = { + A: "Added", + M: "Modified", + D: "Deleted", + R: "Renamed", + C: "Copied", + T: "Type changed", + U: "Unmerged", + X: "Unknown", + B: "Broken pairing", +} as const; + +function CommitFilesTree(props: { + files: ReadonlyArray; + onShowDiff: (path: string) => void; +}) { + const { resolvedTheme } = useTheme(); + return ( + + data={props.files} + keyExtractor={(file) => file.path} + estimatedItemSize={24} + drawDistance={192} + recycleItems + className="h-full" + renderItem={({ item: file }) => ( + + props.onShowDiff(file.path)} + aria-label={`${FILE_STATUS_LABELS[file.status]} ${file.path}; show diff`} + > + + {file.status} + + + {file.path} + + } + /> + {`${FILE_STATUS_LABELS[file.status]} ${file.path}; show diff`} + + )} + /> + ); +} + +export function CommitDetailsPane(props: { + className?: string; + style?: CSSProperties; + id?: string; + details: GitCommitDetails | null; + timestampFormat: TimestampFormat; + files: ReadonlyArray; + filesCapped: boolean; + filesHasMore: boolean; + filesError: boolean; + filesLoading: boolean; + onLoadMoreFiles: () => void; + onRetryFiles: () => void; + isPending: boolean; + hasError: boolean; + hasSelection: boolean; + onRetry: () => void; + onShowDiff: (hash: string, filePath?: string) => void; +}) { + const { copyToClipboard, isCopied } = useCopyToClipboard({ + target: "commit hash", + onError: reportCommitHashCopyFailure, + }); + if (!props.hasSelection) + return ( + + ); + if (props.hasError) + return ( + + ); + if (props.isPending || props.details === null) + return ( + + ); + const details = props.details; + const authoredAtLabel = formatCommitDate(details.authoredAt, props.timestampFormat); + return ( + + ); +} diff --git a/apps/web/src/components/git-history/GitHistoryCommitDiff.tsx b/apps/web/src/components/git-history/GitHistoryCommitDiff.tsx new file mode 100644 index 000000000000..db7628cecea2 --- /dev/null +++ b/apps/web/src/components/git-history/GitHistoryCommitDiff.tsx @@ -0,0 +1,142 @@ +import type { GitCommitChangedFile } from "@t3tools/contracts"; +import { FileDiff } from "@pierre/diffs/react"; +import { FileDiffIcon, RefreshCwIcon } from "lucide-react"; +import { useMemo } from "react"; + +import { + getRenderablePatch, + DIFF_SURFACE_THEME_UNSAFE_CSS, + resolveDiffThemeName, + resolveFileDiffPath, +} from "../../lib/diffRendering"; +import { useTheme } from "../../hooks/useTheme"; +import { Button } from "../ui/button"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; + +export function CommitDiffView(props: { + hash: string; + filePath?: string; + files: ReadonlyArray; + filesError: boolean; + filesHasMore: boolean; + filesLoading: boolean; + diff: string | null; + truncated: boolean; + isPending: boolean; + error: string | null; + onBack: () => void; + onSelectFile: (filePath?: string) => void; + onRetry: () => void; + onLoadMoreFiles: () => void; + onRetryFiles: () => void; +}) { + const { resolvedTheme } = useTheme(); + const renderable = useMemo( + () => + getRenderablePatch( + props.diff ?? undefined, + `git-history:${props.hash}:${props.filePath ?? "all"}`, + ), + [props.diff, props.filePath, props.hash], + ); + return ( +
+
+ + + + {props.filePath ?? `Commit ${props.hash.slice(0, 8)}`} + + + {props.truncated ? truncated : null} + {!props.filesError && (props.filesHasMore || props.filesLoading) ? ( + + ) : null} + {props.filesError ? ( + + ) : null} +
+
+ {props.isPending ? ( +
+ Loading diff… +
+ ) : props.error ? ( +
+ {props.error} + +
+ ) : renderable?.kind === "files" ? ( +
+ {renderable.files.map((fileDiff) => ( +
+ + {fileDiff.hunks.length === 0 ? ( +
+ Binary or metadata-only change; no textual diff is available. +
+ ) : null} +
+ ))} +
+ ) : renderable?.kind === "raw" ? ( +
{renderable.text}
+ ) : ( +
+ This commit has no textual diff. +
+ )} +
+
+ ); +} diff --git a/apps/web/src/components/git-history/GitHistoryCommitList.tsx b/apps/web/src/components/git-history/GitHistoryCommitList.tsx new file mode 100644 index 000000000000..2c1db70c27d5 --- /dev/null +++ b/apps/web/src/components/git-history/GitHistoryCommitList.tsx @@ -0,0 +1,356 @@ +import type { GitHistoryCommit } from "@t3tools/contracts"; +import type { TimestampFormat } from "@t3tools/contracts/settings"; +import { CheckIcon, CircleDotIcon, CloudIcon, GitBranchIcon, TagIcon } from "lucide-react"; +import * as Cause from "effect/Cause"; + +import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; +import type { GitHistoryGraphRow } from "../../lib/gitHistoryGraph"; +import { cn } from "../../lib/utils"; +import { formatShortTimestamp, parseTimestampDate } from "../../timestampFormat"; +import { reportCommitHashCopyFailure } from "./gitHistoryClipboard"; +import type { CommitRefKind, GitHistoryRow } from "./GitHistoryVisualTypes"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { PullRequestGlyph } from "../pullRequest/pullRequestIcons"; + +const GIT_HISTORY_ROW_HEIGHT_REM = 1.875; +const DEFAULT_INTERFACE_FONT_SIZE = 16; +export const GIT_HISTORY_ROW_HEIGHT = GIT_HISTORY_ROW_HEIGHT_REM * DEFAULT_INTERFACE_FONT_SIZE; +const LANE_WIDTH = 11; +const GRAPH_HORIZONTAL_PADDING = 6; +const MAX_GRAPH_WIDTH = 104; +const GRAPH_COLORS = ["#4f9cff", "#b26cff", "#f59e0b", "#22c55e", "#ec4899", "#14b8a6"] as const; + +export function queryErrorMessage(cause: Cause.Cause): string { + const error = Cause.squash(cause); + return error instanceof Error && error.message.trim().length > 0 + ? error.message + : "Could not load Git history."; +} + +export function formatCommitDate(value: string, timestampFormat: TimestampFormat): string { + const date = parseTimestampDate(value); + if (!date) return value; + const now = new Date(); + const elapsedMs = now.valueOf() - date.valueOf(); + if (elapsedMs >= 0 && elapsedMs < 60 * 60 * 1_000) { + const minutes = Math.max(1, Math.floor(elapsedMs / 60_000)); + return `${minutes} minute${minutes === 1 ? "" : "s"} ago`; + } + const time = formatShortTimestamp(value, timestampFormat); + const sameDay = + date.getFullYear() === now.getFullYear() && + date.getMonth() === now.getMonth() && + date.getDate() === now.getDate(); + if (sameDay) return `Today ${time}`; + const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1); + const isYesterday = + date.getFullYear() === yesterday.getFullYear() && + date.getMonth() === yesterday.getMonth() && + date.getDate() === yesterday.getDate(); + if (isYesterday) return `Yesterday ${time}`; + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")} ${time}`; +} + +function pullRequestNumberFromSubject(subject: string): string | null { + return /^Merge pull request #(\d+)\b/i.exec(subject)?.[1] ?? null; +} + +function CommitRefDecoration(props: { + refName: string; + refKinds: ReadonlyMap; +}) { + const refTarget = props.refName.split(" -> ", 1)[0] ?? props.refName; + const explicitTag = props.refName.startsWith("tag: "); + const label = explicitTag ? props.refName.slice(5) : props.refName; + const kind = props.refName.startsWith("HEAD -> ") + ? "head" + : explicitTag + ? "tag" + : (props.refKinds.get(refTarget) ?? "unknown"); + const Icon = + kind === "head" + ? CircleDotIcon + : kind === "remote" + ? CloudIcon + : kind === "tag" + ? TagIcon + : GitBranchIcon; + return ( + + + + {label} + + } + /> + {`${kind === "head" ? "HEAD" : kind === "remote" ? "Remote branch" : kind === "tag" ? "Tag" : kind === "local" ? "Local branch" : "Unresolved branch"}: ${label}`} + + ); +} + +export function graphColumnWidth(laneCount: number): number { + return Math.min( + Math.max(LANE_WIDTH * laneCount + GRAPH_HORIZONTAL_PADDING * 2, 44), + MAX_GRAPH_WIDTH, + ); +} + +export function gitHistoryRowHeight(interfaceFontSize: number): number { + return GIT_HISTORY_ROW_HEIGHT_REM * interfaceFontSize; +} + +export function currentHeadHash(commits: ReadonlyArray): string | undefined { + return ( + commits.find((commit) => + commit.refs.some((ref) => ref === "HEAD" || ref.startsWith("HEAD -> ")), + )?.hash ?? commits[0]?.hash + ); +} + +export function firstParentHashes( + commits: ReadonlyArray, + headHash: string | undefined, +): ReadonlySet { + const commitsByHash = new Map(commits.map((commit) => [commit.hash, commit])); + const hashes = new Set(); + let hash = headHash; + while (hash && !hashes.has(hash)) { + hashes.add(hash); + hash = commitsByHash.get(hash)?.parentHashes[0]; + } + return hashes; +} + +function GraphCell(props: { + graph: GitHistoryGraphRow; + laneCount: number; + rowHeight: number; + selected: boolean; + current: boolean; +}) { + const width = graphColumnWidth(props.laneCount); + const centerY = props.rowHeight / 2; + const laneWidth = Math.min( + LANE_WIDTH, + (width - GRAPH_HORIZONTAL_PADDING * 2) / Math.max(props.laneCount, 1), + ); + const x = (lane: number) => lane * laneWidth + GRAPH_HORIZONTAL_PADDING + laneWidth / 2; + const { edges } = props.graph; + return ( + + ); +} + +export function CommitRow(props: { + row: GitHistoryRow; + laneCount: number; + rowHeight?: number; + refKinds: ReadonlyMap; + timestampFormat: TimestampFormat; + selected: boolean; + onSelect: (hash: string) => void; +}) { + const { commit } = props.row; + const rowHeight = props.rowHeight ?? GIT_HISTORY_ROW_HEIGHT; + const { copyToClipboard, isCopied } = useCopyToClipboard({ + target: "commit hash", + onError: reportCommitHashCopyFailure, + }); + const pullRequestNumber = pullRequestNumberFromSubject(commit.subject); + const isMergeCommit = commit.parentHashes.length > 1 || /^Merge\b/i.test(commit.subject); + const authoredAtLabel = formatCommitDate(commit.authoredAt, props.timestampFormat); + return ( +
+ + } + /> + + {isCopied ? "Commit hash copied" : `Copy full commit hash ${commit.hash}`} + + + + {isCopied ? `Copied commit hash ${commit.hash}` : ""} + +
+ ); +} diff --git a/apps/web/src/components/git-history/GitHistoryPaneResizeHandle.tsx b/apps/web/src/components/git-history/GitHistoryPaneResizeHandle.tsx new file mode 100644 index 000000000000..594333f18169 --- /dev/null +++ b/apps/web/src/components/git-history/GitHistoryPaneResizeHandle.tsx @@ -0,0 +1,92 @@ +import { GripVerticalIcon } from "lucide-react"; +import { useEffect, useRef } from "react"; + +export function PaneResizeHandle(props: { + label: string; + value: number; + min: number; + max: number; + onMove: (delta: number) => void; + onReset: () => void; +}) { + const onMoveRef = useRef(props.onMove); + const dragStateRef = useRef<{ + pointerId: number; + lastClientX: number; + pendingDelta: number; + rafId: number | null; + } | null>(null); + onMoveRef.current = props.onMove; + + useEffect(() => { + return () => { + const state = dragStateRef.current; + if (state && state.rafId !== null) cancelAnimationFrame(state.rafId); + dragStateRef.current = null; + }; + }, []); + + return ( +
{ + dragStateRef.current = { + pointerId: event.pointerId, + lastClientX: event.clientX, + pendingDelta: 0, + rafId: null, + }; + event.currentTarget.setPointerCapture(event.pointerId); + }} + onPointerMove={(event) => { + const state = dragStateRef.current; + if (!state || state.pointerId !== event.pointerId) return; + state.pendingDelta += event.clientX - state.lastClientX; + state.lastClientX = event.clientX; + if (state.rafId !== null) return; + state.rafId = requestAnimationFrame(() => { + const active = dragStateRef.current; + if (!active) return; + active.rafId = null; + const delta = active.pendingDelta; + active.pendingDelta = 0; + if (delta !== 0) onMoveRef.current(delta); + }); + }} + onPointerUp={(event) => { + const state = dragStateRef.current; + if (!state || state.pointerId !== event.pointerId) return; + state.pendingDelta += event.clientX - state.lastClientX; + if (state.rafId !== null) cancelAnimationFrame(state.rafId); + const delta = state.pendingDelta; + dragStateRef.current = null; + event.currentTarget.releasePointerCapture(event.pointerId); + if (delta !== 0) onMoveRef.current(delta); + }} + onLostPointerCapture={(event) => { + const state = dragStateRef.current; + if (!state || state.pointerId !== event.pointerId) return; + if (state.rafId !== null) cancelAnimationFrame(state.rafId); + const delta = state.pendingDelta; + dragStateRef.current = null; + if (delta !== 0) onMoveRef.current(delta); + }} + onDoubleClick={props.onReset} + onKeyDown={(event) => { + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; + event.preventDefault(); + props.onMove(event.key === "ArrowLeft" ? -16 : 16); + }} + > +
+ +
+ ); +} diff --git a/apps/web/src/components/git-history/GitHistoryRefsPane.test.ts b/apps/web/src/components/git-history/GitHistoryRefsPane.test.ts new file mode 100644 index 000000000000..3c75137d36e5 --- /dev/null +++ b/apps/web/src/components/git-history/GitHistoryRefsPane.test.ts @@ -0,0 +1,81 @@ +import type { VcsHistoryRef } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { buildGitRefTree } from "../../lib/gitRefTree"; +import { buildRefPaneRows } from "./GitHistoryRefsPane"; + +function ref(name: string, kind: "local" | "tag" = "local"): VcsHistoryRef { + return { + current: false, + isDefault: false, + kind, + name, + worktreePath: null, + }; +} + +describe("buildRefPaneRows", () => { + it("keeps a large expanded tag hierarchy as list data instead of render items", () => { + const rows = buildRefPaneRows({ + localRefTree: [], + favoriteRefs: [], + remoteRefTree: [], + tagRefTree: buildGitRefTree( + Array.from({ length: 10_000 }, (_, index) => ref(`release/${index}`, "tag")), + ), + expandedRefKeys: new Set(["section:local", "section:tags", "tags:release"]), + filterActive: false, + hasMoreRefs: false, + refPaginationError: null, + }); + + expect(rows).toHaveLength(10_006); + expect(rows.filter((row) => row.kind === "ref")).toHaveLength(10_000); + expect(rows.find((row) => row.key === "section:tags")).toMatchObject({ + count: 10_000, + open: true, + }); + }); + + it("opens tag descendants while filtering without changing folder keys", () => { + const rows = buildRefPaneRows({ + localRefTree: [], + favoriteRefs: [], + remoteRefTree: [], + tagRefTree: buildGitRefTree([ref("release/v1", "tag")]), + expandedRefKeys: new Set(), + filterActive: true, + hasMoreRefs: false, + refPaginationError: null, + }); + + expect(rows.map((row) => row.key)).toContain("tags:release"); + expect(rows.map((row) => row.key)).toContain("refs/tags/release/v1"); + }); + + it("projects loaded favorite local refs before their canonical Local rows", () => { + const favorite = ref("feature/favorite"); + const rows = buildRefPaneRows({ + localRefTree: buildGitRefTree([favorite]), + favoriteRefs: [favorite], + remoteRefTree: [], + tagRefTree: [], + expandedRefKeys: new Set(["section:local", "local:feature"]), + filterActive: false, + hasMoreRefs: false, + refPaginationError: null, + }); + + expect(rows.map((row) => row.key)).toEqual([ + "all", + "current", + "favorite:refs/heads/feature/favorite", + "section:local", + "local:feature", + "refs/heads/feature/favorite", + "section:remote", + "section:tags", + ]); + expect(rows[2]).toMatchObject({ node: { name: "feature/favorite" } }); + }); +}); diff --git a/apps/web/src/components/git-history/GitHistoryRefsPane.tsx b/apps/web/src/components/git-history/GitHistoryRefsPane.tsx new file mode 100644 index 000000000000..51a30d8640af --- /dev/null +++ b/apps/web/src/components/git-history/GitHistoryRefsPane.tsx @@ -0,0 +1,498 @@ +import type { VcsHistoryRef } from "@t3tools/contracts"; +import { LegendList } from "@legendapp/list/react"; +import { + ArrowDownIcon, + ArrowUpIcon, + ChevronDownIcon, + ChevronRightIcon, + FolderIcon, + FolderOpenIcon, + GitBranchIcon, + GitCommitHorizontalIcon, + SearchIcon, + StarIcon, + TagIcon, + XIcon, +} from "lucide-react"; +import { type CSSProperties, useMemo } from "react"; + +import type { GitRefTreeNode } from "../../lib/gitRefTree"; +import { cn } from "../../lib/utils"; +import { Button } from "../ui/button"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +type RefNamespace = "heads" | "remotes" | "tags"; + +type RefPaneRow = + | { readonly kind: "all"; readonly key: "all" } + | { readonly kind: "current"; readonly key: "current" } + | { + readonly kind: "ref"; + readonly key: string; + readonly node: Extract; + readonly namespace: "heads"; + readonly depth: number; + readonly projectedFavorite: true; + } + | { + readonly kind: "section"; + readonly key: string; + readonly label: string; + readonly count: number; + readonly open: boolean; + } + | { + readonly kind: "folder"; + readonly key: string; + readonly label: string; + readonly path: string; + readonly depth: number; + readonly open: boolean; + } + | { + readonly kind: "ref"; + readonly key: string; + readonly node: Extract; + readonly namespace: RefNamespace; + readonly depth: number; + readonly projectedFavorite?: false; + } + | { readonly kind: "empty"; readonly key: "empty" } + | { readonly kind: "error"; readonly key: "error"; readonly message: string } + | { readonly kind: "load-more"; readonly key: "load-more" }; + +function appendRefTreeRows( + rows: RefPaneRow[], + nodes: ReadonlyArray, + section: string, + namespace: RefNamespace, + expanded: ReadonlySet, + filterActive: boolean, + depth = 0, +): number { + let count = 0; + for (const node of nodes) { + if (node.kind === "ref") { + count += 1; + rows.push({ kind: "ref", key: `refs/${namespace}/${node.ref.name}`, node, namespace, depth }); + continue; + } + const key = `${section}:${node.path}`; + const open = filterActive || expanded.has(key); + rows.push({ kind: "folder", key, label: node.name, path: node.path, depth, open }); + if (open) + count += appendRefTreeRows( + rows, + node.children, + section, + namespace, + expanded, + filterActive, + depth + 1, + ); + else count += countRefTreeRefs(node.children); + } + return count; +} + +function countRefTreeRefs(nodes: ReadonlyArray): number { + return nodes.reduce( + (total, node) => total + (node.kind === "ref" ? 1 : countRefTreeRefs(node.children)), + 0, + ); +} + +export function buildRefPaneRows(props: { + readonly localRefTree: ReadonlyArray; + readonly favoriteRefs: ReadonlyArray; + readonly remoteRefTree: ReadonlyArray; + readonly tagRefTree: ReadonlyArray; + readonly expandedRefKeys: ReadonlySet; + readonly filterActive: boolean; + readonly hasMoreRefs: boolean; + readonly refPaginationError: string | null; +}): ReadonlyArray { + const rows: RefPaneRow[] = [ + { kind: "all", key: "all" }, + { kind: "current", key: "current" }, + ]; + rows.push( + ...props.favoriteRefs.map((ref) => ({ + kind: "ref" as const, + key: `favorite:refs/heads/${ref.name}`, + node: { kind: "ref" as const, name: ref.name, ref }, + namespace: "heads" as const, + depth: 0, + projectedFavorite: true as const, + })), + ); + for (const section of [ + { label: "Local", section: "local", nodes: props.localRefTree, namespace: "heads" as const }, + { + label: "Remote", + section: "remote", + nodes: props.remoteRefTree, + namespace: "remotes" as const, + }, + { label: "Tags", section: "tags", nodes: props.tagRefTree, namespace: "tags" as const }, + ]) { + const open = props.filterActive || props.expandedRefKeys.has(`section:${section.section}`); + const sectionRows: RefPaneRow[] = []; + const count = open + ? appendRefTreeRows( + sectionRows, + section.nodes, + section.section, + section.namespace, + props.expandedRefKeys, + props.filterActive, + ) + : countRefTreeRefs(section.nodes); + rows.push({ + kind: "section", + key: `section:${section.section}`, + label: section.label, + count, + open, + }); + if (open) rows.push(...sectionRows); + } + if ( + props.filterActive && + props.localRefTree.length + props.remoteRefTree.length + props.tagRefTree.length === 0 + ) + rows.push({ kind: "empty", key: "empty" }); + if (props.refPaginationError) + rows.push({ kind: "error", key: "error", message: props.refPaginationError }); + if (props.hasMoreRefs) rows.push({ kind: "load-more", key: "load-more" }); + return rows; +} + +export function GitRefsPane(props: { + className?: string; + style?: CSSProperties; + id?: string; + refFilter: string; + onRefFilterChange: (value: string) => void; + selectedRevision: { label: string; revision: string } | null; + onSelectAll: () => void; + currentRef: VcsHistoryRef | null; + onSelectRef: (label: string, revision: string) => void; + normalizedRefFilter: string; + localRefTree: ReadonlyArray; + favoriteRefs: ReadonlyArray; + favoriteBranches: ReadonlySet; + onToggleFavorite: (branch: string) => void; + remoteRefTree: ReadonlyArray; + tagRefTree: ReadonlyArray; + expandedRefKeys: ReadonlySet; + onToggleRefKey: (key: string) => void; + hasMoreRefs: boolean; + isFetchingMoreRefs: boolean; + isRefSnapshotComplete: boolean; + onLoadMoreRefs: () => void; + refPaginationError: string | null; + onRetryRefs: () => void; + onClose?: () => void; +}) { + const rows = useMemo( + () => + buildRefPaneRows({ + localRefTree: props.localRefTree, + favoriteRefs: props.favoriteRefs, + remoteRefTree: props.remoteRefTree, + tagRefTree: props.tagRefTree, + expandedRefKeys: props.expandedRefKeys, + filterActive: props.normalizedRefFilter.length > 0, + hasMoreRefs: props.hasMoreRefs, + refPaginationError: props.refPaginationError, + }), + [ + props.expandedRefKeys, + props.favoriteRefs, + props.hasMoreRefs, + props.localRefTree, + props.normalizedRefFilter, + props.refPaginationError, + props.remoteRefTree, + props.tagRefTree, + ], + ); + return ( + + ); +} diff --git a/apps/web/src/components/git-history/GitHistoryVisualTypes.ts b/apps/web/src/components/git-history/GitHistoryVisualTypes.ts new file mode 100644 index 000000000000..98e1978948c9 --- /dev/null +++ b/apps/web/src/components/git-history/GitHistoryVisualTypes.ts @@ -0,0 +1,9 @@ +import type { GitHistoryCommit } from "@t3tools/contracts"; + +import type { GitHistoryGraphRow } from "../../lib/gitHistoryGraph"; +export interface GitHistoryRow { + commit: GitHistoryCommit; + graph: GitHistoryGraphRow; +} + +export type CommitRefKind = "head" | "local" | "remote" | "tag" | "unknown"; diff --git a/apps/web/src/components/git-history/gitHistoryClipboard.ts b/apps/web/src/components/git-history/gitHistoryClipboard.ts new file mode 100644 index 000000000000..636d4c67c555 --- /dev/null +++ b/apps/web/src/components/git-history/gitHistoryClipboard.ts @@ -0,0 +1,11 @@ +import { stackedThreadToast, toastManager } from "../ui/toast"; + +export function reportCommitHashCopyFailure(error: Error): void { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not copy commit hash", + description: error.message, + }), + ); +} diff --git a/apps/web/src/components/git-history/useGitHistoryRefs.test.tsx b/apps/web/src/components/git-history/useGitHistoryRefs.test.tsx new file mode 100644 index 000000000000..6f800267481c --- /dev/null +++ b/apps/web/src/components/git-history/useGitHistoryRefs.test.tsx @@ -0,0 +1,445 @@ +import { EnvironmentId, type VcsHistoryRef } from "@t3tools/contracts"; +import type { ReactElement } from "react"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { reactHookHarness as hooks } from "../../test/reactHookHarness"; +import { visitElements } from "../../test/reactElementTree"; + +const refState = vi.hoisted(() => ({ + currentRefResolved: true, + currentRef: null as VcsHistoryRef | null, + localError: null as string | null, + debouncedRefFilter: "", + local: [] as ReadonlyArray, + remote: [] as ReadonlyArray, + tags: [] as ReadonlyArray, + favoriteBranches: [] as ReadonlyArray, + repositoryKey: "C:/repositories/t3code/.git" as string | null, + isComplete: true, + nextCursor: null as string | null, + refreshLocal: vi.fn(), + refreshRemote: vi.fn(), + refreshTags: vi.fn(), + remoteGeneration: 0, + remoteRequestKeys: [] as string[], + favoriteStorageKeys: [] as Array, + targets: [] as Array<{ + readonly target: unknown; + readonly namespace: string; + readonly revision: number | undefined; + }>, +})); + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + const { reactHookHarness } = await import("../../test/reactHookHarness"); + return { + ...actual, + useCallback: reactHookHarness.useCallback, + useDeferredValue: (value: Value) => value, + useEffect: () => undefined, + useMemo: reactHookHarness.useMemo, + useRef: reactHookHarness.useRef, + useState: reactHookHarness.useState, + }; +}); + +vi.mock("react/compiler-runtime", async () => { + const { reactHookHarness } = await import("../../test/reactHookHarness"); + return { c: reactHookHarness.useMemoCache }; +}); + +vi.mock("../../hooks/useLocalStorage", () => ({ + useLocalStorage: (key: string | null) => { + refState.favoriteStorageKeys.push(key); + return [ + refState.favoriteBranches, + ( + next: ReadonlyArray | ((current: ReadonlyArray) => ReadonlyArray), + ) => { + refState.favoriteBranches = + typeof next === "function" ? next(refState.favoriteBranches) : next; + }, + ]; + }, +})); + +vi.mock("../../state/queries", () => ({ + useDebouncedValue: () => refState.debouncedRefFilter, + usePaginatedHistoryRefs: ( + _target: unknown, + options: { readonly namespace: string; readonly revision?: number }, + ) => { + refState.targets.push({ + target: _target, + namespace: options.namespace, + revision: options.revision, + }); + const refs = + options.namespace === "local" + ? refState.local + : options.namespace === "remote" + ? refState.remote + : refState.tags; + const target = _target as { readonly environmentId: EnvironmentId | null }; + if (options.namespace === "remote" && target.environmentId !== null) { + refState.remoteRequestKeys.push(`remote:${refState.remoteGeneration}`); + } + return { + data: + target.environmentId === null || + (options.namespace === "local" && !refState.currentRefResolved) + ? null + : { + currentRef: refState.currentRef, + repositoryKey: refState.repositoryKey, + nextCursor: refState.nextCursor, + isComplete: refState.isComplete, + }, + refs, + error: options.namespace === "local" ? refState.localError : null, + isFetchingNextPage: false, + loadNext: vi.fn(), + refresh: + options.namespace === "local" + ? refState.refreshLocal + : options.namespace === "remote" + ? () => { + refState.remoteGeneration += 1; + refState.refreshRemote(); + } + : refState.refreshTags, + retry: vi.fn(), + }; + }, +})); + +import { GitRefsPane } from "./GitHistoryRefsPane"; +import { toggleGitHistoryFavorite, useGitHistoryRefs } from "./useGitHistoryRefs"; + +const environmentId = EnvironmentId.make("environment-local"); +const repositoryCwd = "C:/workspace"; + +function ref(name: string, kind: "local" | "remote" = "local"): VcsHistoryRef { + return { current: false, isDefault: false, kind, name, worktreePath: null }; +} + +function renderRefs(revision = 0) { + hooks.beginRender(); + const historyRefs = useGitHistoryRefs(environmentId, repositoryCwd, revision); + const pane = GitRefsPane({ + refFilter: historyRefs.refFilter, + onRefFilterChange: historyRefs.setRefFilter, + selectedRevision: historyRefs.selectedRevision ?? null, + onSelectAll: historyRefs.selectAllRefs, + currentRef: historyRefs.currentRef ?? null, + onSelectRef: historyRefs.selectRef, + normalizedRefFilter: historyRefs.normalizedRefFilter, + localRefTree: historyRefs.localRefTree, + favoriteRefs: historyRefs.favoriteRefs, + favoriteBranches: historyRefs.favoriteBranches, + onToggleFavorite: historyRefs.toggleFavorite, + remoteRefTree: historyRefs.remoteRefTree, + tagRefTree: historyRefs.tagRefTree, + expandedRefKeys: historyRefs.expandedRefKeys, + onToggleRefKey: historyRefs.toggleRefKey, + hasMoreRefs: historyRefs.hasMoreRefs, + isFetchingMoreRefs: historyRefs.isFetchingMoreRefs, + isRefSnapshotComplete: historyRefs.isRefSnapshotComplete, + onLoadMoreRefs: historyRefs.onLoadMoreRefs, + refPaginationError: historyRefs.refPaginationError, + onRetryRefs: historyRefs.onRetryRefs, + }) as ReactElement>; + const list = visitElements(pane, (element) => typeof element.props.keyExtractor === "function"); + expect(list).not.toBeNull(); + const capStatus = visitElements(pane, (element) => element.props.role === "status"); + return { historyRefs, rows: list!.props.data as ReadonlyArray, capStatus }; +} + +describe("useGitHistoryRefs", () => { + beforeEach(() => { + hooks.reset(); + refState.currentRefResolved = true; + refState.currentRef = null; + refState.localError = null; + refState.debouncedRefFilter = ""; + refState.local = []; + refState.remote = []; + refState.tags = []; + refState.favoriteBranches = []; + refState.repositoryKey = "C:/repositories/t3code/.git"; + refState.isComplete = true; + refState.nextCursor = null; + refState.refreshLocal.mockReset(); + refState.refreshRemote.mockReset(); + refState.refreshTags.mockReset(); + refState.remoteGeneration = 0; + refState.remoteRequestKeys = []; + refState.favoriteStorageKeys = []; + refState.targets = []; + }); + + it("persists favorites under the environment repository key", () => { + renderRefs(); + + expect(refState.favoriteStorageKeys).toEqual([ + "t3code:git-history-favorites:v1:environment-local:C:/repositories/t3code/.git", + ]); + }); + + it("does not use a placeholder key before the repository resolves", () => { + refState.currentRefResolved = false; + + renderRefs(); + + expect(refState.favoriteStorageKeys).toEqual([null]); + }); + + it("toggles branch membership", () => { + expect(toggleGitHistoryFavorite([], "feature/favorite")).toEqual(["feature/favorite"]); + expect(toggleGitHistoryFavorite(["feature/favorite"], "feature/favorite")).toEqual([]); + }); + + it("preserves ref trees and the 10k-row virtual-list model across unchanged rerenders", () => { + refState.local = Array.from({ length: 5_000 }, (_, index) => ref(`feature-${index}`)); + refState.remote = Array.from({ length: 5_000 }, (_, index) => ref(`origin-${index}`, "remote")); + + const collapsed = renderRefs(); + collapsed.historyRefs.toggleRefKey("section:remote"); + const first = renderRefs(); + const second = renderRefs(); + + expect(second.historyRefs.localRefs).toBe(first.historyRefs.localRefs); + expect(second.historyRefs.remoteRefs).toBe(first.historyRefs.remoteRefs); + expect(second.historyRefs.localRefTree).toBe(first.historyRefs.localRefTree); + expect(second.historyRefs.remoteRefTree).toBe(first.historyRefs.remoteRefTree); + expect(second.rows).toBe(first.rows); + expect(second.rows).toHaveLength(10_005); + }); + + it("states the first-10,000 cap when the server snapshot is incomplete", () => { + refState.local = Array.from({ length: 5_000 }, (_, index) => ref(`feature-${index}`)); + refState.remote = Array.from({ length: 5_000 }, (_, index) => ref(`origin-${index}`, "remote")); + refState.isComplete = false; + + const rendered = renderRefs(); + + expect(rendered.capStatus?.props.children).toBe("Showing the first 10,000 matching refs."); + }); + + it("refreshes every ref namespace", () => { + const rendered = renderRefs(); + + rendered.historyRefs.refreshRefs(); + + expect(refState.refreshLocal).toHaveBeenCalledOnce(); + expect(refState.refreshRemote).toHaveBeenCalledOnce(); + expect(refState.refreshTags).toHaveBeenCalledOnce(); + + rendered.historyRefs.toggleRefKey("section:remote"); + rendered.historyRefs.toggleRefKey("section:tags"); + const expanded = renderRefs(); + + expanded.historyRefs.refreshRefs(); + + expect(refState.refreshLocal).toHaveBeenCalledTimes(2); + expect(refState.refreshRemote).toHaveBeenCalledTimes(2); + expect(refState.refreshTags).toHaveBeenCalledTimes(2); + }); + + it("refreshes a collapsed remote namespace before loading it again", () => { + const initial = renderRefs(); + initial.historyRefs.toggleRefKey("section:remote"); + const expanded = renderRefs(); + expect(refState.remoteRequestKeys).toEqual(["remote:0"]); + + expanded.historyRefs.toggleRefKey("section:remote"); + const collapsed = renderRefs(); + expect(refState.remoteRequestKeys).toEqual(["remote:0"]); + + collapsed.historyRefs.refreshRefs(); + expect(refState.refreshRemote).toHaveBeenCalledOnce(); + expect(refState.remoteGeneration).toBe(1); + + collapsed.historyRefs.toggleRefKey("section:remote"); + renderRefs(); + + expect(refState.remoteRequestKeys).toEqual(["remote:0", "remote:1"]); + }); + + it("keeps the history revision unresolved until the local ref snapshot resolves", () => { + refState.currentRefResolved = false; + + const rendered = renderRefs(); + + expect(rendered.historyRefs.currentRef).toBeUndefined(); + expect(rendered.historyRefs.selectedRevision).toBeUndefined(); + }); + + it("falls back to all history when an unresolved local ref request fails after rendering", () => { + refState.currentRefResolved = false; + + expect(renderRefs().historyRefs.selectedRevision).toBeUndefined(); + + refState.localError = "Could not load refs."; + + expect(renderRefs().historyRefs.selectedRevision).toBeNull(); + }); + + it("keeps the resolved current branch selected while a ref filter refresh is pending", () => { + refState.currentRef = ref("main"); + + renderRefs(); + refState.currentRefResolved = false; + + expect(renderRefs().historyRefs.selectedRevision).toEqual({ + label: "main", + revision: "refs/heads/main", + }); + }); + + it("keeps the resolved current branch selected when a local ref refresh fails", () => { + refState.currentRef = ref("main"); + refState.localError = "Could not refresh refs."; + + expect(renderRefs().historyRefs.selectedRevision).toEqual({ + label: "main", + revision: "refs/heads/main", + }); + }); + + it("falls back to all history when the initial local ref request fails", () => { + refState.currentRefResolved = false; + refState.localError = "Could not load refs."; + + const rendered = renderRefs(); + + expect(rendered.historyRefs.selectedRevision).toBeNull(); + expect(rendered.historyRefs.refPaginationError).toBe("Could not load refs."); + }); + + it("keeps the current local branch loaded after collapsing Local", () => { + refState.currentRef = ref("main"); + + const initial = renderRefs(); + initial.historyRefs.toggleRefKey("section:local"); + refState.targets = []; + + const collapsed = renderRefs(); + + expect(refState.targets).toContainEqual({ + namespace: "local", + revision: 0, + target: { environmentId, cwd: repositoryCwd, query: "" }, + }); + expect(collapsed.historyRefs.currentRef?.name).toBe("main"); + expect(collapsed.historyRefs.selectedRevision).toEqual({ + label: "main", + revision: "refs/heads/main", + }); + }); + + it("keeps the default selected revision stable across unchanged rerenders", () => { + refState.currentRef = ref("main"); + + const first = renderRefs(); + const second = renderRefs(); + + expect(second.historyRefs.selectedRevision).toBe(first.historyRefs.selectedRevision); + }); + + it("falls back to the current branch when a completed snapshot removes the selected local ref", () => { + refState.currentRef = ref("main"); + refState.local = [ref("main"), ref("feature/renamed")]; + + const initial = renderRefs(); + initial.historyRefs.selectRef("feature/renamed", "refs/heads/feature/renamed"); + expect(renderRefs().historyRefs.selectedRevision).toEqual({ + label: "feature/renamed", + revision: "refs/heads/feature/renamed", + }); + + initial.historyRefs.setRefFilter("main"); + refState.local = [ref("main")]; + refState.debouncedRefFilter = "main"; + + expect(renderRefs(1).historyRefs.selectedRevision).toEqual({ + label: "feature/renamed", + revision: "refs/heads/feature/renamed", + }); + + initial.historyRefs.setRefFilter(""); + refState.debouncedRefFilter = ""; + refState.nextCursor = "cursor-2"; + + expect(renderRefs(1).historyRefs.selectedRevision).toEqual({ + label: "feature/renamed", + revision: "refs/heads/feature/renamed", + }); + + refState.nextCursor = null; + + expect(renderRefs(1).historyRefs.selectedRevision).toEqual({ + label: "main", + revision: "refs/heads/main", + }); + }); + + it("keeps the selected remote or tag namespace loaded while its section is collapsed", () => { + const initial = renderRefs(); + initial.historyRefs.selectRef("origin/feature", "refs/remotes/origin/feature"); + refState.targets = []; + + renderRefs(); + + expect(refState.targets).toContainEqual({ + namespace: "remote", + revision: 0, + target: { environmentId, cwd: repositoryCwd, query: "" }, + }); + + initial.historyRefs.selectRef("v1.0.0", "refs/tags/v1.0.0"); + refState.targets = []; + + renderRefs(); + + expect(refState.targets).toContainEqual({ + namespace: "tag", + revision: 0, + target: { environmentId, cwd: repositoryCwd, query: "" }, + }); + }); + + it("passes a repository revision to every history ref namespace", () => { + renderRefs(3); + + expect(refState.targets).toEqual([ + { + namespace: "local", + revision: 3, + target: { environmentId, cwd: repositoryCwd, query: "" }, + }, + { namespace: "remote", revision: 3, target: { environmentId: null, cwd: null } }, + { namespace: "tag", revision: 3, target: { environmentId: null, cwd: null } }, + ]); + }); + + it("waits for the ref filter debounce before querying collapsed remote and tag namespaces", () => { + const first = renderRefs(); + first.historyRefs.setRefFilter("release"); + refState.targets = []; + + renderRefs(); + + expect(refState.targets).toContainEqual({ + namespace: "remote", + revision: 0, + target: { environmentId: null, cwd: null }, + }); + expect(refState.targets).toContainEqual({ + namespace: "tag", + revision: 0, + target: { environmentId: null, cwd: null }, + }); + }); +}); diff --git a/apps/web/src/components/git-history/useGitHistoryRefs.ts b/apps/web/src/components/git-history/useGitHistoryRefs.ts new file mode 100644 index 000000000000..070ccb487746 --- /dev/null +++ b/apps/web/src/components/git-history/useGitHistoryRefs.ts @@ -0,0 +1,225 @@ +import type { EnvironmentId, VcsHistoryRef } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { useLocalStorage } from "../../hooks/useLocalStorage"; +import { buildGitRefTree, filterGitRefTree } from "../../lib/gitRefTree"; +import { useDebouncedValue, usePaginatedHistoryRefs } from "../../state/queries"; + +const EMPTY_FAVORITE_BRANCHES: ReadonlyArray = []; +const FavoriteBranchesSchema = Schema.Array(Schema.String); +const REF_FILTER_DEBOUNCE_MS = 175; + +export interface GitHistoryRevision { + readonly label: string; + readonly revision: string; +} + +export function toggleGitHistoryFavorite( + favorites: ReadonlyArray, + branch: string, +): ReadonlyArray { + return favorites.includes(branch) + ? favorites.filter((value) => value !== branch) + : [...favorites, branch]; +} + +export function useGitHistoryRefs(environmentId: EnvironmentId, cwd: string, revision: number) { + const [refFilter, setRefFilter] = useState(""); + const [selectedRevisionState, setSelectedRevision] = useState< + GitHistoryRevision | null | undefined + >(undefined); + const [expandedRefKeys, setExpandedRefKeys] = useState>( + () => new Set(["section:local"]), + ); + const deferredRefFilter = useDebouncedValue(refFilter.trim(), REF_FILTER_DEBOUNCE_MS); + const normalizedRefFilter = refFilter.trim().toLocaleLowerCase(); + const shouldLoadRemote = + deferredRefFilter.length > 0 || + expandedRefKeys.has("section:remote") || + selectedRevisionState?.revision.startsWith("refs/remotes/") === true; + const shouldLoadTags = + deferredRefFilter.length > 0 || + expandedRefKeys.has("section:tags") || + selectedRevisionState?.revision.startsWith("refs/tags/") === true; + const refs = usePaginatedHistoryRefs( + { environmentId, cwd, query: deferredRefFilter }, + { limit: 200, namespace: "local", revision }, + ); + const remote = usePaginatedHistoryRefs( + shouldLoadRemote + ? { environmentId, cwd, query: deferredRefFilter } + : { environmentId: null, cwd: null }, + { limit: 200, namespace: "remote", revision }, + ); + const tags = usePaginatedHistoryRefs( + shouldLoadTags + ? { environmentId, cwd, query: deferredRefFilter } + : { environmentId: null, cwd: null }, + { limit: 200, namespace: "tag", revision }, + ); + const localRefs = refs.refs; + const remoteRefs = remote.refs; + const tagRefs = tags.refs; + const repositoryKey = refs.data?.repositoryKey ?? null; + const favoriteStorageKey = + repositoryKey === null + ? null + : `t3code:git-history-favorites:v1:${environmentId}:${repositoryKey}`; + const [persistedFavoriteBranches, setFavoriteBranches] = useLocalStorage( + favoriteStorageKey, + EMPTY_FAVORITE_BRANCHES, + FavoriteBranchesSchema, + ); + const localRefTree = useMemo( + () => filterGitRefTree(buildGitRefTree(localRefs), normalizedRefFilter), + [localRefs, normalizedRefFilter], + ); + const favoriteBranchSet = useMemo( + () => new Set(persistedFavoriteBranches), + [persistedFavoriteBranches], + ); + const favoriteRefs = useMemo( + () => + localRefs.filter( + (ref) => + favoriteBranchSet.has(ref.name) && + (normalizedRefFilter.length === 0 || + ref.name.toLocaleLowerCase().includes(normalizedRefFilter)), + ), + [favoriteBranchSet, localRefs, normalizedRefFilter], + ); + const remoteRefTree = useMemo( + () => filterGitRefTree(buildGitRefTree(remoteRefs), normalizedRefFilter), + [normalizedRefFilter, remoteRefs], + ); + const tagRefTree = useMemo( + () => filterGitRefTree(buildGitRefTree(tagRefs), normalizedRefFilter), + [normalizedRefFilter, tagRefs], + ); + const currentRefResult = refs.data?.currentRef; + const lastResolvedCurrentRef = useRef(undefined); + if (currentRefResult !== undefined) lastResolvedCurrentRef.current = currentRefResult; + const currentRef = + currentRefResult === undefined ? lastResolvedCurrentRef.current : currentRefResult; + const defaultSelectedRevision = useMemo(() => { + if (currentRef === undefined) return refs.error === null ? undefined : null; + if (currentRef === null) return null; + return { label: currentRef.name, revision: `refs/heads/${currentRef.name}` }; + }, [currentRef, refs.error]); + const selectedRefWasRemoved = useMemo(() => { + if (selectedRevisionState === undefined || selectedRevisionState === null) return false; + if (deferredRefFilter.length > 0) return false; + const selectedRef = selectedRevisionState.revision; + if (selectedRef.startsWith("refs/heads/")) { + return ( + refs.data?.isComplete === true && + refs.data.nextCursor === null && + !localRefs.some((ref) => selectedRef === `refs/heads/${ref.name}`) + ); + } + if (selectedRef.startsWith("refs/remotes/")) { + return ( + remote.data?.isComplete === true && + remote.data.nextCursor === null && + !remoteRefs.some((ref) => selectedRef === `refs/remotes/${ref.name}`) + ); + } + if (selectedRef.startsWith("refs/tags/")) { + return ( + tags.data?.isComplete === true && + tags.data.nextCursor === null && + !tagRefs.some((ref) => selectedRef === `refs/tags/${ref.name}`) + ); + } + return false; + }, [ + localRefs, + deferredRefFilter, + refs.data?.isComplete, + refs.data?.nextCursor, + remote.data?.isComplete, + remote.data?.nextCursor, + remoteRefs, + selectedRevisionState, + tagRefs, + tags.data?.isComplete, + tags.data?.nextCursor, + ]); + const selectedRevision = + selectedRevisionState === undefined || selectedRefWasRemoved + ? defaultSelectedRevision + : selectedRevisionState; + const initialLocalRefError = + currentRef === undefined && selectedRevisionState === undefined ? refs.error : null; + const toggleRefKey = useCallback((key: string) => { + setExpandedRefKeys((current) => { + const next = new Set(current); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }, []); + const selectRef = useCallback((label: string, revision: string) => { + setSelectedRevision({ label, revision }); + }, []); + const selectAllRefs = useCallback(() => { + setSelectedRevision(null); + }, []); + const toggleFavorite = useCallback( + (branch: string) => { + const toggle = (current: ReadonlyArray) => toggleGitHistoryFavorite(current, branch); + setFavoriteBranches(toggle); + }, + [setFavoriteBranches], + ); + + useEffect(() => { + if (selectedRefWasRemoved) setSelectedRevision(undefined); + }, [selectedRefWasRemoved]); + + const refNamespaces = [ + { namespace: "local", query: refs, enabled: true }, + { namespace: "remote", query: remote, enabled: shouldLoadRemote }, + { namespace: "tag", query: tags, enabled: shouldLoadTags }, + ] as const; + const enabledRefNamespaces = refNamespaces.filter(({ enabled }) => enabled); + + return { + currentRef, + expandedRefKeys, + favoriteBranches: favoriteBranchSet, + favoriteRefs, + hasMoreRefs: enabledRefNamespaces.some( + ({ query }) => query.data?.nextCursor !== null && query.data?.nextCursor !== undefined, + ), + isFetchingMoreRefs: enabledRefNamespaces.some(({ query }) => query.isFetchingNextPage), + isRefSnapshotComplete: enabledRefNamespaces.every( + ({ query }) => query.data?.isComplete !== false, + ), + initialLocalRefError, + localRefTree, + localRefs, + normalizedRefFilter, + onLoadMoreRefs: () => enabledRefNamespaces.forEach(({ query }) => query.loadNext()), + refreshRefs: () => { + refNamespaces.forEach(({ query }) => query.refresh()); + }, + onRetryRefs: () => + enabledRefNamespaces.forEach(({ query }) => { + if (query.error) query.retry(); + }), + refPaginationError: enabledRefNamespaces.find(({ query }) => query.error)?.query.error ?? null, + refFilter, + remoteRefTree, + remoteRefs, + selectAllRefs, + selectRef, + selectedRevision, + setRefFilter, + tagRefTree, + tagRefs, + toggleRefKey, + toggleFavorite, + }; +} diff --git a/apps/web/src/gitHistoryCapability.test.ts b/apps/web/src/gitHistoryCapability.test.ts new file mode 100644 index 000000000000..bc49e1dd0229 --- /dev/null +++ b/apps/web/src/gitHistoryCapability.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { hasGitHistoryCapability } from "./gitHistoryCapability"; + +describe("Git History capability", () => { + it("treats only an explicit true flag as supported", () => { + expect(hasGitHistoryCapability(undefined)).toBe(false); + expect(hasGitHistoryCapability({})).toBe(false); + expect(hasGitHistoryCapability({ gitHistory: false })).toBe(false); + expect(hasGitHistoryCapability({ gitHistory: true })).toBe(true); + }); +}); diff --git a/apps/web/src/gitHistoryCapability.ts b/apps/web/src/gitHistoryCapability.ts new file mode 100644 index 000000000000..bc408079438d --- /dev/null +++ b/apps/web/src/gitHistoryCapability.ts @@ -0,0 +1,7 @@ +import type { ExecutionEnvironmentCapabilities } from "@t3tools/contracts"; + +export function hasGitHistoryCapability( + capabilities: Pick | null | undefined, +): boolean { + return capabilities?.gitHistory === true; +} diff --git a/apps/web/src/hooks/useLocalStorage.ts b/apps/web/src/hooks/useLocalStorage.ts index 8e7158c36152..ff9ed85cc889 100644 --- a/apps/web/src/hooks/useLocalStorage.ts +++ b/apps/web/src/hooks/useLocalStorage.ts @@ -98,11 +98,12 @@ function dispatchLocalStorageChange(key: string) { } export function useLocalStorage( - key: string, + key: string | null, initialValue: T, schema: Schema.Codec, ): [T, (value: T | ((val: T) => T)) => void] { const getSnapshot = useCallback(() => { + if (key === null) return null; try { return read(key); } catch (error) { @@ -113,6 +114,7 @@ export function useLocalStorage( const subscribe = useCallback( (onStoreChange: () => void) => { + if (key === null) return () => undefined; const handleStorageChange = (event: StorageEvent) => { if (event.key === key) { onStoreChange(); @@ -136,7 +138,7 @@ export function useLocalStorage( const serializedValue = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); const storedValue = useMemo(() => { - if (serializedValue === null) { + if (key === null || serializedValue === null) { return initialValue; } try { @@ -149,6 +151,7 @@ export function useLocalStorage( const setValue = useCallback( (value: T | ((val: T) => T)) => { + if (key === null) return; try { const currentValue = getLocalStorageItem(key, schema) ?? initialValue; let valueToStore: T; diff --git a/apps/web/src/lib/gitHistoryGraph.test.ts b/apps/web/src/lib/gitHistoryGraph.test.ts new file mode 100644 index 000000000000..ea89dfb224ee --- /dev/null +++ b/apps/web/src/lib/gitHistoryGraph.test.ts @@ -0,0 +1,291 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + MAX_GIT_HISTORY_GRAPH_EDGES_PER_ROW, + MAX_GIT_HISTORY_GRAPH_LANES, + layoutGitHistoryGraph, +} from "./gitHistoryGraph"; + +describe("layoutGitHistoryGraph", () => { + it("lays out a linear history in one lane", () => { + const layout = layoutGitHistoryGraph([ + { hash: "c", parentHashes: ["b"] }, + { hash: "b", parentHashes: ["a"] }, + { hash: "a", parentHashes: [] }, + ]); + + expect(layout).toEqual({ + laneCount: 1, + rows: [ + { + hash: "c", + lane: 0, + colorIndex: 0, + hasIncoming: false, + edges: [ + { + fromLane: 0, + toLane: 0, + colorIndex: 0, + kind: "parent", + parentHash: "b", + isMissingParent: false, + }, + ], + }, + { + hash: "b", + lane: 0, + colorIndex: 0, + hasIncoming: true, + edges: [ + { + fromLane: 0, + toLane: 0, + colorIndex: 0, + kind: "parent", + parentHash: "a", + isMissingParent: false, + }, + ], + }, + { hash: "a", lane: 0, colorIndex: 0, hasIncoming: true, edges: [] }, + ], + }); + }); + + it("keeps a lane color stable when a commit has a branch decoration", () => { + const layout = layoutGitHistoryGraph([ + { hash: "head", parentHashes: ["tip"], refs: ["HEAD -> merge-wt", "origin/merge-wt"] }, + { hash: "tip", parentHashes: ["base"] }, + { hash: "base", parentHashes: ["root"], refs: ["fix/602-pr-integration"] }, + { hash: "root", parentHashes: [] }, + ]); + + expect(layout.rows.map((row) => [row.hash, row.lane, row.colorIndex])).toEqual([ + ["head", 0, 0], + ["tip", 0, 0], + ["base", 0, 0], + ["root", 0, 0], + ]); + expect(layout.rows[2]?.edges).toContainEqual( + expect.objectContaining({ kind: "parent", colorIndex: 0, fromLane: 0, toLane: 0 }), + ); + }); + + it("keeps branch lanes and colors stable through a merge", () => { + const layout = layoutGitHistoryGraph([ + { hash: "merge", parentHashes: ["main", "side"] }, + { hash: "main", parentHashes: ["base"] }, + { hash: "side", parentHashes: ["base"] }, + { hash: "base", parentHashes: [] }, + ]); + + expect(layout.laneCount).toBe(2); + expect(layout.rows.map((row) => [row.hash, row.lane, row.colorIndex])).toEqual([ + ["merge", 0, 0], + ["main", 0, 0], + ["side", 1, 1], + ["base", 0, 0], + ]); + expect(layout.rows[0]?.edges).toEqual([ + { + fromLane: 0, + toLane: 0, + colorIndex: 0, + kind: "parent", + parentHash: "main", + isMissingParent: false, + }, + { + fromLane: 0, + toLane: 1, + colorIndex: 1, + kind: "parent", + parentHash: "side", + isMissingParent: false, + }, + ]); + expect(layout.rows[1]?.edges).toEqual([ + { + fromLane: 0, + toLane: 0, + colorIndex: 0, + kind: "parent", + parentHash: "base", + isMissingParent: false, + }, + { fromLane: 1, toLane: 1, colorIndex: 1, kind: "continuation" }, + ]); + expect(layout.rows[2]?.edges).toEqual([ + { + fromLane: 1, + toLane: 1, + colorIndex: 1, + kind: "parent", + parentHash: "base", + isMissingParent: false, + }, + { fromLane: 0, toLane: 0, colorIndex: 0, kind: "continuation" }, + ]); + expect(layout.rows[3]?.edges).toEqual([ + { + fromLane: 1, + toLane: 0, + colorIndex: 1, + kind: "incoming", + }, + ]); + }); + + it("allocates one lane for each octopus parent", () => { + const layout = layoutGitHistoryGraph([ + { hash: "octopus", parentHashes: ["first", "second", "third"] }, + { hash: "first", parentHashes: [] }, + { hash: "second", parentHashes: [] }, + { hash: "third", parentHashes: [] }, + ]); + + expect(layout.laneCount).toBe(3); + expect(layout.rows[0]?.edges).toEqual([ + { + fromLane: 0, + toLane: 0, + colorIndex: 0, + kind: "parent", + parentHash: "first", + isMissingParent: false, + }, + { + fromLane: 0, + toLane: 1, + colorIndex: 1, + kind: "parent", + parentHash: "second", + isMissingParent: false, + }, + { + fromLane: 0, + toLane: 2, + colorIndex: 2, + kind: "parent", + parentHash: "third", + isMissingParent: false, + }, + ]); + expect(layout.rows.map((row) => [row.hash, row.lane, row.colorIndex])).toEqual([ + ["octopus", 0, 0], + ["first", 0, 0], + ["second", 1, 1], + ["third", 2, 2], + ]); + }); + + it("keeps the first-parent history in one straight primary lane", () => { + const layout = layoutGitHistoryGraph([ + { hash: "head", parentHashes: ["merge"] }, + { hash: "merge", parentHashes: ["main", "side"] }, + { hash: "side", parentHashes: ["base"] }, + { hash: "main", parentHashes: ["base"] }, + { hash: "base", parentHashes: [] }, + ]); + + expect( + layout.rows + .filter((row) => ["head", "merge", "main", "base"].includes(row.hash)) + .map((row) => row.lane), + ).toEqual([0, 0, 0, 0]); + }); + + it("reserves the primary lane for the decorated current HEAD ancestry", () => { + const layout = layoutGitHistoryGraph([ + { hash: "other", parentHashes: [] }, + { hash: "head", parentHashes: ["main"], refs: ["HEAD -> feature/current"] }, + { hash: "main", parentHashes: ["base"] }, + { hash: "base", parentHashes: [] }, + ]); + + expect(layout.rows.map((row) => [row.hash, row.lane])).toEqual([ + ["other", 1], + ["head", 0], + ["main", 0], + ["base", 0], + ]); + expect(layout.rows[0]?.edges.some((edge) => edge.fromLane === 0)).toBe(false); + expect(layout.rows[1]?.hasIncoming).toBe(false); + }); + + it("keeps missing page-boundary parents visible and starts unrelated commits in a new lane", () => { + const layout = layoutGitHistoryGraph([ + { hash: "newest", parentHashes: ["older-than-page"] }, + { hash: "unrelated", parentHashes: [] }, + ]); + + expect(layout).toEqual({ + laneCount: 2, + rows: [ + { + hash: "newest", + lane: 0, + colorIndex: 0, + hasIncoming: false, + edges: [ + { + fromLane: 0, + toLane: 0, + colorIndex: 0, + kind: "parent", + parentHash: "older-than-page", + isMissingParent: true, + }, + ], + }, + { + hash: "unrelated", + lane: 1, + colorIndex: 1, + hasIncoming: false, + edges: [{ fromLane: 0, toLane: 0, colorIndex: 0, kind: "continuation" }], + }, + ], + }); + }); + + it("is deterministic and does not duplicate an edge for repeated parent hashes", () => { + const commits = [ + { hash: "tip", parentHashes: ["base", "base"] }, + { hash: "base", parentHashes: [] }, + ]; + + expect(layoutGitHistoryGraph(commits)).toEqual(layoutGitHistoryGraph(commits)); + expect(layoutGitHistoryGraph(commits).rows[0]?.edges).toHaveLength(1); + }); + + it("compacts a 1001-commit high-parent snapshot into bounded, explicit graph elisions", () => { + const parentHashes = Array.from({ length: 1_000 }, (_, index) => `parent-${index}`); + const layout = layoutGitHistoryGraph([ + { hash: "merge", parentHashes }, + ...parentHashes.map((hash) => ({ hash, parentHashes: [] })), + ]); + + expect(layout.rows).toHaveLength(1_001); + expect(layout.laneCount).toBeLessThanOrEqual(MAX_GIT_HISTORY_GRAPH_LANES); + expect(layout.rows.every((row) => row.lane < MAX_GIT_HISTORY_GRAPH_LANES)).toBe(true); + expect( + layout.rows.every((row) => row.edges.length <= MAX_GIT_HISTORY_GRAPH_EDGES_PER_ROW), + ).toBe(true); + expect(layout.rows.flatMap((row) => row.edges).length).toBeLessThanOrEqual( + layout.rows.length * MAX_GIT_HISTORY_GRAPH_EDGES_PER_ROW, + ); + expect( + layout.rows.flatMap((row) => row.edges).filter((edge) => edge.kind === "elided"), + ).toEqual([expect.objectContaining({ fromLane: 0, toLane: MAX_GIT_HISTORY_GRAPH_LANES - 1 })]); + expect( + layout.rows.every((row) => + row.edges + .filter((edge) => edge.kind === "parent" || edge.kind === "elided") + .every((edge) => edge.fromLane === row.lane), + ), + ).toBe(true); + }); +}); diff --git a/apps/web/src/lib/gitHistoryGraph.ts b/apps/web/src/lib/gitHistoryGraph.ts new file mode 100644 index 000000000000..11dbbddff252 --- /dev/null +++ b/apps/web/src/lib/gitHistoryGraph.ts @@ -0,0 +1,231 @@ +export interface GitHistoryGraphCommit { + hash: string; + parentHashes: ReadonlyArray; + refs?: ReadonlyArray; +} + +export interface GitHistoryGraphEdge { + fromLane: number; + toLane: number; + colorIndex: number; + kind: "continuation" | "incoming" | "parent" | "elided"; + parentHash?: string; + isMissingParent?: boolean; +} + +export const MAX_GIT_HISTORY_GRAPH_LANES = 12; +export const MAX_GIT_HISTORY_GRAPH_EDGES_PER_ROW = MAX_GIT_HISTORY_GRAPH_LANES * 2; + +const MAX_TRACKED_LANES = MAX_GIT_HISTORY_GRAPH_LANES - 1; +const ELISION_LANE = MAX_GIT_HISTORY_GRAPH_LANES - 1; + +export interface GitHistoryGraphRow { + hash: string; + lane: number; + colorIndex: number; + hasIncoming: boolean; + edges: ReadonlyArray; +} + +export interface GitHistoryGraphLayout { + laneCount: number; + rows: ReadonlyArray; +} + +interface Lane { + hash: string; + colorIndex: number; + started: boolean; +} + +type LaneSlot = Lane | null; + +interface GitHistoryGraphOptions { + primaryHash?: string; + primaryHashes?: ReadonlySet; + includeMissingParents?: boolean; +} + +function findLaneIndices(lanes: ReadonlyArray, hash: string): number[] { + return lanes.flatMap((lane, index) => (lane?.hash === hash ? [index] : [])); +} + +function uniqueHashes(hashes: ReadonlyArray): string[] { + return Array.from(new Set(hashes)); +} + +function nextColorIndex(lanes: ReadonlyArray): number { + const usedColors = new Set(lanes.flatMap((lane) => (lane === null ? [] : [lane.colorIndex]))); + let colorIndex = 1; + + while (colorIndex < Number.MAX_SAFE_INTEGER && usedColors.has(colorIndex)) { + colorIndex += 1; + } + + return colorIndex; +} + +function continuationEdges( + beforeLanes: ReadonlyArray, + endingLanes: ReadonlySet, +): GitHistoryGraphEdge[] { + return beforeLanes.flatMap((lane, fromLane) => { + if (endingLanes.has(fromLane) || lane === null || !lane.started) { + return []; + } + + return [ + { + fromLane, + toLane: fromLane, + colorIndex: lane.colorIndex, + kind: "continuation" as const, + }, + ]; + }); +} + +function elidedEdge(fromLane: number, colorIndex: number): GitHistoryGraphEdge { + return { + fromLane, + toLane: ELISION_LANE, + colorIndex, + kind: "elided", + }; +} + +export function layoutGitHistoryGraph( + commits: ReadonlyArray, + options: GitHistoryGraphOptions = {}, +): GitHistoryGraphLayout { + const knownHashes = new Set(commits.map((commit) => commit.hash)); + const includeMissingParents = options.includeMissingParents ?? true; + const currentHeadHash = + options.primaryHash ?? + commits.find((commit) => + commit.refs?.some((ref) => ref === "HEAD" || ref.startsWith("HEAD -> ")), + )?.hash ?? + commits[0]?.hash; + const commitsByHash = new Map(commits.map((commit) => [commit.hash, commit])); + const primaryHashes = new Set(options.primaryHashes); + if (primaryHashes.size === 0) { + let primaryHash = currentHeadHash; + while (primaryHash && !primaryHashes.has(primaryHash)) { + primaryHashes.add(primaryHash); + primaryHash = commitsByHash.get(primaryHash)?.parentHashes[0]; + } + } + const rows: GitHistoryGraphRow[] = []; + let lanes: LaneSlot[] = []; + let laneCount = 0; + + for (const commit of commits) { + const isPrimary = primaryHashes.has(commit.hash); + const matchingLanes = findLaneIndices(lanes, commit.hash); + const activeMatchingLanes = matchingLanes.filter((lane) => lanes[lane]?.started === true); + const preferredNodeLane = isPrimary ? 0 : (activeMatchingLanes[0] ?? matchingLanes[0] ?? -1); + const emptyLane = lanes.findIndex( + (lane, index) => lane === null && index > 0 && index < MAX_TRACKED_LANES, + ); + const nodeLane = + preferredNodeLane !== -1 + ? preferredNodeLane + : emptyLane !== -1 + ? emptyLane + : Math.max(1, lanes.length) < MAX_TRACKED_LANES + ? Math.max(1, lanes.length) + : ELISION_LANE; + const isElidedNode = nodeLane === ELISION_LANE && matchingLanes.length === 0; + const existingNodeLane = lanes[nodeLane]; + const nodeColorIndex = isPrimary + ? (existingNodeLane?.colorIndex ?? 0) + : (existingNodeLane?.colorIndex ?? nextColorIndex(lanes)); + const beforeLanes = [...lanes]; + if (!isElidedNode) { + while (beforeLanes.length <= nodeLane) beforeLanes.push(null); + } + if (!isElidedNode && (beforeLanes[nodeLane] === null || beforeLanes[nodeLane] === undefined)) { + beforeLanes[nodeLane] = { + hash: commit.hash, + colorIndex: nodeColorIndex, + started: false, + }; + } + + const endingLanes = new Set(isElidedNode ? matchingLanes : [...matchingLanes, nodeLane]); + const incomingEdges: GitHistoryGraphEdge[] = activeMatchingLanes + .filter((lane) => lane !== nodeLane) + .map((lane) => ({ + fromLane: lane, + toLane: nodeLane, + colorIndex: beforeLanes[lane]?.colorIndex ?? nodeColorIndex, + kind: "incoming", + })); + const afterLanes = [...beforeLanes]; + for (const lane of endingLanes) afterLanes[lane] = null; + + const parentEdges: GitHistoryGraphEdge[] = []; + const parentHashes = uniqueHashes(commit.parentHashes).filter( + (parentHash) => includeMissingParents || knownHashes.has(parentHash), + ); + for (const [parentIndex, parentHash] of parentHashes.entries()) { + if (isElidedNode || parentEdges.length >= MAX_TRACKED_LANES) { + break; + } + const parentIsPrimary = primaryHashes.has(parentHash); + const preferredParentLane = parentIsPrimary ? 0 : parentIndex === 0 ? nodeLane : -1; + const availableParentLane = + preferredParentLane !== -1 && afterLanes[preferredParentLane] === null + ? preferredParentLane + : afterLanes.findIndex( + (lane, index) => lane === null && index > 0 && index < MAX_TRACKED_LANES, + ); + const parentLane = + availableParentLane === -1 && Math.max(1, afterLanes.length) < MAX_TRACKED_LANES + ? Math.max(1, afterLanes.length) + : availableParentLane; + if (parentLane === -1) { + break; + } + const colorIndex = parentIndex === 0 ? nodeColorIndex : nextColorIndex(afterLanes); + while (afterLanes.length <= parentLane) afterLanes.push(null); + afterLanes[parentLane] = { + hash: parentHash, + colorIndex, + started: true, + }; + parentEdges.push({ + fromLane: nodeLane, + toLane: parentLane, + colorIndex, + kind: "parent", + parentHash, + isMissingParent: !knownHashes.has(parentHash), + }); + } + + const hasElidedParents = parentEdges.length < parentHashes.length; + const edges = [ + ...incomingEdges, + ...parentEdges, + ...continuationEdges(beforeLanes, endingLanes), + ...(hasElidedParents ? [elidedEdge(nodeLane, nodeColorIndex)] : []), + ]; + rows.push({ + hash: commit.hash, + lane: nodeLane, + colorIndex: nodeColorIndex, + hasIncoming: activeMatchingLanes.includes(nodeLane), + edges: edges.slice(0, MAX_GIT_HISTORY_GRAPH_EDGES_PER_ROW), + }); + + laneCount = Math.max( + laneCount, + isElidedNode ? MAX_GIT_HISTORY_GRAPH_LANES : beforeLanes.length, + afterLanes.length, + ); + lanes = afterLanes; + } + + return { laneCount, rows }; +} diff --git a/apps/web/src/lib/gitRefTree.test.ts b/apps/web/src/lib/gitRefTree.test.ts new file mode 100644 index 000000000000..87f56a18cb1a --- /dev/null +++ b/apps/web/src/lib/gitRefTree.test.ts @@ -0,0 +1,58 @@ +import type { VcsHistoryRef } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { buildGitRefTree, filterGitRefTree } from "./gitRefTree"; + +function ref(name: string, options?: { current?: boolean; isDefault?: boolean }): VcsHistoryRef { + return { + name, + kind: "local", + current: options?.current ?? false, + isDefault: options?.isDefault ?? false, + worktreePath: null, + }; +} + +describe("buildGitRefTree", () => { + it("groups slash-delimited refs and pins current and default branches", () => { + const tree = buildGitRefTree([ + ref("development"), + ref("feat/two"), + ref("merge-wt", { current: true }), + ref("feat/one"), + ref("master", { isDefault: true }), + ]); + + expect(tree.map((node) => [node.kind, node.name])).toEqual([ + ["ref", "merge-wt"], + ["ref", "master"], + ["folder", "feat"], + ["ref", "development"], + ]); + expect(tree[2]).toMatchObject({ + kind: "folder", + path: "feat", + children: [ + { kind: "ref", name: "one" }, + { kind: "ref", name: "two" }, + ], + }); + }); + + it("preserves matching ancestors while filtering by full ref name", () => { + const tree = buildGitRefTree([ref("chore/review/hardening"), ref("feat/map")]); + expect(filterGitRefTree(tree, "hardening")).toMatchObject([ + { + kind: "folder", + name: "chore", + children: [ + { + kind: "folder", + name: "review", + children: [{ kind: "ref", name: "hardening" }], + }, + ], + }, + ]); + }); +}); diff --git a/apps/web/src/lib/gitRefTree.ts b/apps/web/src/lib/gitRefTree.ts new file mode 100644 index 000000000000..39e1d3bf3a94 --- /dev/null +++ b/apps/web/src/lib/gitRefTree.ts @@ -0,0 +1,88 @@ +import type { VcsHistoryRef } from "@t3tools/contracts"; + +export type GitRefTreeNode = + | { + readonly kind: "folder"; + readonly name: string; + readonly path: string; + readonly children: ReadonlyArray; + } + | { + readonly kind: "ref"; + readonly name: string; + readonly ref: VcsHistoryRef; + }; + +interface MutableFolder { + readonly folders: Map; + readonly refs: VcsHistoryRef[]; +} + +function createFolder(): MutableFolder { + return { folders: new Map(), refs: [] }; +} + +function compareRefs(left: VcsHistoryRef, right: VcsHistoryRef): number { + if (left.current !== right.current) return left.current ? -1 : 1; + if (left.isDefault !== right.isDefault) return left.isDefault ? -1 : 1; + return left.name.localeCompare(right.name); +} + +function materialize(folder: MutableFolder, parentPath: string): GitRefTreeNode[] { + const folders = [...folder.folders.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([name, child]) => { + const path = parentPath.length === 0 ? name : `${parentPath}/${name}`; + return { + kind: "folder" as const, + name, + path, + children: materialize(child, path), + }; + }); + const refs = folder.refs.sort(compareRefs).map((ref) => ({ + kind: "ref" as const, + name: ref.name.split("/").at(-1) ?? ref.name, + ref, + })); + const pinned = refs.filter((node) => node.ref.current || node.ref.isDefault); + const ordinary = refs.filter((node) => !node.ref.current && !node.ref.isDefault); + return [...pinned, ...folders, ...ordinary]; +} + +export function buildGitRefTree(refs: ReadonlyArray): ReadonlyArray { + const root = createFolder(); + for (const ref of refs) { + const segments = ref.name.split("/").filter((segment) => segment.length > 0); + let folder = root; + for (const segment of segments.slice(0, -1)) { + const child = folder.folders.get(segment) ?? createFolder(); + folder.folders.set(segment, child); + folder = child; + } + folder.refs.push(ref); + } + return materialize(root, ""); +} + +export function filterGitRefTree( + nodes: ReadonlyArray, + query: string, +): ReadonlyArray { + const normalized = query.trim().toLocaleLowerCase(); + if (normalized.length === 0) return nodes; + const filtered: GitRefTreeNode[] = []; + for (const node of nodes) { + if (node.kind === "ref") { + if (node.ref.name.toLocaleLowerCase().includes(normalized)) filtered.push(node); + continue; + } + if (node.path.toLocaleLowerCase().includes(normalized)) { + filtered.push(node); + continue; + } + const children = filterGitRefTree(node.children, normalized); + if (children.length > 0) filtered.push({ ...node, children }); + } + return filtered; +} diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts index 0cba157bcc49..a71491535a88 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -138,6 +138,15 @@ describe("rightPanelStore", () => { }, ); + it("opens Git History as a singleton surface", () => { + useRightPanelStore.getState().open(refA, "git-history"); + + expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ + id: "git-history", + kind: "git-history", + }); + }); + it.each([ { choice: "issues", choose: () => useRightPanelStore.getState().openIssues(refA) }, { diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index ce2c87a80e2e..88e23a0f910c 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -21,6 +21,7 @@ import { resolveStorage } from "./lib/storage"; const RIGHT_PANEL_KINDS = [ "diff", + "git-history", "files", "file", "preview", @@ -54,6 +55,7 @@ export type RightPanelSurface = splitDirection?: "horizontal" | "vertical"; } | { id: "diff"; kind: "diff" } + | { id: "git-history"; kind: "git-history" } | { id: "files"; kind: "files" } | { id: `file:${string}` | `attachment:${string}`; @@ -233,6 +235,8 @@ const singletonSurface = ( switch (kind) { case "diff": return { id: "diff", kind }; + case "git-history": + return { id: "git-history", kind }; case "files": return { id: "files", kind }; case "pull-requests": diff --git a/apps/web/src/state/environments.ts b/apps/web/src/state/environments.ts index f085075fdd7c..443e99b84cdc 100644 --- a/apps/web/src/state/environments.ts +++ b/apps/web/src/state/environments.ts @@ -11,6 +11,7 @@ import { useMemo } from "react"; import { environmentCatalog } from "../connection/catalog"; import { environmentPresentations, useEnvironmentPresentation } from "./presentation"; import { primaryEnvironmentIdAtom } from "./primaryEnvironment"; +import { useEnvironmentQuery } from "./query"; import { relayEnvironmentDiscovery } from "./relay"; import { usePreparedConnection } from "./session"; @@ -84,3 +85,7 @@ export function useEnvironmentHttpBaseUrl(environmentId: EnvironmentId | null): export function useRelayEnvironmentDiscovery(): Discovery.RelayEnvironmentDiscoveryState { return useAtomValue(relayEnvironmentDiscovery.stateValueAtom); } + +export function useEnvironmentConnectionState(environmentId: EnvironmentId) { + return useEnvironmentQuery(environmentCatalog.stateAtom(environmentId)); +} diff --git a/apps/web/src/state/queries.test.tsx b/apps/web/src/state/queries.test.tsx new file mode 100644 index 000000000000..a1bb49d0b150 --- /dev/null +++ b/apps/web/src/state/queries.test.tsx @@ -0,0 +1,309 @@ +import { + VcsSnapshotExpiredError, + type EnvironmentId, + type VcsListHistoryRefsResult, +} from "@t3tools/contracts"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { reactHookHarness as hooks } from "../test/reactHookHarness"; +import { makeVcsSnapshotCacheKey, usePaginatedHistoryRefs } from "./queries"; + +type PageResult = + | { + readonly _tag: "Success"; + readonly waiting: false; + readonly value: VcsListHistoryRefsResult; + } + | { + readonly _tag: "Failure"; + readonly cause: Error; + readonly waiting: false; + }; + +type PageAtom = { + readonly input: { + readonly cursor?: string; + readonly refresh?: true; + }; + readonly cacheKey?: string; + readonly result: PageResult; +}; + +const refsState = vi.hoisted(() => ({ + atoms: [] as PageAtom[], + connection: { phase: "connected", generation: 1 }, + refresh: vi.fn(), + results: new Map(), +})); + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + const { reactHookHarness } = await import("../test/reactHookHarness"); + return { + ...actual, + useCallback: reactHookHarness.useCallback, + useEffect: (effect: () => void) => effect(), + useMemo: reactHookHarness.useMemo, + useRef: reactHookHarness.useRef, + useState: reactHookHarness.useState, + }; +}); + +vi.mock("@effect/atom-react", () => ({ + useAtomValue: (atom: { readonly value: ReadonlyArray }) => atom.value, +})); + +vi.mock("effect/Cause", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, squash: (cause: Error) => cause }; +}); + +vi.mock("effect/unstable/reactivity", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + AsyncResult: { + ...actual.AsyncResult, + value: (result: PageResult) => + result._tag === "Success" ? { _tag: "Some", value: result.value } : { _tag: "None" }, + }, + Atom: { + ...actual.Atom, + make: (create: unknown) => { + if (typeof create !== "function") return actual.Atom.make(create); + const value = create((atom) => atom.result); + return { + pipe: () => ({ value }), + value, + }; + }, + withLabel: () => (atom: unknown) => atom, + }, + }; +}); + +vi.mock("../rpc/atomRegistry", () => ({ + appAtomRegistry: { refresh: refsState.refresh }, +})); + +vi.mock("@t3tools/client-runtime/state/thread-search", () => ({ + createThreadSearchResultsAtomFamily: () => () => ({}), + makeThreadSearchKey: () => "", +})); + +vi.mock("./orchestration", () => ({ orchestrationEnvironment: {} })); +vi.mock("./projects", () => ({ projectContentSearch: {}, projectEnvironment: {} })); +vi.mock("./query", () => ({ + useEnvironmentQuery: (atom: { readonly state?: { readonly data: unknown } } | null) => + atom?.state ?? { data: null }, +})); +vi.mock("./threads", () => ({ useEnvironmentThread: () => ({}) })); + +vi.mock("./vcs", () => ({ + vcsEnvironment: { + listHistoryRefs: ({ + cacheKey, + input, + }: { + readonly cacheKey?: string; + readonly input: PageAtom["input"]; + }) => { + const generation = + cacheKey === undefined + ? 0 + : cacheKey + .slice(1, -1) + .split(",") + .map(Number) + .reduce((sum, value) => sum + value, 0); + const result = refsState.results.get(`${generation}:${input.cursor ?? "first"}`); + if (result === undefined) + throw new Error(`Missing result for ${generation}:${input.cursor ?? "first"}`); + const atom = { cacheKey, input, result }; + refsState.atoms.push(atom); + return atom; + }, + }, +})); + +vi.mock("../connection/catalog", () => ({ + environmentCatalog: { stateAtom: () => ({ state: { data: refsState.connection } }) }, +})); + +const target = { + environmentId: "environment" as EnvironmentId, + cwd: "C:/workspace", + query: "", +}; + +function page(nextCursor: string | null): PageResult { + return { + _tag: "Success", + waiting: false, + value: { + refs: [], + isRepo: true, + repositoryKey: "C:/repositories/t3code/.git", + nextCursor, + currentRef: null, + isComplete: true, + }, + }; +} + +function render(revision = 0, queryTarget = target) { + hooks.beginRender(); + return usePaginatedHistoryRefs(queryTarget, { revision }); +} + +describe("usePaginatedHistoryRefs", () => { + beforeEach(() => { + hooks.reset(); + refsState.atoms = []; + refsState.connection = { phase: "connected", generation: 1 }; + refsState.refresh.mockReset(); + refsState.results.clear(); + }); + + it("keeps snapshot generations distinct from revisions", () => { + expect(makeVcsSnapshotCacheKey(1, 11)).not.toBe(makeVcsSnapshotCacheKey(2, 10)); + }); + + it("uses refresh only for the first page of a refreshed ref snapshot", () => { + refsState.results.set("0:first", page("cursor-4")); + refsState.results.set("1:first", page("cursor-8")); + refsState.results.set("1:cursor-8", page(null)); + + const initial = render(); + initial.refresh(); + const refreshed = render(); + refreshed.loadNext(); + render(); + render(0, { ...target, query: "later" }); + render(); + + expect(refsState.atoms.map((atom) => ({ ...atom.input, cacheKey: atom.cacheKey }))).toEqual([ + { cacheKey: "[0,0]", cwd: "C:/workspace", limit: 100, namespace: "local" }, + { cacheKey: "[1,0]", cwd: "C:/workspace", limit: 100, namespace: "local", refresh: true }, + { cacheKey: "[1,0]", cwd: "C:/workspace", limit: 100, namespace: "local", refresh: true }, + { + cacheKey: "[1,0]", + cwd: "C:/workspace", + cursor: "cursor-8", + limit: 100, + namespace: "local", + }, + { + cacheKey: "[1,0]", + cwd: "C:/workspace", + limit: 100, + namespace: "local", + query: "later", + }, + { cacheKey: "[1,0]", cwd: "C:/workspace", limit: 100, namespace: "local" }, + ]); + expect( + new Set( + refsState.atoms + .filter((atom) => atom.input.refresh === true) + .map( + (atom) => + `${atom.input.cwd}:${atom.input.query ?? ""}:${atom.input.cursor ?? ""}:${atom.cacheKey}`, + ), + ), + ).toEqual(new Set(["C:/workspace:::[1,0]"])); + }); + + it("discards history cursor pages when the root reconnects", () => { + refsState.results.set("0:first", page("cursor-4")); + refsState.results.set("0:cursor-4", page(null)); + + const initial = render(); + initial.loadNext(); + render(); + refsState.connection = { phase: "connected", generation: 2 }; + const reconnected = render(); + + expect(refsState.atoms.map((atom) => atom.input.cursor)).toEqual([ + undefined, + undefined, + "cursor-4", + undefined, + ]); + expect(reconnected.data?.nextCursor).toBe("cursor-4"); + }); + + it("restarts from the first history refs page once when its repository revision changes", () => { + refsState.results.set("0:first", page("cursor-4")); + refsState.results.set("0:cursor-4", page(null)); + refsState.results.set("1:first", page(null)); + + const initial = render(); + initial.loadNext(); + render(); + render(1); + + expect(refsState.atoms.map((atom) => ({ ...atom.input, cacheKey: atom.cacheKey }))).toEqual([ + { cacheKey: "[0,0]", cwd: "C:/workspace", limit: 100, namespace: "local" }, + { cacheKey: "[0,0]", cwd: "C:/workspace", limit: 100, namespace: "local" }, + { + cacheKey: "[0,0]", + cwd: "C:/workspace", + cursor: "cursor-4", + limit: 100, + namespace: "local", + }, + { cacheKey: "[0,1]", cwd: "C:/workspace", limit: 100, namespace: "local" }, + ]); + }); + + it("retries the failed appended page without duplicating its cursor", () => { + refsState.results.set("0:first", page("cursor-4")); + refsState.results.set("0:cursor-4", { + _tag: "Failure", + cause: new Error("temporary failure"), + waiting: false, + }); + + const initial = render(); + initial.loadNext(); + const withFailedPage = render(); + withFailedPage.loadNext(); + withFailedPage.retry(); + + expect(refsState.atoms.map((atom) => atom.input.cursor)).toEqual([ + undefined, + undefined, + "cursor-4", + ]); + expect(refsState.refresh).toHaveBeenCalledTimes(1); + expect(refsState.refresh).toHaveBeenCalledWith(refsState.atoms.at(-1)); + }); + + it("recovers an expired snapshot once per generation, including after a later refresh", () => { + const expired = (cursor: string) => + new VcsSnapshotExpiredError({ operation: "GitVcsDriver.listHistoryRefs", cursor }); + refsState.results.set("0:first", { _tag: "Failure", cause: expired("first"), waiting: false }); + refsState.results.set("1:first", page(null)); + + render(); + render(); + render(); + + expect(refsState.atoms.map((atom) => atom.cacheKey)).toEqual(["[0,0]", "[1,0]"]); + + const recovered = render(); + recovered.refresh(); + refsState.results.set("2:first", { _tag: "Failure", cause: expired("second"), waiting: false }); + refsState.results.set("3:first", page(null)); + render(); + render(); + + expect(refsState.atoms.map((atom) => atom.cacheKey)).toEqual([ + "[0,0]", + "[1,0]", + "[2,0]", + "[3,0]", + ]); + }); +}); diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index a16c0921a7de..7780c5cf3e58 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -9,24 +9,30 @@ import { type EnvironmentThreadSearchMatch, } from "@t3tools/client-runtime/state/thread-search"; import { type VcsRefTarget } from "@t3tools/client-runtime/state/vcs"; -import type { +import { + VcsSnapshotExpiredError, EnvironmentId, OrchestrationThread, ProjectContentMatch, ProjectEntryKind, + VcsHistoryRef, + VcsListHistoryRefsResult, VcsListRefsResult, VcsRef, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useEffect, useMemo, useState } from "react"; import { appAtomRegistry } from "../rpc/atomRegistry"; +import { environmentCatalog } from "../connection/catalog"; import { orchestrationEnvironment } from "./orchestration"; import { isPaginatedBranchesNextPagePending } from "./paginatedBranches"; import { projectContentSearch, projectEnvironment } from "./projects"; import { useEnvironmentQuery } from "./query"; +import { usePaginatedSnapshotPages } from "./snapshotPages"; import { vcsEnvironment } from "./vcs"; const PROJECT_PATH_SEARCH_DEBOUNCE_MS = 120; @@ -36,14 +42,24 @@ const PROJECT_CONTENT_SEARCH_LIMIT = 500; const THREAD_SEARCH_DEBOUNCE_MS = 200; const VCS_REF_LIST_LIMIT = 100; const EMPTY_REFS: ReadonlyArray = []; +const EMPTY_HISTORY_REFS: ReadonlyArray = []; const EMPTY_CONTENT_MATCHES: ReadonlyArray = []; const INITIAL_BRANCH_CURSORS = [undefined] as const; const EMPTY_THREAD_SEARCH_MATCHES: ReadonlyArray = Object.freeze([]); +const isVcsSnapshotExpiredError = Schema.is(VcsSnapshotExpiredError); const EMPTY_THREAD_SEARCH_ATOM = Atom.make({ matches: EMPTY_THREAD_SEARCH_MATCHES, isLoading: false, }).pipe(Atom.withLabel("web:thread-search:empty")); +export function makeVcsSnapshotCacheKey(generation: number, revision: number): string { + return JSON.stringify([generation, revision]); +} + +export function isVcsSnapshotExpiredCause(cause: Cause.Cause): boolean { + return isVcsSnapshotExpiredError(Cause.squash(cause)); +} + const threadSearchResultsAtom = createThreadSearchResultsAtomFamily({ getSearchAtom: (environmentId, query) => orchestrationEnvironment.threadSearch({ @@ -204,6 +220,99 @@ export function usePaginatedBranches(target: VcsRefTarget) { }; } +export function usePaginatedHistoryRefs( + target: VcsRefTarget, + options?: { + readonly limit?: number; + readonly namespace?: "local" | "remote" | "tag"; + readonly revision?: number; + }, +) { + const environmentId = target.environmentId; + const cwd = target.cwd; + const query = target.query?.trim() ?? ""; + const limit = options?.limit ?? VCS_REF_LIST_LIMIT; + const namespace = options?.namespace ?? "local"; + const revision = options?.revision ?? 0; + const connection = useEnvironmentQuery( + environmentId === null ? null : environmentCatalog.stateAtom(environmentId), + ).data; + const connectionGeneration = connection?.phase === "connected" ? connection.generation : null; + const targetKey = + environmentId !== null && cwd !== null + ? JSON.stringify([ + environmentId, + cwd, + query, + limit, + namespace, + revision, + connectionGeneration, + ]) + : null; + const makePageAtom = useMemo(() => { + if (environmentId === null || cwd === null) return null; + return (cursor: string | undefined, generation: number, refreshFirstPage: boolean) => + vcsEnvironment.listHistoryRefs({ + environmentId, + cacheKey: makeVcsSnapshotCacheKey(generation, revision), + input: { + cwd, + ...(query.length > 0 ? { query } : {}), + ...(cursor === undefined ? {} : { cursor }), + limit, + namespace, + ...(cursor === undefined && refreshFirstPage ? { refresh: true } : {}), + }, + }); + }, [cwd, environmentId, limit, namespace, query, revision]); + const pagination = usePaginatedSnapshotPages({ + targetKey, + label: "web:vcs-history-ref-pages", + makePageAtom, + getNextCursor: (page) => page.nextCursor, + isExpiredError: isVcsSnapshotExpiredCause, + }); + const { values } = pagination; + const data = useMemo(() => { + const first = values[0] ?? null; + const last = values.at(-1) ?? null; + if (first === null || last === null) return null; + const refs = new Map(); + for (const value of values) { + for (const ref of value.refs) refs.set(ref.name, ref); + } + return { + refs: [...refs.values()], + currentRef: first.currentRef, + isRepo: first.isRepo, + repositoryKey: first.repositoryKey, + nextCursor: last.nextCursor, + isComplete: last.isComplete, + }; + }, [values]); + const failed = pagination.failed; + const error = + failed?._tag === "Failure" + ? (() => { + const cause = Cause.squash(failed.cause); + return cause instanceof Error && cause.message.trim().length > 0 + ? cause.message + : "Failed to load refs."; + })() + : null; + return { + data, + refs: data?.refs ?? EMPTY_HISTORY_REFS, + error, + isPending: pagination.isPending, + isFetchingNextPage: pagination.isFetchingNextPage, + refresh: pagination.refresh, + retry: pagination.retry, + loadNext: pagination.loadNext, + }; +} + type ProjectPathSearchTarget = ComposerPathSearchTarget & { readonly kind?: ProjectEntryKind | undefined; readonly imageOnly?: boolean | undefined; diff --git a/apps/web/src/state/query.ts b/apps/web/src/state/query.ts index f5cb765a2479..5716ecfcb769 100644 --- a/apps/web/src/state/query.ts +++ b/apps/web/src/state/query.ts @@ -11,6 +11,7 @@ export interface EnvironmentQueryView { readonly data: A | null; readonly dataUpdatedAt: number | null; readonly error: string | null; + readonly errorCause: Cause.Cause | null; readonly isPending: boolean; readonly isSuccess: boolean; readonly refresh: () => void; @@ -33,6 +34,7 @@ export function useEnvironmentQuery( data: Option.getOrNull(AsyncResult.value(result)), dataUpdatedAt: result._tag === "Success" ? result.timestamp : null, error: result._tag === "Failure" ? formatEnvironmentQueryError(result.cause) : null, + errorCause: result._tag === "Failure" ? result.cause : null, isPending: atom !== null && result.waiting, isSuccess: result._tag === "Success", refresh, diff --git a/apps/web/src/state/snapshotPages.test.tsx b/apps/web/src/state/snapshotPages.test.tsx new file mode 100644 index 000000000000..8c4bae141117 --- /dev/null +++ b/apps/web/src/state/snapshotPages.test.tsx @@ -0,0 +1,177 @@ +import * as Cause from "effect/Cause"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { reactHookHarness as hooks } from "../test/reactHookHarness"; +import { usePaginatedSnapshotPages } from "./snapshotPages"; + +interface Page { + readonly next: string | null; +} + +type Result = AsyncResult.AsyncResult; +type PageAtom = Atom.Atom; + +const state = vi.hoisted(() => ({ + atoms: [] as Array<{ + readonly cursor: string | undefined; + readonly generation: number; + readonly target: string; + }>, + pageResults: new Map Result>(), + results: new Map(), +})); + +vi.mock("react", async (importOriginal) => { + const actual = await importOriginal(); + const { reactHookHarness } = await import("../test/reactHookHarness"); + return { + ...actual, + useCallback: reactHookHarness.useCallback, + useEffect: (effect: () => void) => effect(), + useMemo: reactHookHarness.useMemo, + useRef: reactHookHarness.useRef, + useState: reactHookHarness.useState, + }; +}); + +vi.mock("@effect/atom-react", () => ({ + useAtomValue: (atom: { readonly value: ReadonlyArray }) => atom.value, +})); + +vi.mock("effect/unstable/reactivity", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + Atom: { + ...actual.Atom, + make: (create: unknown) => { + if (typeof create !== "function") return actual.Atom.make(create); + return { + pipe: () => ({ + get value() { + return create((atom: PageAtom) => state.pageResults.get(atom)!()); + }, + }), + }; + }, + withLabel: () => (atom: unknown) => atom, + }, + }; +}); + +vi.mock("../rpc/atomRegistry", () => ({ appAtomRegistry: { refresh: vi.fn() } })); + +function page(next: string | null, waiting = false): Result { + return AsyncResult.success({ next }, { waiting }); +} + +function expired(): Result { + return AsyncResult.failure(Cause.fail(new Error("expired"))); +} + +function key(target: string, generation: number, cursor: string | undefined): string { + return `${target}:${generation}:${cursor ?? "first"}`; +} + +function render(targetKey: string) { + hooks.beginRender(); + return usePaginatedSnapshotPages({ + targetKey, + label: "test:snapshot-pages", + makePageAtom: (cursor, generation) => { + state.atoms.push({ cursor, generation, target: targetKey }); + const resultKey = key(targetKey, generation, cursor); + const initialResult = state.results.get(resultKey); + if (initialResult === undefined) throw new Error(`Missing result for ${resultKey}`); + const atom = Atom.make(initialResult); + state.pageResults.set(atom, () => state.results.get(resultKey) ?? initialResult); + return atom; + }, + getNextCursor: (value) => value.next, + isExpiredError: (cause) => { + const error = Cause.squash(cause); + return error instanceof Error && error.message === "expired"; + }, + }); +} + +describe("usePaginatedSnapshotPages", () => { + beforeEach(() => { + hooks.reset(); + state.atoms = []; + state.pageResults.clear(); + state.results.clear(); + }); + + it("retries consecutive expired responses only once", () => { + state.results.set(key("A", 0, undefined), expired()); + state.results.set(key("A", 1, undefined), expired()); + + render("A"); + render("A"); + render("A"); + + expect(state.atoms.map((atom) => atom.generation)).toEqual([0, 1, 1]); + }); + + it("keeps the expiry recovery lock while a previous page is refreshing", () => { + state.results.set(key("A", 0, undefined), expired()); + state.results.set(key("A", 1, undefined), page(null, true)); + + render("A"); + render("A"); + state.results.set(key("A", 1, undefined), expired()); + render("A"); + + expect(state.atoms.map((atom) => atom.generation)).toEqual([0, 1, 1]); + }); + + it("starts one fresh first page for a manual refresh", () => { + state.results.set(key("A", 0, undefined), page(null)); + state.results.set(key("A", 1, undefined), page(null)); + + const initial = render("A"); + initial.refresh(); + render("A"); + + expect(state.atoms.map((atom) => atom.generation)).toEqual([0, 1]); + }); + + it("allows an expired target to recover again after a target switch", () => { + state.results.set(key("A", 0, undefined), expired()); + state.results.set(key("A", 1, undefined), expired()); + state.results.set(key("A", 2, undefined), page(null)); + state.results.set(key("B", 1, undefined), page(null)); + + render("A"); + render("A"); + render("B"); + render("A"); + render("A"); + + expect(state.atoms.map((atom) => `${atom.target}:${atom.generation}`)).toEqual([ + "A:0", + "A:1", + "B:1", + "A:1", + "A:2", + ]); + }); + + it("does not resurrect a previous target when its stale page callback runs", () => { + state.results.set(key("A", 0, undefined), page("next-a")); + state.results.set(key("B", 0, undefined), page(null)); + + const targetA = render("A"); + render("B"); + targetA.loadNext(); + render("B"); + + expect(state.atoms).toEqual([ + { target: "A", generation: 0, cursor: undefined }, + { target: "B", generation: 0, cursor: undefined }, + { target: "B", generation: 0, cursor: undefined }, + ]); + }); +}); diff --git a/apps/web/src/state/snapshotPages.ts b/apps/web/src/state/snapshotPages.ts new file mode 100644 index 000000000000..13c0d1c35c84 --- /dev/null +++ b/apps/web/src/state/snapshotPages.ts @@ -0,0 +1,171 @@ +import { useAtomValue } from "@effect/atom-react"; +import type * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { appAtomRegistry } from "../rpc/atomRegistry"; + +const INITIAL_CURSORS = [undefined] as const; + +type PageAtom = Atom.Atom>; + +interface PaginationState { + readonly targetKey: string | null; + readonly cursors: ReadonlyArray; + readonly generation: number; + readonly refreshFirstPage: boolean; +} + +export function usePaginatedSnapshotPages(input: { + readonly targetKey: string | null; + readonly label: string; + readonly makePageAtom: + | (( + cursor: Cursor | undefined, + generation: number, + refreshFirstPage: boolean, + ) => PageAtom) + | null; + readonly getNextCursor: (page: Value) => Cursor | null | undefined; + readonly maxPages?: number; + readonly isExpiredError?: (cause: Cause.Cause) => boolean; + readonly refreshPage?: boolean; +}) { + const expiredRecovery = useRef(null); + const [pagination, setPagination] = useState>({ + targetKey: input.targetKey, + cursors: INITIAL_CURSORS, + generation: 0, + refreshFirstPage: false, + }); + const activePagination: PaginationState = + pagination.targetKey === input.targetKey + ? pagination + : { + targetKey: input.targetKey, + cursors: INITIAL_CURSORS, + generation: pagination.generation, + refreshFirstPage: false, + }; + if (pagination.targetKey !== input.targetKey) { + expiredRecovery.current = null; + setPagination(activePagination); + } + const pageAtoms = useMemo(() => { + const makePageAtom = input.makePageAtom; + return input.targetKey === null || makePageAtom === null + ? [] + : activePagination.cursors.map((cursor, index) => + makePageAtom( + cursor, + activePagination.generation, + index === 0 && activePagination.refreshFirstPage, + ), + ); + }, [ + activePagination.cursors, + activePagination.generation, + activePagination.refreshFirstPage, + input.makePageAtom, + input.targetKey, + ]); + const pagesAtom = useMemo( + () => + Atom.make((get) => pageAtoms.map((atom) => get(atom))).pipe( + Atom.withLabel(`${input.label}:${input.targetKey ?? "empty"}`), + ), + [input.label, input.targetKey, pageAtoms], + ); + const results = useAtomValue(pagesAtom); + const values = useMemo( + () => + results.flatMap((result) => { + const value = Option.getOrNull(AsyncResult.value(result)); + return value === null ? [] : [value]; + }), + [results], + ); + const failedIndex = results.findIndex((result) => result._tag === "Failure"); + const failed = failedIndex === -1 ? null : results[failedIndex]!; + + useEffect(() => { + if (failed?._tag !== "Failure" || input.isExpiredError?.(failed.cause) !== true) return; + if (expiredRecovery.current === input.targetKey) { + return; + } + expiredRecovery.current = input.targetKey; + setPagination({ + targetKey: input.targetKey, + cursors: INITIAL_CURSORS, + generation: activePagination.generation + 1, + refreshFirstPage: true, + }); + }, [activePagination.generation, failed, input.isExpiredError, input.targetKey]); + + useEffect(() => { + if (expiredRecovery.current !== input.targetKey) return; + if ( + results.length > 0 && + results.every((result) => result._tag === "Success" && result.waiting === false) + ) { + expiredRecovery.current = null; + } + }, [input.targetKey, results]); + + const refresh = useCallback(() => { + if (input.targetKey === null) return; + const firstPage = pageAtoms[0]; + setPagination({ + targetKey: input.targetKey, + cursors: INITIAL_CURSORS, + generation: activePagination.generation + 1, + refreshFirstPage: true, + }); + expiredRecovery.current = null; + if (input.refreshPage === true && firstPage !== undefined) appAtomRegistry.refresh(firstPage); + }, [activePagination.generation, input.refreshPage, input.targetKey, pageAtoms]); + const retry = useCallback(() => { + const failedPage = failedIndex === -1 ? undefined : pageAtoms[failedIndex]; + if (failedPage !== undefined) appAtomRegistry.refresh(failedPage); + }, [failedIndex, pageAtoms]); + const loadNext = useCallback(() => { + const lastPage = values.at(-1); + const nextCursor = lastPage === undefined ? null : input.getNextCursor(lastPage); + if (input.targetKey === null || nextCursor === null || nextCursor === undefined) { + return; + } + setPagination((current) => { + if (current.targetKey !== input.targetKey) return current; + const cursors = current.cursors; + if (input.maxPages !== undefined && cursors.length >= input.maxPages) return current; + return cursors.includes(nextCursor) + ? current + : { + targetKey: input.targetKey, + cursors: [...cursors, nextCursor], + generation: current.generation, + refreshFirstPage: current.refreshFirstPage, + }; + }); + }, [input.getNextCursor, input.maxPages, input.targetKey, values]); + + const lastResult = results.at(-1); + + return { + cursors: activePagination.cursors, + generation: activePagination.generation, + pageAtoms, + results, + values, + failed, + isPending: results.some((result) => result.waiting), + isFetchingNextPage: + results.length > 1 && + lastResult?.waiting === true && + Option.isNone(AsyncResult.value(lastResult)), + refresh, + retry, + loadNext, + }; +} diff --git a/apps/web/src/state/vcs.ts b/apps/web/src/state/vcs.ts index dc8c251149f6..29c952e2a76b 100644 --- a/apps/web/src/state/vcs.ts +++ b/apps/web/src/state/vcs.ts @@ -5,5 +5,6 @@ import { import { connectionAtomRuntime } from "../connection/runtime"; -export const vcsEnvironment = createVcsEnvironmentAtoms(connectionAtomRuntime); +export const vcsEnvironment: ReturnType = + createVcsEnvironmentAtoms(connectionAtomRuntime); export const vcsActionManager = createVcsActionManager(connectionAtomRuntime); diff --git a/apps/web/src/test/reactHookHarness.ts b/apps/web/src/test/reactHookHarness.ts index 3a9bf9484ea1..aa7cc592e8f3 100644 --- a/apps/web/src/test/reactHookHarness.ts +++ b/apps/web/src/test/reactHookHarness.ts @@ -51,9 +51,24 @@ function createReactHookHarness() { nextIndex(); return callback; }, - useMemo(factory: () => T): T { - nextIndex(); - return factory(); + useMemo(factory: () => T, dependencies?: ReadonlyArray): T { + const index = nextIndex(); + const previous = slots[index] as + | { readonly dependencies: ReadonlyArray | undefined; readonly value: T } + | undefined; + if ( + previous !== undefined && + dependencies !== undefined && + previous.dependencies !== undefined && + previous.dependencies.length === dependencies.length && + previous.dependencies.every((value, dependencyIndex) => + Object.is(value, dependencies[dependencyIndex]), + ) + ) + return previous.value; + const value = factory(); + slots[index] = { dependencies, value }; + return value; }, useMemoCache(size: number): unknown[] { const index = nextIndex(); diff --git a/docs/user/source-control.md b/docs/user/source-control.md index e6b921ac0bf2..26db770b13cd 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -242,3 +242,16 @@ the scope and merge strategy. GitHub rebases the remaining stack after merging. It can rewrite history and restart checks. If a layer fails, earlier updates remain; resolve that layer before retrying. GitHub may require manual conflict resolution after a lower layer is amended, even when its changes look independent. Stack actions require an environment that supports them. + +### Browse Repository History + +Open the right panel menu and choose **History** to explore the repository without leaving your thread. The history view shows a connected commit graph, branches, remotes, and tags. Select a ref to focus on it, or choose **All refs** to inspect the wider repository. + +History is currently available in web and desktop project views. Mobile keeps its existing Git status and actions, but does not yet include this dense history workspace. + +- Search by commit subject, author, ref, or hash. Use the clear control or `Escape` to reset a search. +- Select a commit to view its metadata and changed files, then open the same diff view used elsewhere in T3 Code. History diffs use Git's normal patch output, so they match the familiar command-line review view. +- Copy a full commit hash from a row. When a GitHub repository is connected, issue references in commit subjects open the matching issue. +- Branch rows show ahead and behind counts when an upstream is configured. + +For large repositories, history is loaded in pages and the commit list is virtualized. The initial browsing window is intentionally bounded so the right panel remains responsive; select a branch or search to narrow the result. diff --git a/packages/client-runtime/src/state/runtime.test.ts b/packages/client-runtime/src/state/runtime.test.ts index d04f313d26e5..bec56d6b32cb 100644 --- a/packages/client-runtime/src/state/runtime.test.ts +++ b/packages/client-runtime/src/state/runtime.test.ts @@ -81,8 +81,14 @@ function queryConnectionState( const makeEnvironmentQueryHarness = Effect.fn("TestEnvironmentQuery.makeHarness")(function* ( execute: Effect.Effect, + options: { + readonly initialState?: SupervisorConnectionState; + readonly revalidateOnReconnect?: boolean; + } = {}, ) { - const supervisorState = yield* SubscriptionRef.make(queryConnectionState()); + const supervisorState = yield* SubscriptionRef.make( + options.initialState ?? queryConnectionState(), + ); const supervisorSession = yield* SubscriptionRef.make(Option.some(QUERY_RPC_SESSION)); const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ target: QUERY_ENVIRONMENT, @@ -107,9 +113,13 @@ const makeEnvironmentQueryHarness = Effect.fn("TestEnvironmentQuery.makeHarness" const runtime = Atom.runtime( Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), ); + const revalidateOnReconnect = options.revalidateOnReconnect; const family = createEnvironmentQueryAtomFamily(runtime, { label: "test.environment-query", staleTimeMs: 60_000, + ...(revalidateOnReconnect === undefined + ? {} + : { revalidateOnReconnect: () => revalidateOnReconnect }), execute: () => execute, }); @@ -264,6 +274,12 @@ describe("environmentRpcKey", () => { expect(environmentRpcKey(originalTarget)).not.toBe(environmentRpcKey(nextTarget)); expect(environmentRpcKey(originalTarget)).toBe(environmentRpcKey({ ...originalTarget })); + expect(environmentRpcKey({ ...originalTarget, cacheKey: 1 })).not.toBe( + environmentRpcKey(originalTarget), + ); + expect(environmentRpcKey({ ...originalTarget, cacheKey: 1 })).not.toBe( + environmentRpcKey({ ...originalTarget, cacheKey: 2 }), + ); expect( environmentRpcKey({ environmentId: EnvironmentId.make("environment-2"), @@ -548,6 +564,139 @@ describe("environment query lifecycle", () => { }), ), ); + + it.effect("settles a non-revalidating query as unavailable while offline", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeEnvironmentQueryHarness(Effect.succeed("connected"), { + initialState: queryConnectionState({ + network: "offline", + phase: "offline", + stage: null, + lastFailure: null, + }), + revalidateOnReconnect: false, + }); + const registry = yield* mountEnvironmentQuery(harness.atom); + + const result = yield* AtomRegistry.getResult(registry, harness.atom, { + suspendOnWaiting: true, + }).pipe(Effect.exit); + + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + expect(Cause.squash(result.cause)).toMatchObject({ + _tag: "EnvironmentRpcUnavailableError", + environmentId: QUERY_ENVIRONMENT.environmentId, + message: `Environment ${QUERY_ENVIRONMENT.environmentId} is offline.`, + }); + } + }), + ), + ); + + it.effect("does not revalidate a cursor query after reconnecting", () => + Effect.scoped( + Effect.gen(function* () { + let executions = 0; + const harness = yield* makeEnvironmentQueryHarness( + Effect.sync(() => { + executions += 1; + return "cached"; + }), + { revalidateOnReconnect: false }, + ); + const registry = yield* mountEnvironmentQuery(harness.atom); + + expect( + yield* AtomRegistry.getResult(registry, harness.atom, { + suspendOnWaiting: true, + }), + ).toBe("cached"); + + yield* SubscriptionRef.set( + harness.supervisorState, + queryConnectionState({ phase: "connecting", stage: "opening" }), + ); + yield* Effect.yieldNow; + yield* SubscriptionRef.set( + harness.supervisorState, + queryConnectionState({ generation: 2 }), + ); + yield* Effect.yieldNow; + + expect(executions).toBe(1); + expect(registry.get(harness.atom)).toMatchObject({ + _tag: "Success", + value: "cached", + waiting: false, + }); + }), + ), + ); + + it.effect("settles a non-revalidating query that goes offline while reconnecting", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeEnvironmentQueryHarness(Effect.succeed("connected"), { + initialState: queryConnectionState({ phase: "connecting", stage: "opening" }), + revalidateOnReconnect: false, + }); + const registry = yield* mountEnvironmentQuery(harness.atom); + + yield* SubscriptionRef.set( + harness.supervisorState, + queryConnectionState({ + network: "offline", + phase: "offline", + stage: null, + lastFailure: null, + }), + ); + + const result = yield* AtomRegistry.getResult(registry, harness.atom, { + suspendOnWaiting: true, + }).pipe(Effect.exit); + + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + expect(Cause.squash(result.cause)).toMatchObject({ + _tag: "EnvironmentRpcUnavailableError", + environmentId: QUERY_ENVIRONMENT.environmentId, + message: `Environment ${QUERY_ENVIRONMENT.environmentId} is offline.`, + }); + } + }), + ), + ); + + it.effect("waits for a live session before executing a non-revalidating query", () => + Effect.scoped( + Effect.gen(function* () { + let executions = 0; + const harness = yield* makeEnvironmentQueryHarness( + Effect.sync(() => { + executions += 1; + return "connected"; + }), + { revalidateOnReconnect: false }, + ); + yield* SubscriptionRef.set(harness.supervisorSession, Option.none()); + const registry = yield* mountEnvironmentQuery(harness.atom); + + yield* Effect.yieldNow; + expect(executions).toBe(0); + + yield* SubscriptionRef.set(harness.supervisorSession, Option.some(QUERY_RPC_SESSION)); + expect( + yield* AtomRegistry.getResult(registry, harness.atom, { + suspendOnWaiting: true, + }), + ).toBe("connected"); + expect(executions).toBe(1); + }), + ), + ); }); describe("Atom.fn mutation semantics", () => { diff --git a/packages/client-runtime/src/state/runtime.ts b/packages/client-runtime/src/state/runtime.ts index 84946bf9e165..ffbc9d5faa5a 100644 --- a/packages/client-runtime/src/state/runtime.ts +++ b/packages/client-runtime/src/state/runtime.ts @@ -7,7 +7,7 @@ import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; -import type { ConnectionAttemptError } from "../connection/model.ts"; +import type { ConnectionAttemptError, SupervisorConnectionState } from "../connection/model.ts"; import { EnvironmentNotRegisteredError, EnvironmentRegistry } from "../connection/registry.ts"; import { type EnvironmentRpcInput, @@ -53,6 +53,7 @@ interface EnvironmentQueryAtomOptions extends EnvironmentAtomOpt readonly staleTimeMs?: number; readonly idleTtlMs?: number; readonly refreshIntervalMs?: number; + readonly revalidateOnReconnect?: (input: Input) => boolean; readonly refreshTrigger?: (target: { readonly environmentId: EnvironmentIdType; readonly input: Input; @@ -430,8 +431,9 @@ export async function settlePromise( export function environmentRpcKey(target: { readonly environmentId: EnvironmentIdType; readonly input: Input; + readonly cacheKey?: string | number; }): string { - return JSON.stringify([target.environmentId, target.input]); + return JSON.stringify([target.environmentId, target.input, target.cacheKey]); } function parseEnvironmentRpcKey(key: string): { @@ -482,6 +484,23 @@ export function followStreamInEnvironment( ); } +function unavailableEnvironmentRpc( + environmentId: EnvironmentIdType, + connectionState: Pick, +): Effect.Effect { + if (connectionState.lastFailure !== null) { + return Effect.fail(connectionState.lastFailure); + } + return Effect.fail( + new EnvironmentRpcUnavailableError({ + environmentId, + message: `Environment ${environmentId} is ${ + connectionState.phase === "available" ? "not connected" : connectionState.phase + }.`, + }), + ); +} + export function createEnvironmentQueryAtomFamily( runtime: Atom.AtomRuntime, options: EnvironmentQueryAtomOptions< @@ -493,6 +512,7 @@ export function createEnvironmentQueryAtomFamily( ): (target: { readonly environmentId: EnvironmentIdType; readonly input: Input; + readonly cacheKey?: string | number; }) => Atom.Atom> { const connectionAtom = Atom.family((environmentId: EnvironmentIdType) => runtime.atom( @@ -519,6 +539,42 @@ export function createEnvironmentQueryAtomFamily( A, E | ConnectionAttemptError | EnvironmentNotRegisteredError | EnvironmentRpcUnavailableError >((get) => { + if (!(options.revalidateOnReconnect?.(target.input) ?? true)) { + return runInEnvironment( + target.environmentId, + EnvironmentSupervisor.pipe( + Effect.flatMap((supervisor) => + Stream.zipLatest( + SubscriptionRef.changes(supervisor.state), + SubscriptionRef.changes(supervisor.session), + ).pipe( + Stream.filter( + ([state, session]) => + (state.phase === "connected" && Option.isSome(session)) || + state.phase === "available" || + state.phase === "offline" || + state.phase === "blocked", + ), + Stream.take(1), + Stream.runHead, + Effect.flatMap( + Option.match({ + onNone: () => Effect.never, + onSome: ([state]): Effect.Effect< + A, + E | ConnectionAttemptError | EnvironmentRpcUnavailableError, + EnvironmentSupervisor | EnvironmentRegistry | AtomRegistry.AtomRegistry | R + > => + state.phase === "connected" + ? options.execute(target.input) + : unavailableEnvironmentRpc(target.environmentId, state), + }), + ), + ), + ), + ), + ); + } const connection = Option.getOrNull( AsyncResult.value(get(connectionAtom(target.environmentId))), ); @@ -537,17 +593,7 @@ export function createEnvironmentQueryAtomFamily( case "available": case "offline": case "blocked": - if (connectionState.lastFailure !== null) { - return Effect.fail(connectionState.lastFailure); - } - return Effect.fail( - new EnvironmentRpcUnavailableError({ - environmentId: target.environmentId, - message: `Environment ${target.environmentId} is ${ - connectionState.phase === "available" ? "not connected" : connectionState.phase - }.`, - }), - ); + return unavailableEnvironmentRpc(target.environmentId, connectionState); } }) .pipe( @@ -624,6 +670,7 @@ export function createEnvironmentRpcQueryAtomFamily) => boolean; readonly refreshTrigger?: (target: { readonly environmentId: EnvironmentIdType; readonly input: EnvironmentRpcInput; @@ -637,6 +684,9 @@ export function createEnvironmentRpcQueryAtomFamily) => options.execute?.(input) ?? request(options.tag, input), diff --git a/packages/client-runtime/src/state/vcs.test.ts b/packages/client-runtime/src/state/vcs.test.ts index d7a4692fc317..2ac2eae6bd7f 100644 --- a/packages/client-runtime/src/state/vcs.test.ts +++ b/packages/client-runtime/src/state/vcs.test.ts @@ -33,9 +33,11 @@ import { createVcsEnvironmentAtoms, makeCachedVcsRefsChanges, } from "./vcs.ts"; +import { executeAtomQuery } from "./runtime.ts"; import { invalidateCachedVcsRefs, invalidateVcsRefs, + vcsHistoryRevisionAtom, vcsRefsCacheStateAtom, } from "./vcsRefInvalidation.ts"; @@ -115,13 +117,90 @@ function cacheWithRefs( } describe("cached VCS refs", () => { - it("invalidates all ref streams in the mutated environment", () => { + it.effect("waits for the initial connection before requesting a cursor history page", () => + Effect.scoped( + Effect.gen(function* () { + const calls = yield* Ref.make(0); + const connectionState = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE); + const sessionState = yield* SubscriptionRef.make(Option.none()); + const client = { + [WS_METHODS.vcsListHistoryRefs]: () => + Ref.update(calls, (count) => count + 1).pipe( + Effect.as({ + refs: [], + currentRef: null, + isRepo: true, + hasPrimaryRemote: false, + nextCursor: null, + isComplete: true, + }), + ), + } as unknown as WsRpcProtocolClient; + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: connectionState, + session: sessionState, + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const run: EnvironmentRegistry.EnvironmentRegistry["Service"]["run"] = ( + _environmentId, + effect, + ) => Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor); + const runtime = Atom.runtime( + Layer.merge( + Layer.succeed( + EnvironmentRegistry.EnvironmentRegistry, + EnvironmentRegistry.EnvironmentRegistry.of({ + run, + } as EnvironmentRegistry.EnvironmentRegistry["Service"]), + ), + Layer.succeed(Persistence.EnvironmentCacheStore, cacheWithRefs(Option.none())), + ), + ); + const atoms = createVcsEnvironmentAtoms(runtime); + const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) => + Effect.sync(() => registry.dispose()), + ); + const result = yield* Effect.forkChild( + Effect.promise(() => + executeAtomQuery( + registry, + atoms.listHistoryRefs({ + environmentId: TARGET.environmentId, + input: { cwd: "/repo", cursor: "next", limit: 100 }, + }), + ), + ), + ); + + yield* Effect.yieldNow; + expect(yield* Ref.get(calls)).toBe(0); + + yield* SubscriptionRef.set(sessionState, Option.some(session(client))); + yield* SubscriptionRef.set(connectionState, CONNECTED_CONNECTION_STATE); + + expect(AsyncResult.isSuccess(yield* Fiber.join(result))).toBe(true); + expect(yield* Ref.get(calls)).toBe(1); + }), + ), + ); + + it("invalidates ref streams for an environment but history only for the mutated repository", () => { const registry = AtomRegistry.make(); const environment = { environmentId: TARGET.environmentId, + cwd: "/repo-a", + }; + const siblingRepository = { + environmentId: TARGET.environmentId, + cwd: "/repo-b", }; const otherEnvironment = { environmentId: EnvironmentId.make("environment-2"), + cwd: "/repo-a", }; expect(registry.get(vcsRefsCacheStateAtom(environment))).toEqual({ @@ -132,6 +211,9 @@ describe("cached VCS refs", () => { revision: 0, persistedCacheReadable: true, }); + expect(registry.get(vcsHistoryRevisionAtom(environment))).toBe(0); + expect(registry.get(vcsHistoryRevisionAtom(siblingRepository))).toBe(0); + expect(registry.get(vcsHistoryRevisionAtom(otherEnvironment))).toBe(0); invalidateVcsRefs(registry, environment); @@ -143,6 +225,9 @@ describe("cached VCS refs", () => { revision: 0, persistedCacheReadable: true, }); + expect(registry.get(vcsHistoryRevisionAtom(environment))).toBe(1); + expect(registry.get(vcsHistoryRevisionAtom(siblingRepository))).toBe(0); + expect(registry.get(vcsHistoryRevisionAtom(otherEnvironment))).toBe(0); registry.dispose(); }); diff --git a/packages/client-runtime/src/state/vcs.ts b/packages/client-runtime/src/state/vcs.ts index bedcb751216f..42f3743f7def 100644 --- a/packages/client-runtime/src/state/vcs.ts +++ b/packages/client-runtime/src/state/vcs.ts @@ -18,6 +18,7 @@ import { Atom, AtomRegistry } from "effect/unstable/reactivity"; import { createEnvironmentRpcCommand, createEnvironmentRpcSubscriptionAtomFamily, + createEnvironmentRpcQueryAtomFamily, createEnvironmentSubscriptionAtomFamily, } from "./runtime.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; @@ -29,6 +30,7 @@ import { followStreamInEnvironment } from "./runtime.ts"; import { vcsCommandConcurrency, vcsCommandScheduler } from "./vcsCommandScheduler.ts"; import { invalidateCachedVcsRefs, + vcsHistoryRevisionAtom, vcsRefsCacheStateAtom, withVcsRefsPersistenceLock, } from "./vcsRefInvalidation.ts"; @@ -272,13 +274,41 @@ export function createVcsEnvironmentAtoms( const invalidateRefs = ( target: { readonly environmentId: EnvironmentId; readonly input: { readonly cwd: string } }, registry: AtomRegistry.AtomRegistry, + invalidateHistory = true, ) => - invalidateCachedVcsRefs(registry, { - environmentId: target.environmentId, - cwd: target.input.cwd, - }); + invalidateCachedVcsRefs( + registry, + { + environmentId: target.environmentId, + cwd: target.input.cwd, + }, + invalidateHistory, + ); return { + historyRevisionAtom: vcsHistoryRevisionAtom, + listHistoryRefs: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:vcs:list-history-refs", + tag: WS_METHODS.vcsListHistoryRefs, + revalidateOnReconnect: (input) => input.cursor === undefined, + }), + getHistory: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:vcs:get-history", + tag: WS_METHODS.vcsGetHistory, + revalidateOnReconnect: (input) => input.cursor === undefined, + }), + getCommitDetails: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:vcs:get-commit-details", + tag: WS_METHODS.vcsGetCommitDetails, + }), + listCommitFiles: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:vcs:list-commit-files", + tag: WS_METHODS.vcsListCommitFiles, + }), + getCommitDiff: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:vcs:get-commit-diff", + tag: WS_METHODS.vcsGetCommitDiff, + }), listRefs, status: createEnvironmentSubscriptionAtomFamily(runtime, { label: "environment-data:vcs:status", @@ -306,7 +336,7 @@ export function createVcsEnvironmentAtoms( tag: WS_METHODS.vcsRefreshStatus, scheduler: vcsCommandScheduler, concurrency: vcsCommandConcurrency, - onSettled: invalidateRefs, + onSettled: (target, registry) => invalidateRefs(target, registry, false), }), createWorktree: createEnvironmentRpcCommand(runtime, { label: "environment-data:vcs:create-worktree", diff --git a/packages/client-runtime/src/state/vcsAction.test.ts b/packages/client-runtime/src/state/vcsAction.test.ts index 24aa314b1cff..44ce39e4d651 100644 --- a/packages/client-runtime/src/state/vcsAction.test.ts +++ b/packages/client-runtime/src/state/vcsAction.test.ts @@ -43,7 +43,7 @@ import { VcsActionTargetKeyParseError, VcsActionUnavailableError, } from "./vcsAction.ts"; -import { vcsRefsCacheStateAtom } from "./vcsRefInvalidation.ts"; +import { vcsHistoryRevisionAtom, vcsRefsCacheStateAtom } from "./vcsRefInvalidation.ts"; const actionId = "action-123"; const action = "commit_push" as const; @@ -653,9 +653,11 @@ describe("vcsActionState", () => { Effect.sync(() => registry.dispose()), ); const state = vcsRefsCacheStateAtom({ environmentId }); + const historyRevision = vcsHistoryRevisionAtom({ environmentId, cwd }); expect(registry.get(state).revision).toBe(0); const threadId = ThreadId.make("thread-stacked-action"); + expect(registry.get(historyRevision)).toBe(0); const successfulResult = yield* Effect.promise(() => manager.runStackedAction(targetKey).run(registry, { actionId: successfulActionId, @@ -666,6 +668,7 @@ describe("vcsActionState", () => { expect(AsyncResult.isSuccess(successfulResult)).toBe(true); expect(registry.get(state).revision).toBe(1); + expect(registry.get(historyRevision)).toBe(1); expect(removed).toEqual([`${environmentId}:*`]); // The server links a created pull request to this thread, so the id must ride along. expect(rpcInputs).toEqual([ @@ -681,6 +684,7 @@ describe("vcsActionState", () => { expect(AsyncResult.isFailure(failedResult)).toBe(true); expect(registry.get(state).revision).toBe(2); + expect(registry.get(historyRevision)).toBe(2); expect(removed).toEqual([`${environmentId}:*`, `${environmentId}:*`]); }), ), diff --git a/packages/client-runtime/src/state/vcsRefInvalidation.ts b/packages/client-runtime/src/state/vcsRefInvalidation.ts index ff9de7bd1cc6..d29f27180547 100644 --- a/packages/client-runtime/src/state/vcsRefInvalidation.ts +++ b/packages/client-runtime/src/state/vcsRefInvalidation.ts @@ -25,21 +25,33 @@ const stateByEnvironment = Atom.family((environmentId: EnvironmentId) => persistedCacheReadable: true, }).pipe(Atom.keepAlive, Atom.withLabel(`environment-data:vcs:list-refs-state:${environmentId}`)), ); +const historyRevisionByRepository = Atom.family((key: string) => + Atom.make(0).pipe(Atom.withLabel(`environment-data:vcs:history-revision:${key}`)), +); const persistenceLock = PartitionedSemaphore.makeUnsafe({ permits: 1 }); export function vcsRefsCacheStateAtom(target: VcsRefsInvalidationTarget) { return stateByEnvironment(target.environmentId); } +export function vcsHistoryRevisionAtom( + target: CachedVcsRefsInvalidationTarget, +): Atom.Writable { + return historyRevisionByRepository(JSON.stringify([target.environmentId, target.cwd])); +} + export function invalidateVcsRefs( registry: AtomRegistry.AtomRegistry, - target: VcsRefsInvalidationTarget, + target: CachedVcsRefsInvalidationTarget, persistedCacheReadable?: boolean, + invalidateHistory = true, ): void { registry.update(vcsRefsCacheStateAtom(target), (state) => ({ revision: state.revision + 1, persistedCacheReadable: persistedCacheReadable ?? state.persistedCacheReadable, })); + if (invalidateHistory) + registry.update(vcsHistoryRevisionAtom(target), (revision) => revision + 1); } export function withVcsRefsPersistenceLock( @@ -59,6 +71,7 @@ export function withVcsRefsPersistenceLock( export const invalidateCachedVcsRefs = Effect.fn("VcsRefsState.invalidateCached")(function* ( registry: AtomRegistry.AtomRegistry, target: CachedVcsRefsInvalidationTarget, + invalidateHistory = true, ) { const cache = yield* EnvironmentCacheStore; yield* withVcsRefsPersistenceLock( @@ -77,7 +90,7 @@ export const invalidateCachedVcsRefs = Effect.fn("VcsRefsState.invalidateCached" ), ), ); - invalidateVcsRefs(registry, target, persistedCacheReadable); + invalidateVcsRefs(registry, target, persistedCacheReadable, invalidateHistory); }), ); }); From 2d03490094fdca7b288c0df22229dc54df574453 Mon Sep 17 00:00:00 2001 From: Defmon3 <111503233+Defmon3@users.noreply.github.com> Date: Tue, 22 Sep 2026 00:39:14 +0200 Subject: [PATCH 2/2] fix(#6315): keep History usable on small screens --- apps/web/src/components/ChatView.tsx | 10 +- .../GitHistoryPanel.filters-details.test.tsx | 155 +++++++++++++++++- .../GitHistoryPanel.test-fixture.tsx | 18 +- apps/web/src/components/GitHistoryPanel.tsx | 93 +++++++---- .../git-history/GitHistoryCommitDiff.tsx | 16 +- .../git-history/GitHistoryPanelState.ts | 111 +++++++++++++ .../git-history/useGitHistoryRefs.ts | 71 +++++--- apps/web/src/state/snapshotPages.ts | 24 ++- docs/user/source-control.md | 2 + 9 files changed, 421 insertions(+), 79 deletions(-) create mode 100644 apps/web/src/components/git-history/GitHistoryPanelState.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d815176beb52..d34c5654615f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -502,6 +502,7 @@ import { } from "../lib/attachmentUploadQueue"; import { sanitizeThreadErrorMessage } from "~/rpc/transportError"; import { RightPanelSheet } from "./RightPanelSheet"; +import { createGitHistoryPanelStore } from "./git-history/GitHistoryPanelState"; import { previewEnvironment } from "../state/preview"; import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments"; import { appAtomRegistry } from "../rpc/atomRegistry"; @@ -1762,6 +1763,7 @@ export default function ChatView(props: ChatViewProps) { const [pendingUserInputQuestionIndexByRequestId, setPendingUserInputQuestionIndexByRequestId] = useState>({}); const shouldUseRightPanelSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); + const [gitHistoryPanelStore] = useState(createGitHistoryPanelStore); const isMobileViewport = useMediaQuery("max-sm"); const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); const [pullRequestDialogState, setPullRequestDialogState] = @@ -9753,7 +9755,13 @@ export default function ChatView(props: ChatViewProps) { isGitRepo && gitCwd !== null ? ( - + ) : renderedRightPanelSurface?.kind === "pull-request" && !pullRequestsCapabilityKnown ? ( diff --git a/apps/web/src/components/GitHistoryPanel.filters-details.test.tsx b/apps/web/src/components/GitHistoryPanel.filters-details.test.tsx index 731f411e36f9..0e5471559f69 100644 --- a/apps/web/src/components/GitHistoryPanel.filters-details.test.tsx +++ b/apps/web/src/components/GitHistoryPanel.filters-details.test.tsx @@ -8,12 +8,14 @@ import { componentElement, componentTree, commit, + effectQueue, environmentId, flushEffects, gitRef, historyList, historyPageSize, historyState, + loadMoreHistory, newestMatchingCommitHash, page, primaryCommitHash, @@ -23,8 +25,103 @@ import { workspacePath, } from "./GitHistoryPanel.test-fixture"; import { CommitDiffView } from "./git-history/GitHistoryCommitDiff"; +import { createGitHistoryPanelStore } from "./git-history/GitHistoryPanelState"; describe("GitHistoryPanel filters and details", () => { + it("clears retained history state when a mounted panel changes threads in one repository", () => { + const stateStore = createGitHistoryPanelStore(); + historyState.pages.set(undefined, page([commit(primaryCommitHash, "Thread one")])); + renderPanel(stateStore, "thread-one"); + flushEffects(); + const filter = visitElements( + renderPanel(stateStore, "thread-one"), + (element) => element.props["aria-label"] === "Filter Git history", + ); + if (filter === null) throw new Error("Git history filter is missing."); + (filter.props.onChange as (event: { readonly target: { readonly value: string } }) => void)({ + target: { value: "thread" }, + }); + + const secondThread = renderPanel(stateStore, "thread-two"); + expect( + visitElements(secondThread, (element) => element.props["aria-label"] === "Filter Git history") + ?.props.value, + ).toBe(""); + const secondFilter = visitElements( + renderPanel(stateStore, "thread-two"), + (element) => element.props["aria-label"] === "Filter Git history", + ); + if (secondFilter === null) throw new Error("Git history filter is missing."); + ( + secondFilter.props.onChange as (event: { + readonly target: { readonly value: string }; + }) => void + )({ + target: { value: "second" }, + }); + expect( + visitElements( + renderPanel(stateStore, "thread-two"), + (element) => element.props["aria-label"] === "Filter Git history", + )?.props.value, + ).toBe("second"); + }); + + it("restores a filtered page-two diff after the responsive panel remounts", () => { + const stateStore = createGitHistoryPanelStore(); + historyState.pages.set( + undefined, + page([commit(primaryCommitHash, "Page one")], { hasMore: true, nextCursor: "page-two" }), + ); + historyState.pages.set("page-two", page([commit(secondaryCommitHash, "Page two")])); + renderPanel(stateStore); + flushEffects(); + + const filter = visitElements( + renderPanel(stateStore), + (element) => element.props["aria-label"] === "Filter Git history", + ); + if (filter === null) throw new Error("Git history filter is missing."); + (filter.props.onChange as (event: { readonly target: { readonly value: string } }) => void)({ + target: { value: "page" }, + }); + const refresh = visitElements( + renderPanel(stateStore), + (element) => element.props["aria-label"] === "Refresh Git history", + ); + if (refresh === null) throw new Error("Git history refresh is missing."); + (refresh.props.onClick as () => void)(); + renderPanel(stateStore); + flushEffects(); + loadMoreHistory(renderPanel(stateStore)); + renderPanel(stateStore); + flushEffects(); + const row = historyList(renderPanel(stateStore)).props.renderItem({ + item: historyList(renderPanel(stateStore)).props.data[1]!, + }); + (row.props.onSelect as (hash: string) => void)(secondaryCommitHash); + const details = componentElement(renderPanel(stateStore), "CommitDetailsPane"); + (details.props.onShowDiff as (hash: string) => void)(secondaryCommitHash); + + hooks.reset(); + const restoredDiff = componentElement(renderPanel(stateStore), "CommitDiffView"); + expect(restoredDiff.props.hash).toBe(secondaryCommitHash); + (restoredDiff.props.onBack as () => void)(); + + expect(stateStore.getState().historyPagination.generation).toBe(1); + expect( + visitElements( + renderPanel(stateStore), + (element) => element.props["aria-label"] === "Filter Git history", + )?.props.value, + ).toBe("page"); + + expect(historyList(renderPanel(stateStore)).props.data.map((row) => row.commit.hash)).toEqual([ + primaryCommitHash, + secondaryCommitHash, + ]); + }); + it("filters history by commit message", () => { historyState.pages.set( undefined, @@ -82,6 +179,40 @@ describe("GitHistoryPanel filters and details", () => { ).toBe(""); }); + it("keeps search and selected commit state when Activity resumes History", () => { + historyState.pages.set( + undefined, + page([ + commit(primaryCommitHash, "Provider neutral"), + commit(secondaryCommitHash, "Other change"), + ]), + ); + const initial = renderPanel(); + flushEffects(); + const filter = visitElements( + initial, + (element) => element.props["aria-label"] === "Filter Git history", + ); + expect(filter).not.toBeNull(); + if (filter === null) throw new Error("Git history filter is missing."); + (filter.props.onChange as (event: { readonly target: { readonly value: string } }) => void)({ + target: { value: "provider neutral" }, + }); + const row = historyList(renderPanel()).props.renderItem({ + item: historyList(renderPanel()).props.data[0]!, + }); + (row.props.onSelect as (hash: string) => void)(primaryCommitHash); + + effectQueue.dependencies.length = 0; + renderPanel(); + flushEffects(); + + expect(historyList(renderPanel()).props.data.map((item) => item.commit.hash)).toEqual([ + primaryCommitHash, + ]); + expect(componentElement(renderPanel(), "CommitDetailsPane").props.hasSelection).toBe(true); + }); + it("keeps a history search to the loaded page until the user requests older commits", () => { historyState.pages.set( undefined, @@ -602,6 +733,7 @@ describe("GitHistoryPanel filters and details", () => { }); it("lets the diff load changed files beyond the first page", () => { + const stateStore = createGitHistoryPanelStore(); const historyCommit = commit(primaryCommitHash, "Add panel"); historyState.pages.set(undefined, page([historyCommit])); historyState.commitDetails = { ...historyCommit, body: "" }; @@ -612,28 +744,41 @@ describe("GitHistoryPanel filters and details", () => { hasMore: true, capped: false, }; + renderPanel(stateStore); + flushEffects(); - const list = historyList(renderPanel()); + const list = historyList(renderPanel(stateStore)); const historyRow = renderComponent(list.props.renderItem({ item: list.props.data[0]! })); const selectCommit = visitElements( historyRow, (element) => element.props["data-commit-hash"] === historyCommit.hash, ); (selectCommit?.props.onClick as (() => void) | undefined)?.(); - renderPanel(); + renderPanel(stateStore); flushEffects(); - const detailsPane = componentElement(renderPanel(), "CommitDetailsPane"); + const detailsPane = componentElement(renderPanel(stateStore), "CommitDetailsPane"); (detailsPane.props.onShowDiff as ((hash: string, filePath?: string) => void) | undefined)?.( historyCommit.hash, ); - const diffView = componentElement(renderPanel(), "CommitDiffView"); + const diffView = componentElement(renderPanel(stateStore), "CommitDiffView"); expect(diffView.props).toMatchObject({ filesHasMore: true, filesLoading: false }); const diff = renderComponent(diffView); const loadMore = visitElements(diff, (element) => element.props.children === "Load more files"); expect(loadMore).not.toBeNull(); - expect(loadMore?.props.onClick).toBe(diffView.props.onLoadMoreFiles); + if (loadMore === null) throw new Error("Load more files action is missing."); + expect(loadMore.props.onClick).toBe(diffView.props.onLoadMoreFiles); + (loadMore.props.onClick as () => void)(); + renderPanel(stateStore); + flushEffects(); + + hooks.reset(); + componentElement(renderPanel(stateStore), "CommitDiffView"); + expect(stateStore.getState().commitFilesPagination.cursors).toEqual([ + undefined, + "files-page-2", + ]); }); it("lets the diff retry a failed changed-file continuation", () => { diff --git a/apps/web/src/components/GitHistoryPanel.test-fixture.tsx b/apps/web/src/components/GitHistoryPanel.test-fixture.tsx index 17f214c3ec2c..28e9831322f9 100644 --- a/apps/web/src/components/GitHistoryPanel.test-fixture.tsx +++ b/apps/web/src/components/GitHistoryPanel.test-fixture.tsx @@ -121,6 +121,9 @@ vi.mock("react", async (importOriginal) => { effectQueue.effects.push(effect); }, useMemo: reactHookHarness.useMemo, + useLayoutEffect: (effect: () => void) => { + effect(); + }, useRef: reactHookHarness.useRef, useState: (initialValue: Value | (() => Value)) => { const [value, setValue] = reactHookHarness.useState(initialValue); @@ -135,6 +138,11 @@ vi.mock("react", async (importOriginal) => { }; }); +vi.mock("zustand", async (importOriginal) => ({ + ...(await importOriginal()), + useStore: (store: { getState: () => State }) => store.getState(), +})); + vi.mock("react/compiler-runtime", async () => { const { reactHookHarness } = await import("../test/reactHookHarness"); return { c: reactHookHarness.useMemoCache }; @@ -399,16 +407,22 @@ function gitRef( }; } -function renderPanel(): ReactElement> { +function renderPanel( + stateStore?: import("./git-history/GitHistoryPanelState").GitHistoryPanelStore, + stateScopeKey?: string, +): ReactElement> { hooks.beginRender(); effectQueue.cursor = 0; const boundary = GitHistoryPanel({ environmentId, cwd: workspacePath, + ...(stateStore === undefined ? {} : { stateStore }), + ...(stateScopeKey === undefined ? {} : { stateScopeKey }), }) as ReactElement>; - return ( + const panel = ( boundary.type as (props: Record) => ReactElement> )(boundary.props); + return panel; } function flushEffects(): void { diff --git a/apps/web/src/components/GitHistoryPanel.tsx b/apps/web/src/components/GitHistoryPanel.tsx index 90e9128cd52d..47f83a9a5ac7 100644 --- a/apps/web/src/components/GitHistoryPanel.tsx +++ b/apps/web/src/components/GitHistoryPanel.tsx @@ -2,10 +2,12 @@ import { useAtomValue } from "@effect/atom-react"; import type { EnvironmentId, GitCommitChangedFile, GitHistoryCommit } from "@t3tools/contracts"; import { LegendList } from "@legendapp/list/react"; import { FileIcon, GitBranchIcon, RefreshCwIcon, SearchIcon, XIcon } from "lucide-react"; +import { useStore } from "zustand"; import { useCallback, useDeferredValue, useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -33,6 +35,10 @@ import { queryErrorMessage, } from "./git-history/GitHistoryCommitList"; import { PaneResizeHandle } from "./git-history/GitHistoryPaneResizeHandle"; +import { + createGitHistoryPanelStore, + type GitHistoryPanelStore, +} from "./git-history/GitHistoryPanelState"; import { GitRefsPane } from "./git-history/GitHistoryRefsPane"; import type { CommitRefKind, GitHistoryRow } from "./git-history/GitHistoryVisualTypes"; import { useGitHistoryRefs } from "./git-history/useGitHistoryRefs"; @@ -53,6 +59,8 @@ interface GitHistoryPanelProps { environmentId: EnvironmentId; cwd: string; active?: boolean; + stateStore?: GitHistoryPanelStore; + stateScopeKey?: string; } export function isWideHistoryLayout(width: number): boolean { @@ -110,6 +118,8 @@ function useHistoryPanelLayout( function GitHistoryPanelContent(props: GitHistoryPanelProps) { const panelRef = useRef(null); + const [fallbackStateStore] = useState(createGitHistoryPanelStore); + const stateStore = props.stateStore ?? fallbackStateStore; const interfaceFontSize = useClientSettings((settings) => settings.fontSizeInterface); const timestampFormat = useClientSettings((settings) => settings.timestampFormat); const rowHeight = gitHistoryRowHeight(interfaceFontSize); @@ -134,10 +144,31 @@ function GitHistoryPanelContent(props: GitHistoryPanelProps) { const vcsHistoryRevision = useAtomValue( vcsEnvironment.historyRevisionAtom({ environmentId: props.environmentId, cwd: props.cwd }), ); - const historyRefs = useGitHistoryRefs(props.environmentId, props.cwd, vcsHistoryRevision); + const stateScopeKey = `${props.stateScopeKey ?? ""}:${baseTargetKey}:${connectionGeneration}`; + const historyState = useStore(stateStore); + const stateMatchesScope = historyState.scopeKey === stateScopeKey; + useLayoutEffect(() => { + stateStore.getState().setScope(stateScopeKey); + }, [stateScopeKey, stateStore]); + const historyRefs = useGitHistoryRefs( + props.environmentId, + props.cwd, + vcsHistoryRevision, + stateScopeKey, + stateMatchesScope + ? { + selectedRevision: historyState.selectedRevision, + onSelectedRevisionChange: stateStore.getState().setSelectedRevision, + } + : undefined, + ); const { selectedRevision } = historyRefs; const refSelectionError = historyRefs.initialLocalRefError; - const targetKey = `${baseTargetKey}:${selectedRevision?.revision ?? "all"}:${vcsHistoryRevision}:${connectionGeneration}`; + const targetKey = `${stateScopeKey}:${selectedRevision?.revision ?? "all"}:${vcsHistoryRevision}`; + const stateMatchesTarget = stateMatchesScope && historyState.targetKey === targetKey; + useLayoutEffect(() => { + stateStore.getState().setTarget(targetKey); + }, [stateScopeKey, stateStore, targetKey]); const makeHistoryPageAtom = useMemo(() => { if (selectedRevision === undefined || refSelectionError !== null) return null; return (cursor: string | undefined, generation: number) => @@ -158,6 +189,8 @@ function GitHistoryPanelContent(props: GitHistoryPanelProps) { makePageAtom: makeHistoryPageAtom, getNextCursor: (page) => (page.hasMore ? page.nextCursor : null), isExpiredError: isVcsSnapshotExpiredCause, + ...(stateMatchesTarget ? { initialPagination: historyState.historyPagination } : {}), + onPaginationChange: stateStore.getState().setHistoryPagination, }); const { results, values } = pagination; const failed = pagination.failed; @@ -184,34 +217,17 @@ function GitHistoryPanelContent(props: GitHistoryPanelProps) { const historyCapped = values.some((value) => value.capped === true) && !hasMoreFromServer; const hasMore = hasMoreFromServer; const isFetchingNextPage = pagination.isFetchingNextPage; - const [filterState, setFilterState] = useState({ targetKey, value: "" }); - const filter = filterState.targetKey === targetKey ? filterState.value : ""; - if (filterState.targetKey !== targetKey) setFilterState({ targetKey, value: "" }); - const setFilter = (value: string) => setFilterState({ targetKey, value }); + const filter = stateMatchesTarget ? historyState.filter : ""; + const setFilter = stateStore.getState().setFilter; const normalizedFilter = filter.trim().toLocaleLowerCase(); const deferredFilter = useDeferredValue(normalizedFilter); const activeFilter = normalizedFilter.length === 0 ? "" : deferredFilter; const searchInputRef = useRef(null); - const [selectionState, setSelectionState] = useState<{ - readonly targetKey: string; - readonly hash: string | null; - readonly diffRequest: { readonly hash: string; readonly filePath?: string } | null; - }>({ targetKey, hash: null, diffRequest: null }); - const selection = - selectionState.targetKey === targetKey - ? selectionState - : { targetKey, hash: null, diffRequest: null }; - if (selectionState.targetKey !== targetKey) setSelectionState(selection); - const selectedHash = selection.hash; + const selectedHash = stateMatchesTarget ? historyState.selectedHash : null; const setSelectedHash = (next: SetStateAction) => { - setSelectionState((current) => { - const previous = current.targetKey === targetKey ? current.hash : null; - return { - targetKey, - hash: typeof next === "function" ? next(previous) : next, - diffRequest: null, - }; - }); + stateStore + .getState() + .setSelectedHash(typeof next === "function" ? next(historyState.selectedHash) : next); }; const [mobilePaneState, setMobilePaneState] = useState<{ readonly targetKey: string; @@ -236,13 +252,9 @@ function GitHistoryPanelContent(props: GitHistoryPanelProps) { const previousMobilePane = useRef(null); const branchesButtonRef = useRef(null); const detailsButtonRef = useRef(null); - const commitDiffRequest = selection.diffRequest; + const commitDiffRequest = stateMatchesTarget ? historyState.diffRequest : null; const setCommitDiffRequest = (request: typeof commitDiffRequest) => { - setSelectionState((current) => ({ - targetKey, - hash: current.targetKey === targetKey ? current.hash : null, - diffRequest: request, - })); + stateStore.getState().setDiffRequest(request); }; const showCommitDiff = (hash: string, filePath?: string) => { setMobilePane(null); @@ -276,14 +288,22 @@ function GitHistoryPanelContent(props: GitHistoryPanelProps) { }, }); }, [props.cwd, props.environmentId, selectedHash, vcsHistoryRevision]); + const commitFilesTargetKey = + selectedHash === null ? null : `${stateScopeKey}:${vcsHistoryRevision}:${selectedHash}`; + const stateMatchesCommitFiles = + stateMatchesTarget && historyState.commitFilesTargetKey === commitFilesTargetKey; + useLayoutEffect(() => { + stateStore.getState().setCommitFilesTarget(commitFilesTargetKey); + }, [commitFilesTargetKey, stateStore]); const commitFilesPagination = usePaginatedSnapshotPages({ - targetKey: - selectedHash === null ? null : `${baseTargetKey}:${vcsHistoryRevision}:${selectedHash}`, + targetKey: commitFilesTargetKey, label: "web:vcs-commit-files-pages", makePageAtom: makeCommitFilesPageAtom, getNextCursor: (page) => (page.hasMore ? page.nextCursor : null), maxPages: 20, isExpiredError: isVcsSnapshotExpiredCause, + ...(stateMatchesCommitFiles ? { initialPagination: historyState.commitFilesPagination } : {}), + onPaginationChange: stateStore.getState().setCommitFilesPagination, }); const selectedCommitFiles = useMemo(() => { const files = new Map(); @@ -441,10 +461,10 @@ function GitHistoryPanelContent(props: GitHistoryPanelProps) { className="flex shrink-0 items-center gap-2 border-b border-border/70 px-3 py-2" inert={mobilePane !== null ? true : undefined} > -
+
@@ -465,6 +485,7 @@ function GitHistoryPanelContent(props: GitHistoryPanelProps) { onClick={() => openMobilePane("refs")} aria-controls="git-history-refs-panel" aria-expanded={mobilePane === "refs"} + className="shrink-0" > Branches @@ -476,6 +497,7 @@ function GitHistoryPanelContent(props: GitHistoryPanelProps) { disabled={selectedHash === null} aria-controls="git-history-details-panel" aria-expanded={mobilePane === "details"} + className="shrink-0" > Details @@ -487,6 +509,7 @@ function GitHistoryPanelContent(props: GitHistoryPanelProps) { onClick={refresh} disabled={isPending} aria-label="Refresh Git history" + className="shrink-0" > diff --git a/apps/web/src/components/git-history/GitHistoryCommitDiff.tsx b/apps/web/src/components/git-history/GitHistoryCommitDiff.tsx index db7628cecea2..4b75d7cc44f2 100644 --- a/apps/web/src/components/git-history/GitHistoryCommitDiff.tsx +++ b/apps/web/src/components/git-history/GitHistoryCommitDiff.tsx @@ -41,15 +41,15 @@ export function CommitDiffView(props: { ); return (
-
- - - + + {props.filePath ?? `Commit ${props.hash.slice(0, 8)}`} -