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
68 changes: 68 additions & 0 deletions apps/web/src/DiffComparison.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { Popover } from "@base-ui/react/popover";
import { InfoIcon, XIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useDiffSource } from "./app-state.tsx";
import { diffComparison } from "./diff-comparison.ts";
import { useSession } from "./session-context.tsx";

export function DiffComparison() {
const session = useSession();
const { mode, repository } = useDiffSource();
const comparison = diffComparison(session, mode, repository?.head);
return (
<Popover.Root key={mode}>
<Popover.Trigger
id="scope-description"
aria-label={`Diff comparison: ${comparison.summary}`}
render={
<Button
variant="ghost"
size="xs"
className="diff-comparison-trigger"
/>
}
>
<span className="diff-comparison-summary">{comparison.summary}</span>
<span className="diff-comparison-mobile-label">Comparison</span>
<InfoIcon aria-hidden="true" />
</Popover.Trigger>
<Popover.Portal>
<Popover.Positioner
side="top"
align="start"
sideOffset={8}
collisionPadding={16}
className="z-50"
>
<Popover.Popup className="diff-comparison-popup">
<div className="diff-comparison-heading">
<Popover.Title>Diff comparison</Popover.Title>
<Popover.Close
render={
<Button
variant="ghost"
size="icon-xs"
aria-label="Close diff comparison"
/>
}
>
<XIcon aria-hidden="true" />
</Popover.Close>
</div>
<Popover.Description>{comparison.description}</Popover.Description>
{comparison.details.length > 0 && (
<dl>
{comparison.details.map(({ label, value }) => (
<div key={label}>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
)}
</Popover.Popup>
</Popover.Positioner>
</Popover.Portal>
</Popover.Root>
);
}
10 changes: 2 additions & 8 deletions apps/web/src/DiffWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
import { useAppearance } from "./appearance-context.tsx";
import { CopyPathButton } from "./CopyPathButton.tsx";
import { DiffToolbar } from "./DiffToolbar.tsx";
import { DiffComparison } from "./DiffComparison.tsx";
import { themesFor } from "./display-options.ts";
import { DraftComment, ReviewCommentCard } from "./review.tsx";
import type { CommentAnnotation } from "./review-model.ts";
Expand Down Expand Up @@ -610,13 +611,6 @@ export function DiffWorkspace() {
],
);

const scopeDescription = piped
? "Command output · fixed snapshot"
: mode === "staged"
? "HEAD → index"
: mode === "unstaged"
? "Index → working tree, including untracked files"
: "HEAD → working tree, including untracked files";
const emptyTitle = !repository
? diff.notice
? "Cannot load changes"
Expand Down Expand Up @@ -765,7 +759,7 @@ export function DiffWorkspace() {
</div>
</div>
<footer className="main-footer">
<span id="scope-description">{scopeDescription}</span>
<DiffComparison />
<span
className="mobile-review-progress"
aria-label={`${reviewedCount} of ${allFiles.length} files reviewed`}
Expand Down
74 changes: 74 additions & 0 deletions apps/web/src/diff-comparison.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { ApiContext } from "@servediff/api";
import type { DiffMode } from "@servediff/shared";
import { contextIsPiped } from "./project-picker.ts";

export function diffComparison(
context: ApiContext,
mode: DiffMode,
repositoryHead: string | null | undefined,
) {
const observation = context.observation;
const comparison = observation?.comparison;
const head = observation ? observation.head : repositoryHead;
const details: { label: string; value: string }[] = [];
const recorded = (value: string | null | undefined) =>
value || "Not recorded";
if (contextIsPiped(context)) {
return {
summary: "Command output · fixed snapshot",
description:
"The supplied patch is shown as captured. No Git comparison was calculated by servediff.",
details,
};
}

let summary: string;
let description: string;
if (mode === "unstaged") {
summary = "Index → working tree, including untracked files";
description =
"Unstaged changes compare the index with the working tree, including untracked files. Committed branch changes are excluded.";
details.push({ label: "Baseline", value: "Index" });
} else if (mode === "all" && comparison?.kind === "branch") {
const base = comparison.baseRef.replace(/^refs\/(heads|remotes)\//, "");
const committedOnly =
context.capabilities.diff.stagingMetadata.state !== "enabled";
const target = committedOnly ? "HEAD" : "working tree";
summary = `Merge base (${base} · ${comparison.mergeBase?.slice(0, 7) || "unknown"}) → ${target}`;
description = committedOnly
? "Committed branch changes compare the merge base with the captured HEAD. Index and working tree changes are excluded."
: "Branch changes compare the merge base with the working tree, including committed, staged, unstaged, and untracked changes.";
details.push(
{ label: "Base ref", value: comparison.baseRef },
{ label: "Base commit", value: recorded(comparison.baseCommit) },
{ label: "Merge base", value: recorded(comparison.mergeBase) },
);
} else {
const unborn = !!comparison && !comparison.baseCommit && !head;
const base = unborn ? "Empty tree" : "HEAD";
summary =
mode === "staged"
? `${base} → index`
: `${base} → working tree, including untracked files`;
description =
mode === "staged"
? "Staged changes compare the captured HEAD with the index. Committed branch changes are excluded."
: "Working changes compare the captured HEAD with the working tree, including staged, unstaged, and untracked changes. Committed branch changes are excluded.";
if (unborn)
description =
"This checkout has no commits. Changes are compared with Git’s empty tree.";
details.push({ label: "Baseline", value: base });
}
details.push(
{
label: "HEAD",
value:
head ||
(comparison && !comparison.baseCommit
? "Unborn (no commits)"
: "Not recorded"),
},
{ label: "Branch", value: recorded(observation?.branch ?? context.branch) },
);
return { summary, description, details };
}
47 changes: 46 additions & 1 deletion apps/web/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -607,9 +607,51 @@ h2 {
}
.main-footer > #scope-description {
min-width: 0;
flex-shrink: 1;
justify-content: flex-start;
padding-inline: 0;
font: inherit;
}
.diff-comparison-trigger > span {
overflow: hidden;
text-overflow: ellipsis;
}
.diff-comparison-mobile-label {
display: none;
}
.diff-comparison-popup {
width: min(30rem, calc(100vw - 2 * var(--space-4)));
max-height: var(--available-height);
overflow-y: auto;
padding: var(--space-4);
border: 1px solid var(--border);
border-radius: var(--radius-control);
background: var(--surface-raised);
color: var(--fg);
font-size: var(--text-sm);
line-height: var(--leading-copy);
}
.diff-comparison-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-2);
font-weight: var(--weight-semibold);
}
.diff-comparison-popup dl {
display: grid;
gap: var(--space-3);
margin-top: var(--space-4);
}
.diff-comparison-popup dt {
color: var(--text-secondary);
}
.diff-comparison-popup dd {
font-family: var(--font-mono);
overflow-wrap: anywhere;
user-select: text;
}
.mobile-review-progress {
display: none;
}
Expand Down Expand Up @@ -671,9 +713,12 @@ h2 {
}
}
@media (max-width: 767px) {
.main-footer > #scope-description {
.main-footer .diff-comparison-summary {
display: none;
}
.diff-comparison-mobile-label {
display: inline;
}
.main-footer .footer-details {
margin-left: auto;
}
Expand Down
109 changes: 109 additions & 0 deletions apps/web/test/browser/local-workspace.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,115 @@ async function ready(page: Page, value = "all-v1") {
).toBeVisible();
}

test("comparison uses the captured merge base and follows the selected scope", async ({
page,
}) => {
const state = await localWorkspace(page);
const head = "a".repeat(40);
const baseCommit = "b".repeat(40);
const mergeBase = "c".repeat(40);
state.context.kind = "observation";
state.context.observation = {
sourceId: "machine",
hostname: "host",
runId: "",
agent: "",
trigger: "manual",
repositoryKey: "repo",
repositoryName: "repo",
remoteUrl: "",
checkoutKey: "checkout",
root: "/repo",
worktreeName: "repo",
branch: "feature",
head,
collectedAt: 1,
collectorVersion: "test",
comparison: {
kind: "branch",
baseRef: "refs/heads/main",
baseCommit,
mergeBase,
},
};
await page.goto("/contexts/local-review");
await ready(page);
const trigger = page.locator("#scope-description");
await expect(trigger).toContainText(
"Merge base (main · ccccccc) → working tree",
);
await trigger.click();
const popup = page.getByRole("dialog", { name: "Diff comparison" });
await expect(popup).toContainText(
"including committed, staged, unstaged, and untracked changes",
);
for (const value of ["refs/heads/main", head, baseCommit, mergeBase])
await expect(popup.getByText(value, { exact: true })).toBeVisible();
await page.keyboard.press("Escape");
await expect(trigger).toBeFocused();
await page.getByRole("button", { name: "Staged", exact: true }).click();
await expect(trigger).toContainText("HEAD → index");
await trigger.click();
await expect(popup).toContainText("Committed branch changes are excluded");
await expect(popup.getByText(head, { exact: true })).toBeVisible();
await expect(popup.getByText(mergeBase, { exact: true })).toHaveCount(0);
await page.keyboard.press("Escape");
await page.getByRole("button", { name: "Unstaged", exact: true }).click();
await expect(trigger).toContainText("Index → working tree");
await trigger.click();
await expect(popup).toContainText("Unstaged changes compare the index");
await page.keyboard.press("Escape");
await page.getByRole("button", { name: "All changes", exact: true }).click();
await page.setViewportSize({ width: 390, height: 844 });
await expect(trigger).toBeVisible();
await expect(trigger).toContainText("Comparison");
await trigger.click();
await expect(popup.getByText(mergeBase, { exact: true })).toBeVisible();
const bounds = await popup.boundingBox();
expect(bounds).not.toBeNull();
if (bounds) {
expect(bounds.x).toBeGreaterThanOrEqual(0);
expect(bounds.x + bounds.width).toBeLessThanOrEqual(390);
}
});

test("working comparison identifies HEAD and unborn baselines", async ({
page,
}) => {
const state = await localWorkspace(page);
await page.goto("/contexts/local-review");
await ready(page);
const trigger = page.locator("#scope-description");
await expect(trigger).toContainText("HEAD → working tree");
await trigger.click();
const popup = page.getByRole("dialog", { name: "Diff comparison" });
await expect(popup).toContainText("Committed branch changes are excluded");
await expect(popup.getByText("head", { exact: true })).toBeVisible();
state.context.observation = {
sourceId: "machine",
hostname: "host",
runId: "",
agent: "",
trigger: "manual",
repositoryKey: "repo",
repositoryName: "repo",
remoteUrl: "",
checkoutKey: "checkout",
root: "/repo",
worktreeName: "repo",
branch: "main",
head: null,
collectedAt: 1,
collectorVersion: "test",
comparison: { kind: "working-tree", baseRef: "HEAD" },
};
await page.reload();
await ready(page);
await expect(trigger).toContainText("Empty tree → working tree");
await trigger.click();
await expect(popup).toContainText("Unborn (no commits)");
});

test("snapshot stays selected after newer captures and branch transitions", async ({
page,
}) => {
Expand Down
6 changes: 6 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,12 @@ The header's delete action removes the selected snapshot, all
its stored diff scopes, comments, and reviewed-file marks after confirmation.
Deletion preserves stream freshness history; older snapshots remain stale.
It leaves Git files untouched, and a new collection can create a review again.
The footer shows the selected scope's actual baseline. Its Diff comparison popover
explains the calculation and exposes the recorded base ref, base commit, merge base,
and HEAD with full commit IDs. These facts are persisted in observation metadata
and returned by `GET /api/v2/contexts/{contextId}` as `observation.comparison`
and `observation.head`; reading them never resolves current Git refs.

`DiffWorkspace.tsx` owns Pierre rendering,
versions, worker options, and measured geometry. `use-review.ts` and
`use-reviewed-files.ts` own their REST requests and recovery; reads cannot settle
Expand Down
Loading
Loading