diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 2838edcad7..5e4416d8b9 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -386,6 +386,36 @@ it.layer(TestLayer)("git integration", (it) => { }), ); + it.effect("paginates branch results and returns paging metadata", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(tmp); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-a" }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-b" }); + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature-c" }); + + const firstPage = yield* (yield* GitCore).listBranches({ cwd: tmp, limit: 2 }); + expect(firstPage.totalCount).toBe(4); + expect(firstPage.nextCursor).toBe(2); + expect(firstPage.branches.map((branch) => branch.name)).toEqual([ + initialBranch, + "feature-a", + ]); + + const secondPage = yield* (yield* GitCore).listBranches({ + cwd: tmp, + cursor: firstPage.nextCursor ?? 0, + limit: 2, + }); + expect(secondPage.totalCount).toBe(4); + expect(secondPage.nextCursor).toBeNull(); + expect(secondPage.branches.map((branch) => branch.name)).toEqual([ + "feature-b", + "feature-c", + ]); + }), + ); + it.effect("parses separate branch names when column.ui is always enabled", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); @@ -531,6 +561,41 @@ it.layer(TestLayer)("git integration", (it) => { expect(remoteBranch?.remoteName).toBe(remoteName); }), ); + + it.effect( + "filters branch queries before pagination and dedupes origin refs with local matches", + () => + Effect.gen(function* () { + const remote = yield* makeTmpDir(); + const tmp = yield* makeTmpDir(); + + yield* git(remote, ["init", "--bare"]); + const { initialBranch } = yield* initRepoWithCommit(tmp); + yield* git(tmp, ["remote", "add", "origin", remote]); + yield* git(tmp, ["push", "-u", "origin", initialBranch]); + + yield* (yield* GitCore).createBranch({ cwd: tmp, branch: "feature/demo" }); + yield* git(tmp, ["push", "-u", "origin", "feature/demo"]); + + yield* git(tmp, ["checkout", "-b", "feature/remote-only"]); + yield* git(tmp, ["push", "-u", "origin", "feature/remote-only"]); + yield* git(tmp, ["checkout", initialBranch]); + yield* git(tmp, ["branch", "-D", "feature/remote-only"]); + + const result = yield* (yield* GitCore).listBranches({ + cwd: tmp, + query: "feature/", + limit: 10, + }); + + expect(result.totalCount).toBe(2); + expect(result.nextCursor).toBeNull(); + expect(result.branches.map((branch) => branch.name)).toEqual([ + "feature/demo", + "origin/feature/remote-only", + ]); + }), + ); }); // ── checkoutGitBranch ── @@ -737,6 +802,9 @@ it.layer(TestLayer)("git integration", (it) => { ) { return ok(); } + if (input.operation === "GitCore.statusDetails.defaultRef") { + return ok("refs/remotes/origin/main\n"); + } return Effect.fail( new GitCommandError({ operation: input.operation, @@ -800,6 +868,9 @@ it.layer(TestLayer)("git integration", (it) => { ) { return ok(); } + if (input.operation === "GitCore.statusDetails.defaultRef") { + return ok("refs/remotes/origin/main\n"); + } return Effect.fail( new GitCommandError({ operation: input.operation, diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index 0a11abab5b..98b1400aba 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -18,7 +18,8 @@ import { } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { GitCommandError } from "@t3tools/contracts"; +import { GitCommandError, type GitBranch } from "@t3tools/contracts"; +import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; import { GitCore, type ExecuteGitProgress, @@ -49,6 +50,7 @@ const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5); const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048; const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const; +const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100; type TraceTailState = { processedChars: number; @@ -183,6 +185,40 @@ function parseBranchLine(line: string): { name: string; current: boolean } | nul }; } +function filterBranchesForListQuery( + branches: ReadonlyArray, + query?: string, +): ReadonlyArray { + if (!query) { + return branches; + } + + const normalizedQuery = query.toLowerCase(); + return branches.filter((branch) => branch.name.toLowerCase().includes(normalizedQuery)); +} + +function paginateBranches(input: { + branches: ReadonlyArray; + cursor?: number | undefined; + limit?: number | undefined; +}): { + branches: ReadonlyArray; + nextCursor: number | null; + totalCount: number; +} { + const cursor = input.cursor ?? 0; + const limit = input.limit ?? GIT_LIST_BRANCHES_DEFAULT_LIMIT; + const totalCount = input.branches.length; + const branches = input.branches.slice(cursor, cursor + limit); + const nextCursor = cursor + branches.length < totalCount ? cursor + branches.length : null; + + return { + branches, + nextCursor, + totalCount, + }; +} + function sanitizeRemoteName(value: string): string { const sanitized = value .trim() @@ -1101,15 +1137,52 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { const statusDetails: GitCoreShape["statusDetails"] = Effect.fn("statusDetails")(function* (cwd) { yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.ignoreCause({ log: true })); - const [statusStdout, unstagedNumstatStdout, stagedNumstatStdout] = yield* Effect.all( - [ - runGitStdout("GitCore.statusDetails.status", cwd, ["status", "--porcelain=2", "--branch"]), - runGitStdout("GitCore.statusDetails.unstagedNumstat", cwd, ["diff", "--numstat"]), - runGitStdout("GitCore.statusDetails.stagedNumstat", cwd, ["diff", "--cached", "--numstat"]), - ], - { concurrency: "unbounded" }, + const statusResult = yield* executeGit( + "GitCore.statusDetails.status", + cwd, + ["status", "--porcelain=2", "--branch"], + { + allowNonZeroExit: true, + }, ); + if (statusResult.code !== 0) { + const stderr = statusResult.stderr.trim(); + return yield* createGitCommandError( + "GitCore.statusDetails.status", + cwd, + ["status", "--porcelain=2", "--branch"], + stderr || "git status failed", + ); + } + + const [unstagedNumstatStdout, stagedNumstatStdout, defaultRefResult, hasOriginRemote] = + yield* Effect.all( + [ + runGitStdout("GitCore.statusDetails.unstagedNumstat", cwd, ["diff", "--numstat"]), + runGitStdout("GitCore.statusDetails.stagedNumstat", cwd, [ + "diff", + "--cached", + "--numstat", + ]), + executeGit( + "GitCore.statusDetails.defaultRef", + cwd, + ["symbolic-ref", "refs/remotes/origin/HEAD"], + { + allowNonZeroExit: true, + }, + ), + originRemoteExists(cwd).pipe(Effect.catch(() => Effect.succeed(false))), + ], + { concurrency: "unbounded" }, + ); + const statusStdout = statusResult.stdout; + const defaultBranch = + defaultRefResult.code === 0 + ? defaultRefResult.stdout.trim().replace(/^refs\/remotes\/origin\//, "") + : null; + let branch: string | null = null; let upstreamRef: string | null = null; let aheadCount = 0; @@ -1176,6 +1249,12 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { files.sort((a, b) => a.path.localeCompare(b.path)); return { + isRepo: true, + hasOriginRemote, + isDefaultBranch: + branch !== null && + (branch === defaultBranch || + (defaultBranch === null && (branch === "main" || branch === "master"))), branch, upstreamRef, hasWorkingTreeChanges, @@ -1193,6 +1272,9 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { const status: GitCoreShape["status"] = (input) => statusDetails(input.cwd).pipe( Effect.map((details) => ({ + isRepo: details.isRepo, + hasOriginRemote: details.hasOriginRemote, + isDefaultBranch: details.isDefaultBranch, branch: details.branch, hasWorkingTreeChanges: details.hasWorkingTreeChanges, workingTree: details.workingTree, @@ -1571,7 +1653,13 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { if (localBranchResult.code !== 0) { const stderr = localBranchResult.stderr.trim(); if (stderr.toLowerCase().includes("not a git repository")) { - return { branches: [], isRepo: false, hasOriginRemote: false }; + return { + branches: [], + isRepo: false, + hasOriginRemote: false, + nextCursor: null, + totalCount: 0, + }; } return yield* createGitCommandError( "GitCore.listBranches", @@ -1735,9 +1823,22 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { }) : []; - const branches = [...localBranches, ...remoteBranches]; + const branches = paginateBranches({ + branches: filterBranchesForListQuery( + dedupeRemoteBranchesWithLocalMatches([...localBranches, ...remoteBranches]), + input.query, + ), + cursor: input.cursor, + limit: input.limit, + }); - return { branches, isRepo: true, hasOriginRemote: remoteNames.includes("origin") }; + return { + branches: [...branches.branches], + isRepo: true, + hasOriginRemote: remoteNames.includes("origin"), + nextCursor: branches.nextCursor, + totalCount: branches.totalCount, + }; }); const createWorktree: GitCoreShape["createWorktree"] = Effect.fn("createWorktree")( diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index 6fd55030fd..9ee2ed97ad 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -660,6 +660,9 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }); const status = yield* manager.status({ cwd: repoDir }); + expect(status.isRepo).toBe(true); + expect(status.hasOriginRemote).toBe(true); + expect(status.isDefaultBranch).toBe(false); expect(status.branch).toBe("feature/status-open-pr"); expect(status.pr).toEqual({ number: 13, @@ -672,6 +675,32 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("status returns an explicit non-repo result for non-git directories", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir("t3code-git-manager-non-repo-"); + const { manager } = yield* makeManager(); + + const status = yield* manager.status({ cwd }); + + expect(status).toEqual({ + isRepo: false, + hasOriginRemote: false, + isDefaultBranch: false, + branch: null, + hasWorkingTreeChanges: false, + workingTree: { + files: [], + insertions: 0, + deletions: 0, + }, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + pr: null, + }); + }), + ); + it.effect("status briefly caches repeated lookups for the same cwd", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index ca9d562c03..363e6f7de9 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -5,6 +5,7 @@ import { Cache, Duration, Effect, Exit, FileSystem, Layer, Option, Path, Ref } f import { GitActionProgressEvent, GitActionProgressPhase, + GitCommandError, GitRunStackedActionResult, GitStackedAction, ModelSelection, @@ -22,7 +23,7 @@ import { type GitManagerShape, type GitRunStackedActionOptions, } from "../Services/GitManager.ts"; -import { GitCore } from "../Services/GitCore.ts"; +import { GitCore, GitStatusDetails } from "../Services/GitCore.ts"; import { GitHubCli, type GitHubPullRequestSummary } from "../Services/GitHubCli.ts"; import { TextGeneration } from "../Services/TextGeneration.ts"; import { extractBranchNameFromRemoteRef } from "../remoteRefs.ts"; @@ -38,6 +39,10 @@ const STATUS_RESULT_CACHE_CAPACITY = 2_048; type StripProgressContext = T extends any ? Omit : never; type GitActionProgressPayload = StripProgressContext; +function isNotGitRepositoryError(error: GitCommandError): boolean { + return error.message.toLowerCase().includes("not a git repository"); +} + interface OpenPrInfo { number: number; title: string; @@ -688,10 +693,25 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp"; const normalizeStatusCacheKey = (cwd: string) => canonicalizeExistingPath(cwd); const readStatus = Effect.fn("readStatus")(function* (cwd: string) { - const details = yield* gitCore.statusDetails(cwd); + const details = yield* gitCore.statusDetails(cwd).pipe( + Effect.catchIf(isNotGitRepositoryError, () => + Effect.succeed({ + isRepo: false, + hasOriginRemote: false, + isDefaultBranch: false, + branch: null, + upstreamRef: null, + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + } satisfies GitStatusDetails), + ), + ); const pr = - details.branch !== null + details.isRepo && details.branch !== null ? yield* findLatestPr(cwd, { branch: details.branch, upstreamRef: details.upstreamRef, @@ -702,6 +722,9 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { : null; return { + isRepo: details.isRepo, + hasOriginRemote: details.hasOriginRemote, + isDefaultBranch: details.isDefaultBranch, branch: details.branch, hasWorkingTreeChanges: details.hasWorkingTreeChanges, workingTree: details.workingTree, @@ -908,12 +931,6 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { return parsed[0] ?? null; }); - const isDefaultBranch = Effect.fn("isDefaultBranch")(function* (cwd: string, branch: string) { - const branches = yield* gitCore.listBranches({ cwd }); - const currentBranch = branches.branches.find((candidate) => candidate.name === branch); - return currentBranch?.isDefault ?? (branch === "main" || branch === "master"); - }); - const buildCompletionToast = Effect.fn("buildCompletionToast")(function* ( cwd: string, result: Pick, @@ -935,11 +952,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { upstreamRef: finalStatus.upstreamRef, hasUpstream: finalStatus.hasUpstream, }; - currentBranchIsDefault = yield* isDefaultBranch(cwd, finalStatus.branch).pipe( - Effect.catch(() => - Effect.succeed(finalStatus.branch === "main" || finalStatus.branch === "master"), - ), - ); + currentBranchIsDefault = finalStatus.isDefaultBranch; } } diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 53829f37b8..7bf7585d11 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -822,6 +822,9 @@ it.layer(NodeServices.layer)("server router seam", (it) => { gitManager: { status: () => Effect.succeed({ + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: true, branch: "main", hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, @@ -922,6 +925,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ], isRepo: true, hasOriginRemote: true, + nextCursor: null, + totalCount: 1, }), createWorktree: () => Effect.succeed({ diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 99928441c7..c9e336bf48 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -1,5 +1,9 @@ import type { GitBranch } from "@t3tools/contracts"; import { Schema } from "effect"; +export { + dedupeRemoteBranchesWithLocalMatches, + deriveLocalBranchNameFromRemoteRef, +} from "@t3tools/shared/git"; export const EnvMode = Schema.Literals(["local", "worktree"]); export type EnvMode = typeof EnvMode.Type; @@ -43,58 +47,6 @@ export function resolveBranchToolbarValue(input: { return currentGitBranch ?? activeThreadBranch; } -export function deriveLocalBranchNameFromRemoteRef(branchName: string): string { - const firstSeparatorIndex = branchName.indexOf("/"); - if (firstSeparatorIndex <= 0 || firstSeparatorIndex === branchName.length - 1) { - return branchName; - } - return branchName.slice(firstSeparatorIndex + 1); -} - -function deriveLocalBranchNameCandidatesFromRemoteRef( - branchName: string, - remoteName?: string, -): ReadonlyArray { - const candidates = new Set(); - const firstSlashCandidate = deriveLocalBranchNameFromRemoteRef(branchName); - if (firstSlashCandidate.length > 0) { - candidates.add(firstSlashCandidate); - } - - if (remoteName) { - const remotePrefix = `${remoteName}/`; - if (branchName.startsWith(remotePrefix) && branchName.length > remotePrefix.length) { - candidates.add(branchName.slice(remotePrefix.length)); - } - } - - return [...candidates]; -} - -export function dedupeRemoteBranchesWithLocalMatches( - branches: ReadonlyArray, -): ReadonlyArray { - const localBranchNames = new Set( - branches.filter((branch) => !branch.isRemote).map((branch) => branch.name), - ); - - return branches.filter((branch) => { - if (!branch.isRemote) { - return true; - } - - if (branch.remoteName !== "origin") { - return true; - } - - const localBranchCandidates = deriveLocalBranchNameCandidatesFromRemoteRef( - branch.name, - branch.remoteName, - ); - return !localBranchCandidates.some((candidate) => localBranchNames.has(candidate)); - }); -} - export function resolveBranchSelectionTarget(input: { activeProjectCwd: string; activeWorktreePath: string | null; diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index c1a43adc90..e1dbb8756c 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -1,5 +1,5 @@ import type { GitBranch } from "@t3tools/contracts"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query"; import { useVirtualizer } from "@tanstack/react-virtual"; import { ChevronDownIcon } from "lucide-react"; import { @@ -15,7 +15,7 @@ import { } from "react"; import { - gitBranchesQueryOptions, + gitBranchSearchInfiniteQueryOptions, gitQueryKeys, gitStatusQueryOptions, invalidateGitQueries, @@ -23,7 +23,6 @@ import { import { readNativeApi } from "../nativeApi"; import { parsePullRequestReference } from "../pullRequestReference"; import { - dedupeRemoteBranchesWithLocalMatches, deriveLocalBranchNameFromRemoteRef, EnvMode, resolveBranchSelectionTarget, @@ -38,6 +37,7 @@ import { ComboboxItem, ComboboxList, ComboboxPopup, + ComboboxStatus, ComboboxTrigger, } from "./ui/combobox"; import { toastManager } from "./ui/toast"; @@ -89,11 +89,33 @@ export function BranchToolbarBranchSelector({ const [branchQuery, setBranchQuery] = useState(""); const deferredBranchQuery = useDeferredValue(branchQuery); - const branchesQuery = useQuery(gitBranchesQueryOptions(branchCwd)); const branchStatusQuery = useQuery(gitStatusQueryOptions(branchCwd)); + const trimmedBranchQuery = branchQuery.trim(); + const deferredTrimmedBranchQuery = deferredBranchQuery.trim(); + + useEffect(() => { + if (!branchCwd) return; + void queryClient.prefetchInfiniteQuery( + gitBranchSearchInfiniteQueryOptions({ cwd: branchCwd, query: "" }), + ); + }, [branchCwd, queryClient]); + + const { + data: branchesSearchData, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isPending: isBranchesSearchPending, + } = useInfiniteQuery( + gitBranchSearchInfiniteQueryOptions({ + cwd: branchCwd, + query: deferredTrimmedBranchQuery, + enabled: isBranchMenuOpen, + }), + ); const branches = useMemo( - () => dedupeRemoteBranchesWithLocalMatches(branchesQuery.data?.branches ?? []), - [branchesQuery.data?.branches], + () => branchesSearchData?.pages.flatMap((page) => page.branches) ?? [], + [branchesSearchData?.pages], ); const currentGitBranch = branchStatusQuery.data?.branch ?? branches.find((branch) => branch.current)?.name ?? null; @@ -108,8 +130,6 @@ export function BranchToolbarBranchSelector({ () => new Map(branches.map((branch) => [branch.name, branch] as const)), [branches], ); - const trimmedBranchQuery = branchQuery.trim(); - const deferredTrimmedBranchQuery = deferredBranchQuery.trim(); const normalizedDeferredBranchQuery = deferredTrimmedBranchQuery.toLowerCase(); const prReference = parsePullRequestReference(trimmedBranchQuery); const isSelectingWorktreeBase = @@ -156,6 +176,14 @@ export function BranchToolbarBranchSelector({ ); const [isBranchActionPending, startBranchActionTransition] = useTransition(); const shouldVirtualizeBranchList = filteredBranchPickerItems.length > 40; + const totalBranchCount = branchesSearchData?.pages[0]?.totalCount ?? 0; + const branchStatusText = isBranchesSearchPending + ? "Loading branches..." + : isFetchingNextPage + ? "Loading more branches..." + : hasNextPage + ? `Showing ${branches.length} of ${totalBranchCount} branches` + : null; const runBranchAction = (action: () => Promise) => { startBranchActionTransition(async () => { @@ -213,7 +241,7 @@ export function BranchToolbarBranchSelector({ let nextBranchName = selectedBranchName; if (branch.isRemote) { - const status = await api.git.status({ cwd: branchCwd }).catch(() => null); + const status = await api.git.status({ cwd: selectionTarget.checkoutCwd }).catch(() => null); if (status?.branch) { nextBranchName = status.branch; } @@ -295,6 +323,24 @@ export function BranchToolbarBranchSelector({ ); const branchListScrollElementRef = useRef(null); + const maybeFetchNextBranchPage = useCallback(() => { + if (!isBranchMenuOpen || !hasNextPage || isFetchingNextPage) { + return; + } + + const scrollElement = branchListScrollElementRef.current; + if (!scrollElement) { + return; + } + + const distanceFromBottom = + scrollElement.scrollHeight - scrollElement.scrollTop - scrollElement.clientHeight; + if (distanceFromBottom > 96) { + return; + } + + void fetchNextPage().catch(() => undefined); + }, [fetchNextPage, hasNextPage, isBranchMenuOpen, isFetchingNextPage]); const branchListVirtualizer = useVirtualizer({ count: filteredBranchPickerItems.length, estimateSize: (index) => @@ -331,6 +377,35 @@ export function BranchToolbarBranchSelector({ shouldVirtualizeBranchList, ]); + useEffect(() => { + if (!isBranchMenuOpen) { + return; + } + + branchListScrollElementRef.current?.scrollTo({ top: 0 }); + }, [deferredTrimmedBranchQuery, isBranchMenuOpen]); + + useEffect(() => { + const scrollElement = branchListScrollElementRef.current; + if (!scrollElement || !isBranchMenuOpen) { + return; + } + + const handleScroll = () => { + maybeFetchNextBranchPage(); + }; + + scrollElement.addEventListener("scroll", handleScroll, { passive: true }); + handleScroll(); + return () => { + scrollElement.removeEventListener("scroll", handleScroll); + }; + }, [isBranchMenuOpen, maybeFetchNextBranchPage]); + + useEffect(() => { + maybeFetchNextBranchPage(); + }, [branches.length, maybeFetchNextBranchPage]); + const triggerLabel = getBranchTriggerLabel({ activeWorktreePath, effectiveEnvMode, @@ -425,7 +500,7 @@ export function BranchToolbarBranchSelector({ } className="text-muted-foreground/70 hover:text-foreground/80" - disabled={(branchesQuery.isLoading && branches.length === 0) || isBranchActionPending} + disabled={(isBranchesSearchPending && branches.length === 0) || isBranchActionPending} > {triggerLabel} @@ -468,6 +543,7 @@ export function BranchToolbarBranchSelector({ filteredBranchPickerItems.map((itemValue, index) => renderPickerItem(itemValue, index)) )} + {branchStatusText ? {branchStatusText} : null} ); diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 0e5f573d54..b0263cbf40 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -632,6 +632,8 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { return { isRepo: true, hasOriginRemote: true, + nextCursor: null, + totalCount: 1, branches: [ { name: "main", @@ -644,6 +646,9 @@ function resolveWsRpc(body: NormalizedWsRpcRequestBody): unknown { } if (tag === WS_METHODS.gitStatus) { return { + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: true, branch: "main", hasWorkingTreeChanges: false, workingTree: { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 76133712d4..07561e8e64 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -26,7 +26,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; -import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; +import { gitCreateWorktreeMutationOptions, gitStatusQueryOptions } from "~/lib/gitReactQuery"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; import { isElectron } from "../env"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; @@ -1201,7 +1201,7 @@ export default function ChatView({ threadId }: ChatViewProps) { (debouncerState) => ({ isPending: debouncerState.isPending }), ); const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; - const branchesQuery = useQuery(gitBranchesQueryOptions(gitCwd)); + const gitStatusQuery = useQuery(gitStatusQueryOptions(gitCwd)); const keybindings = useServerKeybindings(); const availableEditors = useServerAvailableEditors(); const modelOptionsByProvider = useMemo( @@ -1338,7 +1338,7 @@ export default function ChatView({ threadId }: ChatViewProps) { }); }, [activeProjectCwd, activeThreadWorktreePath]); // Default true while loading to avoid toolbar flicker. - const isGitRepo = branchesQuery.data?.isRepo ?? true; + const isGitRepo = gitStatusQuery.data?.isRepo ?? true; const terminalShortcutLabelOptions = useMemo( () => ({ context: { diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index fadb8cb69d..dc376a5b3d 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -19,7 +19,7 @@ import { useState, } from "react"; import { openInPreferredEditor } from "../editorPreferences"; -import { gitBranchesQueryOptions } from "~/lib/gitReactQuery"; +import { gitStatusQueryOptions } from "~/lib/gitReactQuery"; import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; import { cn } from "~/lib/utils"; import { readNativeApi } from "../nativeApi"; @@ -189,8 +189,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { activeProjectId ? store.projects.find((project) => project.id === activeProjectId) : undefined, ); const activeCwd = activeThread?.worktreePath ?? activeProject?.cwd; - const gitBranchesQuery = useQuery(gitBranchesQueryOptions(activeCwd ?? null)); - const isGitRepo = gitBranchesQuery.data?.isRepo ?? true; + const gitStatusQuery = useQuery(gitStatusQueryOptions(activeCwd ?? null)); + const isGitRepo = gitStatusQuery.data?.isRepo ?? true; const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = useTurnDiffSummaries(activeThread); const orderedTurnDiffSummaries = useMemo( diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index f21c924c79..6ed3033085 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -13,6 +13,9 @@ import { function status(overrides: Partial = {}): GitStatusResult { return { + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: false, branch: "feature/test", hasWorkingTreeChanges: false, workingTree: { diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 6e811e6f4b..e56f77a60b 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -41,14 +41,12 @@ import { Textarea } from "~/components/ui/textarea"; import { toastManager } from "~/components/ui/toast"; import { openInPreferredEditor } from "~/editorPreferences"; import { - gitBranchesQueryOptions, gitInitMutationOptions, gitMutationKeys, gitPullMutationOptions, gitRunStackedActionMutationOptions, gitStatusQueryOptions, invalidateGitStatusQuery, - invalidateGitQueries, } from "~/lib/gitReactQuery"; import { newCommandId, randomUUID } from "~/lib/utils"; import { resolvePathLinkTarget } from "~/terminal-links"; @@ -276,21 +274,10 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions ); const { data: gitStatus = null, error: gitStatusError } = useQuery(gitStatusQueryOptions(gitCwd)); - - const { data: branchList = null } = useQuery(gitBranchesQueryOptions(gitCwd)); // Default to true while loading so we don't flash init controls. - const isRepo = branchList?.isRepo ?? true; - const hasOriginRemote = branchList?.hasOriginRemote ?? false; - const currentBranch = branchList?.branches.find((branch) => branch.current)?.name ?? null; - const isGitStatusOutOfSync = - !!gitStatus?.branch && !!currentBranch && gitStatus.branch !== currentBranch; - - useEffect(() => { - if (!isGitStatusOutOfSync) return; - void invalidateGitQueries(queryClient, { cwd: gitCwd }); - }, [gitCwd, isGitStatusOutOfSync, queryClient]); - - const gitStatusForActions = isGitStatusOutOfSync ? null : gitStatus; + const isRepo = gitStatus?.isRepo ?? true; + const hasOriginRemote = gitStatus?.hasOriginRemote ?? false; + const gitStatusForActions = gitStatus; const allFiles = gitStatusForActions?.workingTree.files ?? []; const selectedFiles = allFiles.filter((f) => !excludedFiles.has(f.path)); @@ -334,11 +321,8 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions ]); const isDefaultBranch = useMemo(() => { - const branchName = gitStatusForActions?.branch; - if (!branchName) return false; - const current = branchList?.branches.find((branch) => branch.name === branchName); - return current?.isDefault ?? (branchName === "main" || branchName === "master"); - }, [branchList?.branches, gitStatusForActions?.branch]); + return gitStatusForActions?.isDefaultBranch ?? false; + }, [gitStatusForActions?.isDefaultBranch]); const gitActionMenuItems = useMemo( () => buildMenuItems(gitStatusForActions, isGitActionRunning, hasOriginRemote), @@ -879,11 +863,6 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions Behind upstream. Pull/rebase first.

)} - {isGitStatusOutOfSync && ( -

- Refreshing git status... -

- )} {gitStatusError && (

{gitStatusError.message}

)} diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 9581e72cd2..b8398e7b8f 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -157,11 +157,16 @@ function resolveWsRpc(tag: string): unknown { return { isRepo: true, hasOriginRemote: true, + nextCursor: null, + totalCount: 1, branches: [{ name: "main", current: true, isDefault: true, worktreePath: null }], }; } if (tag === WS_METHODS.gitStatus) { return { + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: true, branch: "main", hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, diff --git a/apps/web/src/lib/gitReactQuery.test.ts b/apps/web/src/lib/gitReactQuery.test.ts index be644d8ff6..d260c2aee8 100644 --- a/apps/web/src/lib/gitReactQuery.test.ts +++ b/apps/web/src/lib/gitReactQuery.test.ts @@ -9,8 +9,11 @@ vi.mock("../wsRpcClient", () => ({ getWsRpcClient: vi.fn(), })); +import type { InfiniteData } from "@tanstack/react-query"; +import type { GitListBranchesResult } from "@t3tools/contracts"; + import { - gitBranchesQueryOptions, + gitBranchSearchInfiniteQueryOptions, gitMutationKeys, gitQueryKeys, gitPreparePullRequestThreadMutationOptions, @@ -21,6 +24,19 @@ import { invalidateGitQueries, } from "./gitReactQuery"; +const BRANCH_QUERY_RESULT: GitListBranchesResult = { + branches: [], + isRepo: true, + hasOriginRemote: true, + nextCursor: null, + totalCount: 0, +}; + +const BRANCH_SEARCH_RESULT: InfiniteData = { + pages: [BRANCH_QUERY_RESULT], + pageParams: [0], +}; + describe("gitMutationKeys", () => { it("scopes stacked action keys by cwd", () => { expect(gitMutationKeys.runStackedAction("/repo/a")).not.toEqual( @@ -69,9 +85,21 @@ describe("invalidateGitQueries", () => { const queryClient = new QueryClient(); queryClient.setQueryData(gitQueryKeys.status("/repo/a"), { ok: "a" }); - queryClient.setQueryData(gitQueryKeys.branches("/repo/a"), { ok: "a-branches" }); + queryClient.setQueryData( + gitBranchSearchInfiniteQueryOptions({ + cwd: "/repo/a", + query: "feature", + }).queryKey, + BRANCH_SEARCH_RESULT, + ); queryClient.setQueryData(gitQueryKeys.status("/repo/b"), { ok: "b" }); - queryClient.setQueryData(gitQueryKeys.branches("/repo/b"), { ok: "b-branches" }); + queryClient.setQueryData( + gitBranchSearchInfiniteQueryOptions({ + cwd: "/repo/b", + query: "feature", + }).queryKey, + BRANCH_SEARCH_RESULT, + ); await invalidateGitQueries(queryClient, { cwd: "/repo/a" }); @@ -79,13 +107,23 @@ describe("invalidateGitQueries", () => { queryClient.getQueryState(gitStatusQueryOptions("/repo/a").queryKey)?.isInvalidated, ).toBe(true); expect( - queryClient.getQueryState(gitBranchesQueryOptions("/repo/a").queryKey)?.isInvalidated, + queryClient.getQueryState( + gitBranchSearchInfiniteQueryOptions({ + cwd: "/repo/a", + query: "feature", + }).queryKey, + )?.isInvalidated, ).toBe(true); expect( queryClient.getQueryState(gitStatusQueryOptions("/repo/b").queryKey)?.isInvalidated, ).toBe(false); expect( - queryClient.getQueryState(gitBranchesQueryOptions("/repo/b").queryKey)?.isInvalidated, + queryClient.getQueryState( + gitBranchSearchInfiniteQueryOptions({ + cwd: "/repo/b", + query: "feature", + }).queryKey, + )?.isInvalidated, ).toBe(false); }); }); @@ -95,7 +133,6 @@ describe("invalidateGitStatusQuery", () => { const queryClient = new QueryClient(); queryClient.setQueryData(gitQueryKeys.status("/repo/a"), { ok: "a" }); - queryClient.setQueryData(gitQueryKeys.branches("/repo/a"), { ok: "a-branches" }); queryClient.setQueryData(gitQueryKeys.status("/repo/b"), { ok: "b" }); await invalidateGitStatusQuery(queryClient, "/repo/a"); @@ -103,9 +140,6 @@ describe("invalidateGitStatusQuery", () => { expect( queryClient.getQueryState(gitStatusQueryOptions("/repo/a").queryKey)?.isInvalidated, ).toBe(true); - expect( - queryClient.getQueryState(gitBranchesQueryOptions("/repo/a").queryKey)?.isInvalidated, - ).toBe(false); expect( queryClient.getQueryState(gitStatusQueryOptions("/repo/b").queryKey)?.isInvalidated, ).toBe(false); diff --git a/apps/web/src/lib/gitReactQuery.ts b/apps/web/src/lib/gitReactQuery.ts index 25411db7bd..53f04848c7 100644 --- a/apps/web/src/lib/gitReactQuery.ts +++ b/apps/web/src/lib/gitReactQuery.ts @@ -1,5 +1,10 @@ import { type GitActionProgressEvent, type GitStackedAction } from "@t3tools/contracts"; -import { mutationOptions, queryOptions, type QueryClient } from "@tanstack/react-query"; +import { + infiniteQueryOptions, + mutationOptions, + queryOptions, + type QueryClient, +} from "@tanstack/react-query"; import { ensureNativeApi } from "../nativeApi"; import { getWsRpcClient } from "../wsRpcClient"; @@ -7,11 +12,14 @@ const GIT_STATUS_STALE_TIME_MS = 5_000; const GIT_STATUS_REFETCH_INTERVAL_MS = 15_000; const GIT_BRANCHES_STALE_TIME_MS = 15_000; const GIT_BRANCHES_REFETCH_INTERVAL_MS = 60_000; +const GIT_BRANCHES_PAGE_SIZE = 100; export const gitQueryKeys = { all: ["git"] as const, status: (cwd: string | null) => ["git", "status", cwd] as const, branches: (cwd: string | null) => ["git", "branches", cwd] as const, + branchSearch: (cwd: string | null, query: string) => + ["git", "branches", cwd, "search", query] as const, }; export const gitMutationKeys = { @@ -59,15 +67,28 @@ export function gitStatusQueryOptions(cwd: string | null) { }); } -export function gitBranchesQueryOptions(cwd: string | null) { - return queryOptions({ - queryKey: gitQueryKeys.branches(cwd), - queryFn: async () => { +export function gitBranchSearchInfiniteQueryOptions(input: { + cwd: string | null; + query: string; + enabled?: boolean; +}) { + const normalizedQuery = input.query.trim(); + + return infiniteQueryOptions({ + queryKey: gitQueryKeys.branchSearch(input.cwd, normalizedQuery), + initialPageParam: 0, + queryFn: async ({ pageParam }) => { const api = ensureNativeApi(); - if (!cwd) throw new Error("Git branches are unavailable."); - return api.git.listBranches({ cwd }); + if (!input.cwd) throw new Error("Git branches are unavailable."); + return api.git.listBranches({ + cwd: input.cwd, + ...(normalizedQuery.length > 0 ? { query: normalizedQuery } : {}), + cursor: pageParam, + limit: GIT_BRANCHES_PAGE_SIZE, + }); }, - enabled: cwd !== null, + getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, + enabled: input.cwd !== null && (input.enabled ?? true), staleTime: GIT_BRANCHES_STALE_TIME_MS, refetchOnWindowFocus: true, refetchOnReconnect: true, diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 03fc050d4a..525ae7ee0d 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -2,6 +2,7 @@ import { Schema } from "effect"; import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas"; const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; +const GIT_LIST_BRANCHES_MAX_LIMIT = 200; // Domain Types @@ -120,6 +121,11 @@ export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type; export const GitListBranchesInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, + query: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(256))), + cursor: Schema.optional(NonNegativeInt), + limit: Schema.optional( + PositiveInt.check(Schema.isLessThanOrEqualTo(GIT_LIST_BRANCHES_MAX_LIMIT)), + ), }); export type GitListBranchesInput = typeof GitListBranchesInput.Type; @@ -180,7 +186,10 @@ const GitStatusPr = Schema.Struct({ }); export const GitStatusResult = Schema.Struct({ - branch: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr), + isRepo: Schema.Boolean, + hasOriginRemote: Schema.Boolean, + isDefaultBranch: Schema.Boolean, + branch: Schema.NullOr(TrimmedNonEmptyStringSchema), hasWorkingTreeChanges: Schema.Boolean, workingTree: Schema.Struct({ files: Schema.Array( @@ -204,6 +213,8 @@ export const GitListBranchesResult = Schema.Struct({ branches: Schema.Array(GitBranch), isRepo: Schema.Boolean, hasOriginRemote: Schema.Boolean, + nextCursor: NonNegativeInt.pipe(Schema.NullOr), + totalCount: NonNegativeInt, }); export type GitListBranchesResult = typeof GitListBranchesResult.Type; diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index bbd290393f..90bc655a76 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -1,3 +1,5 @@ +import type { GitBranch } from "@t3tools/contracts"; + /** * Sanitize an arbitrary string into a valid, lowercase git branch fragment. * Strips quotes, collapses separators, limits to 64 chars. @@ -59,3 +61,61 @@ export function resolveAutoFeatureBranchName( return `${resolvedBase}-${suffix}`; } + +/** + * Strip the remote prefix from a remote ref such as `origin/feature/demo`. + */ +export function deriveLocalBranchNameFromRemoteRef(branchName: string): string { + const firstSeparatorIndex = branchName.indexOf("/"); + if (firstSeparatorIndex <= 0 || firstSeparatorIndex === branchName.length - 1) { + return branchName; + } + return branchName.slice(firstSeparatorIndex + 1); +} + +function deriveLocalBranchNameCandidatesFromRemoteRef( + branchName: string, + remoteName?: string, +): ReadonlyArray { + const candidates = new Set(); + const firstSlashCandidate = deriveLocalBranchNameFromRemoteRef(branchName); + if (firstSlashCandidate.length > 0) { + candidates.add(firstSlashCandidate); + } + + if (remoteName) { + const remotePrefix = `${remoteName}/`; + if (branchName.startsWith(remotePrefix) && branchName.length > remotePrefix.length) { + candidates.add(branchName.slice(remotePrefix.length)); + } + } + + return [...candidates]; +} + +/** + * Hide `origin/*` remote refs when a matching local branch already exists. + */ +export function dedupeRemoteBranchesWithLocalMatches( + branches: ReadonlyArray, +): ReadonlyArray { + const localBranchNames = new Set( + branches.filter((branch) => !branch.isRemote).map((branch) => branch.name), + ); + + return branches.filter((branch) => { + if (!branch.isRemote) { + return true; + } + + if (branch.remoteName !== "origin") { + return true; + } + + const localBranchCandidates = deriveLocalBranchNameCandidatesFromRemoteRef( + branch.name, + branch.remoteName, + ); + return !localBranchCandidates.some((candidate) => localBranchNames.has(candidate)); + }); +} diff --git a/scripts/package.json b/scripts/package.json index d9db897ea2..157763b398 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -4,8 +4,6 @@ "type": "module", "scripts": { "prepare": "effect-language-service patch", - "claude-fast-mode-probe": "bun run claude-fast-mode-probe.ts", - "claude-haiku-thinking-probe": "bun run claude-haiku-thinking-probe.ts", "typecheck": "tsc --noEmit", "test": "vitest run" },