Files
roboco/panel/src/components/git/git-diff-viewer.tsx
T
Renn F dcd2f84e69 feat(panel): dense tooltip pass — work surfaces (270 tips)
Task lifecycle actions get a 25-action tip map, every dialog field and
git panel control annotated; fixes two round-1 disabled-button tips
that could never fire and a double data-state stamp on tab-progress.
2026-07-15 18:04:00 +02:00

194 lines
6.3 KiB
TypeScript

"use client";
import { useState } from "react";
import { GitDiffResponse } from "@/types/git";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import { FileCode, FileDiff, WrapText } from "lucide-react";
import { HelpTip } from "@/components/ui/help-tip";
interface GitDiffViewerProps {
stagedDiff: GitDiffResponse | undefined;
unstagedDiff: GitDiffResponse | undefined;
isLoadingStaged: boolean;
isLoadingUnstaged: boolean;
}
function DiffContent({
diff,
isLoading,
wrap,
}: {
diff: GitDiffResponse | undefined;
isLoading: boolean;
wrap: boolean;
}) {
if (isLoading) {
return (
<div className="p-4 space-y-2">
{Array.from({ length: 10 }).map((_, i) => (
<Skeleton key={i} className="h-4 w-full" />
))}
</div>
);
}
if (!diff || !diff.diff) {
return (
<div className="p-8 text-center text-muted-foreground">
<FileDiff className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>No changes to display</p>
</div>
);
}
// Parse diff and colorize
const lines = diff.diff.split("\n");
return (
// overflow-x-auto is the horizontal-scroll affordance for un-wrapped long
// lines on a phone; smaller mobile font, back to the desktop size at sm+.
<ScrollArea className="h-96">
<pre
className={cn(
"p-4 font-mono text-[11px] leading-relaxed sm:text-xs",
wrap ? "whitespace-pre-wrap break-all" : "overflow-x-auto",
)}
>
{lines.map((line, i) => {
let className = "";
if (line.startsWith("+") && !line.startsWith("+++")) {
className = "bg-green-500/10 text-green-700 dark:text-green-400";
} else if (line.startsWith("-") && !line.startsWith("---")) {
className = "bg-red-500/10 text-red-700 dark:text-red-400";
} else if (line.startsWith("@@")) {
className = "bg-blue-500/10 text-blue-700 dark:text-blue-400";
} else if (line.startsWith("diff") || line.startsWith("index")) {
className = "text-muted-foreground font-semibold";
}
return (
<div
key={i}
className={cn(
"px-2 -mx-2",
wrap ? "whitespace-pre-wrap break-all" : "whitespace-pre",
className,
)}
>
{line || " "}
</div>
);
})}
</pre>
</ScrollArea>
);
}
export function GitDiffViewer({
stagedDiff,
unstagedDiff,
isLoadingStaged,
isLoadingUnstaged,
}: GitDiffViewerProps) {
const stagedCount = stagedDiff?.files_changed || 0;
const unstagedCount = unstagedDiff?.files_changed || 0;
const [wrap, setWrap] = useState(false);
return (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between gap-2">
<HelpTip label="Green = added lines, red = removed, blue = hunk header (@@ ... @@ shows the surrounding line range).">
<CardTitle className="text-sm flex items-center gap-2 w-fit">
<FileCode className="h-4 w-4" />
Changes
</CardTitle>
</HelpTip>
<HelpTip
label={
wrap
? "Long lines wrap to fit the panel — click to scroll horizontally instead."
: "Long lines overflow with horizontal scroll — click to wrap them instead."
}
>
<Button
variant={wrap ? "secondary" : "ghost"}
size="sm"
className="h-7 px-2 text-xs"
aria-pressed={wrap}
onClick={() => setWrap((w) => !w)}
>
<WrapText className="h-3.5 w-3.5 mr-1" />
Wrap
</Button>
</HelpTip>
</div>
</CardHeader>
<CardContent className="p-0">
<Tabs defaultValue="unstaged">
<div className="px-4 border-b">
<TabsList className="h-9">
{/* HelpTip wraps an inner span, never the TabsTrigger itself —
TooltipTrigger's asChild would clobber the trigger's own
data-state and break the active-tab highlight (see
task-tabs.tsx for the fuller writeup of this bug class). */}
<TabsTrigger value="unstaged" className="text-xs gap-1">
<HelpTip label="Files changed on disk that haven't been staged for the next commit yet">
<span className="inline-flex items-center gap-1">
Working Directory
{unstagedCount > 0 && (
<Badge
variant="secondary"
className="h-4 px-1 text-[10px]"
>
{unstagedCount}
</Badge>
)}
</span>
</HelpTip>
</TabsTrigger>
<TabsTrigger value="staged" className="text-xs gap-1">
<HelpTip label="Files staged and ready to be included in the next commit">
<span className="inline-flex items-center gap-1">
Staged
{stagedCount > 0 && (
<Badge
variant="secondary"
className="h-4 px-1 text-[10px]"
>
{stagedCount}
</Badge>
)}
</span>
</HelpTip>
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="unstaged" className="m-0">
<DiffContent
diff={unstagedDiff}
isLoading={isLoadingUnstaged}
wrap={wrap}
/>
</TabsContent>
<TabsContent value="staged" className="m-0">
<DiffContent
diff={stagedDiff}
isLoading={isLoadingStaged}
wrap={wrap}
/>
</TabsContent>
</Tabs>
</CardContent>
</Card>
);
}