diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 61892d53d63..83d7478b4dc 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -68,6 +68,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(first.environmentId).toBe(second.environmentId); expect(second.capabilities.repositoryIdentity).toBe(true); expect(second.capabilities.connectionProbe).toBe(true); + expect(second.capabilities.worktreeSourceControl).toBe(true); }), ); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 1c0d34ea5bc..855fea82d62 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -136,6 +136,7 @@ export const make = Effect.gen(function* () { capabilities: { repositoryIdentity: true, connectionProbe: true, + worktreeSourceControl: true, }, }; diff --git a/apps/web/src/components/WorktreeSourceControl.logic.test.ts b/apps/web/src/components/WorktreeSourceControl.logic.test.ts new file mode 100644 index 00000000000..b9fc7a65db8 --- /dev/null +++ b/apps/web/src/components/WorktreeSourceControl.logic.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveWorktreeDiffSource } from "./WorktreeSourceControl.logic"; + +describe("resolveWorktreeDiffSource", () => { + it("uses the exact index-aware source when available", () => { + const sources = [ + { id: "legacy", kind: "working-tree" as const }, + { id: "staged", kind: "staged" as const }, + { id: "unstaged", kind: "unstaged" as const }, + ]; + + expect(resolveWorktreeDiffSource(sources, "unstaged")?.id).toBe("unstaged"); + expect(resolveWorktreeDiffSource(sources, "staged")?.id).toBe("staged"); + }); + + it("falls back to the legacy working-tree source for unstaged changes", () => { + const sources = [ + { id: "legacy", kind: "working-tree" as const }, + { id: "branch", kind: "branch-range" as const }, + ]; + + expect(resolveWorktreeDiffSource(sources, "unstaged")?.id).toBe("legacy"); + }); + + it("does not present a combined legacy diff as staged-only changes", () => { + const sources = [{ id: "legacy", kind: "working-tree" as const }]; + + expect(resolveWorktreeDiffSource(sources, "staged")).toBeUndefined(); + }); + + it("does not mask a missing source in an index-aware response", () => { + const sources = [ + { id: "legacy", kind: "working-tree" as const }, + { id: "staged", kind: "staged" as const }, + ]; + + expect(resolveWorktreeDiffSource(sources, "unstaged")).toBeUndefined(); + }); +}); diff --git a/apps/web/src/components/WorktreeSourceControl.logic.ts b/apps/web/src/components/WorktreeSourceControl.logic.ts new file mode 100644 index 00000000000..c34df0adebf --- /dev/null +++ b/apps/web/src/components/WorktreeSourceControl.logic.ts @@ -0,0 +1,23 @@ +import type { ReviewDiffPreviewSourceKind } from "@t3tools/contracts"; + +export type WorktreeChangeScope = "staged" | "unstaged"; + +/** + * Older environments only return the combined `working-tree` review source. + * Treat it as the unstaged source when no index-aware sources are present so + * mixed-version remote environments retain a useful read-only diff. + */ +export function resolveWorktreeDiffSource( + sources: readonly T[], + selectedScope: WorktreeChangeScope, +): T | undefined { + const exactSource = sources.find((source) => source.kind === selectedScope); + if (exactSource || selectedScope !== "unstaged") return exactSource; + + const hasIndexAwareSources = sources.some( + (source) => source.kind === "staged" || source.kind === "unstaged", + ); + return hasIndexAwareSources + ? undefined + : sources.find((source) => source.kind === "working-tree"); +} diff --git a/apps/web/src/components/WorktreeSourceControl.tsx b/apps/web/src/components/WorktreeSourceControl.tsx index f82f7df64dc..e2e511269ed 100644 --- a/apps/web/src/components/WorktreeSourceControl.tsx +++ b/apps/web/src/components/WorktreeSourceControl.tsx @@ -1,3 +1,4 @@ +import { useAtomValue } from "@effect/atom-react"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -31,6 +32,7 @@ import { import { cn } from "~/lib/utils"; import { reviewEnvironment } from "~/state/review"; import { useEnvironmentQuery } from "~/state/query"; +import { serverEnvironment } from "~/state/server"; import { useAtomCommand } from "~/state/use-atom-command"; import { vcsEnvironment } from "~/state/vcs"; @@ -43,8 +45,9 @@ import { DIFF_VIEW_UNSAFE_CSS } from "./diffs/diffViewStyles"; import { Toggle, ToggleGroup } from "./ui/toggle-group"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { stackedThreadToast, toastManager } from "./ui/toast"; +import { resolveWorktreeDiffSource, type WorktreeChangeScope } from "./WorktreeSourceControl.logic"; -export type WorktreeChangeScope = "staged" | "unstaged"; +export type { WorktreeChangeScope } from "./WorktreeSourceControl.logic"; type WorktreeFile = VcsStatusResult["workingTree"]["files"][number]; type MutationKind = "stage" | "unstage" | "discard" | "refresh"; @@ -101,6 +104,7 @@ interface ChangeSectionProps { readonly selectedScope: WorktreeChangeScope; readonly selectedPath: string | null; readonly pending: { readonly kind: MutationKind; readonly path: string | null } | null; + readonly canMutate: boolean; readonly onSelect: (scope: WorktreeChangeScope, path: string | null) => void; readonly onStage: (paths: readonly string[]) => void; readonly onUnstage: (paths: readonly string[]) => void; @@ -114,6 +118,7 @@ const ChangeSection = memo(function ChangeSection({ selectedScope, selectedPath, pending, + canMutate, onSelect, onStage, onUnstage, @@ -141,28 +146,30 @@ const ChangeSection = memo(function ChangeSection({ {files.length} - - (scope === "staged" ? onUnstage(paths) : onStage(paths))} - /> - } - > - {scope === "staged" ? ( - - ) : ( - - )} - - - {scope === "staged" ? "Unstage All" : "Stage All"} - - + {canMutate ? ( + + (scope === "staged" ? onUnstage(paths) : onStage(paths))} + /> + } + > + {scope === "staged" ? ( + + ) : ( + + )} + + + {scope === "staged" ? "Unstage All" : "Stage All"} + + + ) : null}