Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 214 additions & 0 deletions apps/web/src/components/DiffPanel.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import { RegistryContext } from "@effect/atom-react";
import { scopedThreadKey } from "@t3tools/client-runtime/environment";
import {
DEFAULT_CLIENT_SETTINGS,
EnvironmentId,
ProjectId,
ThreadId,
type ClientSettings,
type ReviewBranchCommitsResult,
type ReviewDiffPreviewResult,
type ScopedThreadRef,
type VcsListRefsResult,
type VcsStatusResult,
} from "@t3tools/contracts";
import * as DateTime from "effect/DateTime";
import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
import { expect, it, vi } from "vite-plus/test";
import { page, userEvent } from "vite-plus/test/browser";

import "~/index.css";
import { render } from "../../test/render";
import { makeThreadFixture } from "~/test-fixtures";

type ResultAtom<A> = Atom.Atom<AsyncResult.AsyncResult<A, Error>>;

const state = vi.hoisted(() => ({
thread: null as ReturnType<typeof makeThreadFixture> | null,
threadRef: null as ScopedThreadRef | null,
previews: new Map<string | null, ResultAtom<ReviewDiffPreviewResult>>(),
config: null as unknown as Atom.Atom<null>,
status: null as unknown as ResultAtom<VcsStatusResult>,
localRefs: null as unknown as ResultAtom<VcsListRefsResult>,
remoteRefs: null as unknown as ResultAtom<VcsListRefsResult>,
commits: null as unknown as ResultAtom<ReviewBranchCommitsResult>,
}));

vi.mock("~/state/server", () => ({
serverEnvironment: { configValueAtom: () => state.config },
}));
vi.mock("~/state/review", () => ({
reviewEnvironment: {
diffPreview: ({ input }: { input: { baseRef?: string } }) =>
state.previews.get(input.baseRef ?? null)!,
branchCommits: () => state.commits,
diffFileContents: {},
},
}));
vi.mock("~/state/vcs", () => ({
vcsEnvironment: {
status: () => state.status,
listRefs: ({ input }: { input: { refKind: "local" | "remote" } }) =>
input.refKind === "local" ? state.localRefs : state.remoteRefs,
},
}));
vi.mock("~/state/entities", () => ({
useQuietThreadShell: () => state.thread,
useProject: () => ({ workspaceRoot: "/project", repositoryIdentity: null }),
useThreadCheckpointSummaries: () => [],
useQuietThreadVisibleTurnItems: () => [],
}));
vi.mock("~/state/filesystem", () => ({
useFilesystemReadAccess: () => ({ canReadFiles: true, isPending: false, error: null }),
}));
vi.mock("~/hooks/useThreadRouteTarget", () => ({ useThreadRouteRef: () => state.threadRef }));
vi.mock("~/hooks/useSettings", () => ({
useClientSettings: (select: (settings: ClientSettings) => unknown) =>
select(DEFAULT_CLIENT_SETTINGS),
updateClientSettings: async () => {},
}));
vi.mock("~/hooks/useTheme", () => ({ useRenderedAppearance: () => "dark" }));
vi.mock("~/state/queries", () => ({
useCheckpointDiff: () => ({ data: null, error: null, isPending: false }),
useTurnItemDetail: () => ({ data: null, error: null, isPending: false }),
}));
vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => async () => {} }));
vi.mock("~/editorPreferences", () => ({ useOpenInPreferredEditor: () => async () => {} }));
vi.mock("~/fileContextMenu", () => ({ useFileContextMenuHandler: () => () => {} }));
vi.mock("~/browser/useOpenLink", () => ({ useOpenLink: () => async () => {} }));
// The test drives comparison controls, without mounting virtualized diff viewers or their workers.
vi.mock("./diffs/AnnotatableCodeView", () => ({ AnnotatableCodeView: () => null }));
vi.mock("./diffs/DiffFileTree", () => ({ DiffFileTree: () => null }));
vi.mock("./DiffFilePathCopyButton", () => ({ DiffFilePathCopyButton: () => null }));

import DiffPanel from "./DiffPanel";
import { useDiffPanelSessionStore, useDiffPanelStore } from "~/diffPanelStore";

const cwd = "/project-worktree";
const errorMessage = "Could not find a common commit between 'origin/orphan' and HEAD.";

function preview(): ReviewDiffPreviewResult {
return {
cwd,
generatedAt: DateTime.makeUnsafe("2026-10-30T00:00:00Z"),
sources: [
{
id: "branch",
kind: "branch-range",
title: "Changes",
baseRef: "main",
headRef: "dev",
diff: "",
diffHash: "empty",
truncated: false,
},
],
};
}

function setup(failedBaseRef: string | null) {
const reset = () => {
useDiffPanelStore.setState({
byThreadKey: {},
branchBaseRefByThreadKey: {},
keepSelectionOnOpenThreadKey: null,
});
useDiffPanelSessionStore.setState({ collapsedFileKeysByScopeKey: {} });
};
reset();
state.threadRef = {
environmentId: EnvironmentId.make("environment"),
threadId: ThreadId.make("thread"),
};
state.thread = makeThreadFixture({
...state.threadRef,
id: state.threadRef.threadId,
projectId: ProjectId.make("project"),
branch: "dev",
worktreePath: cwd,
});
state.previews.clear();
state.previews.set(null, Atom.make(AsyncResult.success(preview())));
state.previews.set("main", Atom.make(AsyncResult.success(preview())));
state.previews.set(failedBaseRef, Atom.make(AsyncResult.fail(new Error(errorMessage))));
state.config = Atom.make(null);
state.status = Atom.make(
AsyncResult.success({
isRepo: true,
hasPrimaryRemote: true,
isDefaultRef: false,
refName: "dev",
hasWorkingTreeChanges: false,
workingTree: { rootPath: cwd, files: [], insertions: 0, deletions: 0 },
hasUpstream: true,
aheadCount: 0,
behindCount: 0,
pr: null,
}),
);
const refs = (names: string[], isRemote: boolean): VcsListRefsResult => ({
isRepo: true,
hasPrimaryRemote: true,
nextCursor: null,
totalCount: names.length,
refs: names.map((name) => ({
name,
isRemote,
...(isRemote ? { remoteName: "origin" } : {}),
current: name === "dev",
isDefault: name === "main",
worktreePath: null,
})),
});
state.localRefs = Atom.make(AsyncResult.success(refs(["dev", "main"], false)));
state.remoteRefs = Atom.make(AsyncResult.success(refs(["origin/orphan"], true)));
state.commits = Atom.make(AsyncResult.success({ commits: [], truncated: false }));
useDiffPanelStore.getState().selectBranchBaseRef(state.threadRef, failedBaseRef);
const registry = AtomRegistry.make();
return {
registry,
threadRef: state.threadRef,
[Symbol.dispose]() {
registry.dispose();
reset();
},
};
}

it.each([
{
name: "a saved unrelated branch",
failedBaseRef: "origin/orphan",
recovery: "Automatic",
baseRef: null,
},
{
name: "an automatic base with unrelated history",
failedBaseRef: null,
recovery: "main",
baseRef: "main",
},
])(
"recovers from $name without leaving the diff panel",
async ({ failedBaseRef, recovery, baseRef }) => {
using context = setup(failedBaseRef);
await using screen = await render(
<RegistryContext.Provider value={context.registry}>
<DiffPanel composerDraftTarget={context.threadRef} workspaceMutationId={null} visible />
</RegistryContext.Provider>,
);
await expect.element(screen.getByText(errorMessage, { exact: true })).toBeVisible();
const selector = screen.getByRole("combobox", { name: /Change comparison target/ });
await vi.waitFor(() => expect(selector.query()).not.toBeNull());
await userEvent.click(selector);
await expect.element(page.getByRole("option", { name: "main", exact: true })).toBeVisible();
await userEvent.click(page.getByRole("option", { name: recovery, exact: true }));
await expect.element(screen.getByText("No net changes in this selection.")).toBeVisible();
await expect.element(screen.getByText(errorMessage, { exact: true })).not.toBeInTheDocument();
const key = scopedThreadKey(context.threadRef);
await vi.waitFor(() => {
expect(useDiffPanelStore.getState().byThreadKey[key]).toEqual({ kind: "branch", baseRef });
expect(useDiffPanelStore.getState().branchBaseRefByThreadKey[key]).toBe(baseRef);
});
},
);
30 changes: 16 additions & 14 deletions apps/web/src/components/DiffPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,8 @@ export default function DiffPanel({
? "commit"
: "branch-range"),
);
const comparisonHeadRef = selectedGitSource?.headRef ?? gitStatusQuery.data?.refName ?? "HEAD";
const comparisonBaseRef = selectedGitSource?.baseRef ?? selectedBaseRef ?? "Automatic";
const selectedCommitSubject =
selectedCommitSha !== null && selectedGitSource?.headRef === selectedCommitSha
? selectedGitSource.commitSubject
Expand Down Expand Up @@ -518,12 +520,14 @@ export default function DiffPanel({
}
return currentLoadDiffFiles(fileDiff);
});
const canSelectBranchBase = canReadFiles && canRefreshGitDiff && selectedGitScope === "branch";
const branchRefsCwd = branchDiffPreview.data?.cwd ?? activeCwd;
const localBranchRefs = useEnvironmentQuery(
isGitSelection && selectedGitScope === "branch" && activeThread && branchDiffPreview.data?.cwd
canSelectBranchBase && activeThread && branchRefsCwd
? vcsEnvironment.listRefs({
environmentId: activeThread.environmentId,
input: {
cwd: branchDiffPreview.data.cwd,
cwd: branchRefsCwd,
includeMatchingRemoteRefs: true,
refKind: "local",
...(baseRefQuery.trim().length > 0 ? { query: baseRefQuery.trim() } : {}),
Expand All @@ -533,11 +537,11 @@ export default function DiffPanel({
: null,
);
const remoteBranchRefs = useEnvironmentQuery(
isGitSelection && selectedGitScope === "branch" && activeThread && branchDiffPreview.data?.cwd
canSelectBranchBase && activeThread && branchRefsCwd
? vcsEnvironment.listRefs({
environmentId: activeThread.environmentId,
input: {
cwd: branchDiffPreview.data.cwd,
cwd: branchRefsCwd,
includeMatchingRemoteRefs: true,
refKind: "remote",
...(baseRefQuery.trim().length > 0 ? { query: baseRefQuery.trim() } : {}),
Expand All @@ -547,7 +551,7 @@ export default function DiffPanel({
: null,
);
const baseRefChoices = buildBaseRefChoices(
localBranchRefs.data?.refs.filter((ref) => ref.name !== selectedGitSource?.headRef) ?? [],
localBranchRefs.data?.refs.filter((ref) => ref.name !== comparisonHeadRef) ?? [],
remoteBranchRefs.data?.refs ?? [],
);
const matchingBaseRefChoices = filterBaseRefChoices(baseRefChoices, baseRefQuery);
Expand Down Expand Up @@ -949,10 +953,10 @@ export default function DiffPanel({
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
{isGitSelection && selectedGitScope === "branch" && selectedGitSource?.baseRef && (
{canSelectBranchBase && (
<div
className="flex min-w-0 max-w-full items-center gap-2 overflow-hidden text-xs text-muted-foreground"
aria-label={`Comparing ${selectedGitSource.headRef ?? "HEAD"} against ${selectedGitSource.baseRef}`}
aria-label={`Comparing ${comparisonHeadRef} against ${comparisonBaseRef}`}
>
<Tooltip>
<TooltipTrigger
Expand All @@ -962,13 +966,11 @@ export default function DiffPanel({
<span className="flex min-w-[min(3rem,calc(100%-2.75rem))] items-center gap-2 overflow-hidden @max-[20rem]/diff-refs:hidden" />
}
>
<span className="min-w-0 max-w-48 truncate">
{selectedGitSource.headRef ?? "HEAD"}
</span>
<span className="min-w-0 max-w-48 truncate">{comparisonHeadRef}</span>
<ArrowRightIcon className="size-3.5 shrink-0 opacity-70" />
</TooltipTrigger>
<TooltipPopup side="top">
{`${selectedGitSource.headRef ?? "HEAD"} → ${selectedGitSource.baseRef}`}
{`${comparisonHeadRef} → ${comparisonBaseRef}`}
</TooltipPopup>
</Tooltip>
<Tooltip>
Expand All @@ -978,7 +980,7 @@ export default function DiffPanel({
vs
</TooltipTrigger>
<TooltipPopup side="top">
{`${selectedGitSource.headRef ?? "HEAD"} → ${selectedGitSource.baseRef}`}
{`${comparisonHeadRef} → ${comparisonBaseRef}`}
</TooltipPopup>
</Tooltip>
<Combobox
Expand All @@ -996,9 +998,9 @@ export default function DiffPanel({
<ComboboxTrigger
render={<Button variant="ghost-muted" size="xs" />}
className="min-w-9 max-w-48 shrink-0"
aria-label={`Change comparison target. Currently ${selectedGitSource.baseRef}`}
aria-label={`Change comparison target. Currently ${comparisonBaseRef}`}
>
<span className="min-w-0 truncate">{selectedGitSource.baseRef}</span>
<span className="min-w-0 truncate">{comparisonBaseRef}</span>
<ChevronDownIcon className="size-3.5 shrink-0 opacity-70" />
</ComboboxTrigger>
<ComboboxPopup
Expand Down
Loading