Skip to content
Open
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
9 changes: 5 additions & 4 deletions apps/app/src/components/layout/content-panel/panels/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@
Mock content, real wiring. These remain to exercise the panel shapes that do not
yet have real features behind them:

| | arity | `create` | what it demonstrates |
| --------- | --------------------- | -------- | --------------------------------------------------- |
| `diff` | singleton | — | the thin default handle; opening twice is one panel |
| `browser` | family (`key: tabId`) | ✓ | an instance with its own store (url, loading) |
| | arity | `create` | what it demonstrates |
| --------- | --------------------- | -------- | --------------------------------------------- |
| `browser` | family (`key: tabId`) | ✓ | an instance with its own store (url, loading) |

The real Files entry panel and path-keyed file viewer live in `features/files/`.
The real Terminal panel lives in `features/terminal/`.
The Review panel (workspace diffs vs uncommitted, committed, or another branch)
lives in `features/review/`.

There is one tab strip and it belongs to the host. A panel that wants "several
of a thing" — two shells, two files — opens several panels, so the strip stays
Expand Down
70 changes: 0 additions & 70 deletions apps/app/src/components/layout/content-panel/panels/diff-panel.tsx

This file was deleted.

4 changes: 3 additions & 1 deletion apps/app/src/features/chat/runtime/chat-update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,9 @@ describe("updateChat", () => {

const prompting = createChatState();
const pending = requested("sending", "in flight");
prompting.outgoing = [{ message: pending.message, parts: pending.parts, status: "sending" }];
prompting.outgoing = [
{ message: pending.message, parts: pending.parts, status: "sending", delivery: "follow-up" },
];
expectIgnored(prompting, {
type: "outgoingCompleted",
messageId: "late",
Expand Down
143 changes: 143 additions & 0 deletions apps/app/src/features/review/review-diff-adapter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { parseDiffFromFile } from "@pierre/diffs";
import {
CodeView,
type CodeViewHandle,
type CodeViewItem,
type CodeViewReactOptions,
} from "@pierre/diffs/react";
import type { GitFileDiff } from "@vibest/contract/git";
import { useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";

const DIFF_UNSAFE_CSS = `
:host {
--diffs-font-family: var(--font-mono);
--diffs-light-bg: var(--background);
--diffs-dark-bg: var(--background);
--diffs-light: var(--foreground);
--diffs-dark: var(--foreground);
--diffs-fg-number-override: var(--muted-foreground);
--diffs-bg-buffer-override: var(--background);
--diffs-bg-context-override: var(--background);
--diffs-bg-context-gutter-override: var(--background);
--diffs-bg-separator-override: var(--border);
min-height: 100%;
width: 100%;
}

[data-diffs-header] {
cursor: pointer;
}
`;

const getAppThemeType = (): "dark" | "light" =>
document.documentElement.classList.contains("dark") ? "dark" : "light";

const subscribeToAppTheme = (listener: () => void): (() => void) => {
const observer = new MutationObserver(listener);
observer.observe(document.documentElement, {
attributeFilter: ["class"],
attributes: true,
});
return () => observer.disconnect();
};

function parseReviewDiff(diff: GitFileDiff) {
return parseDiffFromFile(
diff.oldContents === null
? null
: { name: diff.oldPath ?? diff.path, contents: diff.oldContents },
diff.newContents === null ? null : { name: diff.path, contents: diff.newContents },
);
}

function itemIdFromInstance(instance: object): string | undefined {
if (!("fileDiff" in instance)) return undefined;
const fileDiff = instance.fileDiff;
if (fileDiff === null || typeof fileDiff !== "object" || !("name" in fileDiff)) return undefined;
const name = fileDiff.name;
return typeof name === "string" ? name : undefined;
}

export function ReviewDiffAdapter({
diffs,
locatePath,
locateRequest,
}: {
diffs: ReadonlyArray<GitFileDiff>;
locatePath?: string;
locateRequest: number;
}) {
const themeType = useSyncExternalStore(
subscribeToAppTheme,
getAppThemeType,
() => "light" as const,
);
const codeViewRef = useRef<CodeViewHandle<undefined>>(null);
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(() => new Set());
const [appliedLocate, setAppliedLocate] = useState(locateRequest);
if (locateRequest !== appliedLocate) {
setAppliedLocate(locateRequest);
if (locatePath !== undefined && collapsed.has(locatePath)) {
const next = new Set(collapsed);
next.delete(locatePath);
setCollapsed(next);
}
}
const fileDiffs = useMemo(
() => diffs.map((diff) => ({ path: diff.path, fileDiff: parseReviewDiff(diff) })),
[diffs],
);

const items = useMemo<ReadonlyArray<CodeViewItem>>(
() =>
fileDiffs.map(({ path, fileDiff }) => ({
id: path,
type: "diff",
fileDiff,
collapsed: collapsed.has(path),
})),
[collapsed, fileDiffs],
);

const options = useMemo<CodeViewReactOptions>(
() => ({
overflow: "scroll",
stickyHeaders: true,
theme: { dark: "pierre-dark", light: "pierre-light" },
themeType,
unsafeCSS: DIFF_UNSAFE_CSS,
onPostRender(node, instance, phase) {
if (phase === "unmount") return;
const header = node.shadowRoot?.querySelector("[data-diffs-header]");
if (!(header instanceof HTMLElement)) return;
const id = itemIdFromInstance(instance);
if (id !== undefined) header.dataset.reviewPath = id;
if (header.dataset.reviewCollapseBound === "true") return;
header.dataset.reviewCollapseBound = "true";
header.addEventListener("click", () => {
const path = header.dataset.reviewPath;
if (path === undefined) return;
setCollapsed((current) => {
const next = new Set(current);
if (next.has(path)) next.delete(path);
else next.add(path);
return next;
});
});
},
}),
[themeType],
);

useLayoutEffect(() => {
if (locatePath === undefined) return;
if (!fileDiffs.some((entry) => entry.path === locatePath)) return;
codeViewRef.current?.scrollTo({ type: "item", id: locatePath, align: "start" });
}, [fileDiffs, locatePath, locateRequest]);

return (
<div className="h-full min-h-0 w-full">
<CodeView className="h-full w-full" items={items} options={options} ref={codeViewRef} />
</div>
);
}
138 changes: 138 additions & 0 deletions apps/app/src/features/review/review-diff-pane.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { ORPCError } from "@orpc/client";
import type { GitFileDiff } from "@vibest/contract/git";
import { Spinner } from "@vibest/ui/components/spinner";
import { FileDiffIcon } from "lucide-react";
import { lazy, Suspense } from "react";

import { emptyReviewMessage } from "./review-file-status";
import { ReviewState } from "./review-state";
import type { GitDiffsQuery } from "./use-git-diffs";
import type { GitReviewQuery } from "./use-git-review";

const ReviewDiffAdapter = lazy(() =>
import("./review-diff-adapter").then((module) => ({ default: module.ReviewDiffAdapter })),
);

export function ReviewDiffPane({
review,
diffs,
path,
locateRequest,
}: {
review: GitReviewQuery;
diffs: GitDiffsQuery;
path?: string;
locateRequest: number;
}) {
if (review.data !== undefined && review.data.files.length === 0) {
return (
<ReviewState prominentIcon title="No changes to review">
{emptyReviewMessage(review.data)}
</ReviewState>
);
}

if (diffs.some((diff) => diff.isPending)) {
return (
<div className="flex min-h-0 flex-1 items-center justify-center">
<Spinner className="text-muted-foreground size-4" />
</div>
);
}

const loaded: GitFileDiff[] = [];
let firstError: Error | undefined;
for (const diff of diffs) {
if (diff.data !== undefined) {
loaded.push(diff.data);
continue;
}
if (diff.isError && diff.error !== null && !isSkippedDiffError(diff.error)) {
firstError ??= diff.error;
}
}

if (loaded.length === 0 && firstError !== undefined) {
return (
<ReviewState title={diffErrorTitle(firstError)} onRetry={() => void review.refetch()}>
{diffErrorMessage(firstError)}
</ReviewState>
);
}

if (loaded.length === 0) {
return (
<ReviewState icon={FileDiffIcon} prominentIcon title="Review changes">
Select a changed file in the tree to jump to its diff.
</ReviewState>
);
}

return (
<div className="min-h-0 flex-1">
<Suspense
fallback={
<div className="flex h-full items-center justify-center">
<Spinner className="text-muted-foreground size-4" />
</div>
}
>
<ReviewDiffAdapter diffs={loaded} locatePath={path} locateRequest={locateRequest} />
</Suspense>
</div>
);
}

function isSkippedDiffError(error: Error): boolean {
return (
error instanceof ORPCError && (error.code === "BINARY_FILE" || error.code === "FILE_TOO_LARGE")
);
}

function diffErrorTitle(error: Error): string {
if (!(error instanceof ORPCError)) return "Unable to load diff";
switch (error.code) {
case "NOT_FOUND":
return "File is no longer in the review";
case "BINARY_FILE":
return "Binary preview unavailable";
case "FILE_TOO_LARGE":
return "File too large to preview";
case "REF_NOT_FOUND":
return "Compare branch not found";
default:
return "Unable to load diff";
}
}

function diffErrorMessage(error: Error): string {
if (!(error instanceof ORPCError)) return error.message;
switch (error.code) {
case "NOT_FOUND":
return "The file may have been committed, reverted, or renamed. Refresh the review.";
case "BINARY_FILE":
return "Binary preview unavailable.";
case "FILE_TOO_LARGE": {
const data = error.data as { size?: number; limit?: number } | undefined;
const size = data?.size;
const limit = data?.limit;
if (size !== undefined && limit !== undefined) {
return `${formatBytes(size)} exceeds the ${formatBytes(limit)} preview limit.`;
}
return "File too large to preview.";
}
case "PATH_ESCAPE":
return "This path resolves outside the project workspace.";
case "REF_NOT_FOUND":
return "Pick a local branch or a remote-tracking ref that already exists.";
default:
return error.message;
}
}

function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const kibibytes = bytes / 1024;
if (kibibytes < 1024) return `${kibibytes.toFixed(1)} KiB`;
return `${(kibibytes / 1024).toFixed(1)} MiB`;
}
Loading