Files
kandev/apps/web/components/task/mobile/mobile-diff-sheet.tsx
T
2026-08-05 20:45:59 +01:00

245 lines
8.0 KiB
TypeScript

"use client";
import { memo, useMemo, useState, useEffect, useRef, useCallback } from "react";
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@kandev/ui/drawer";
import { Button } from "@kandev/ui/button";
import { Tabs, TabsList, TabsTrigger } from "@kandev/ui/tabs";
import { TaskChangesPanel } from "../task-changes-panel";
import { CommitDiffView } from "../commit-detail-panel";
import type { ReviewSource, SourceCounts } from "@/hooks/domains/session/use-review-sources";
import type { SelectedDiff } from "../task-layout";
import type { DiffSheetMode } from "../changes-diff-target";
const MOBILE_DIFF_SOURCE_FILTER_KEY = "mobile-diff-source-filter";
type MobileDiffSheetProps = {
mode: DiffSheetMode | null;
onClose: () => void;
onOpenFile?: (filePath: string, repo?: string) => void;
selectedDiff: SelectedDiff | null;
onClearSelected: () => void;
sourceCounts: SourceCounts;
};
function pickFirstNonEmpty(sourceCounts: SourceCounts): ReviewSource | null {
if (sourceCounts.uncommitted > 0) return "uncommitted";
if (sourceCounts.committed > 0) return "committed";
if (sourceCounts.pr > 0) return "pr";
return null;
}
function useAutoSelectSource(
modeKind: string | undefined,
sourceCounts: SourceCounts,
activeSource: ReviewSource,
setActiveSource: (s: ReviewSource) => void,
) {
const userPickedRef = useRef(false);
const prevModeKindRef = useRef<string | undefined>(undefined);
// Reset the "user picked" flag whenever the sheet closes/reopens.
useEffect(() => {
if (modeKind !== "all" && prevModeKindRef.current === "all") {
userPickedRef.current = false;
}
prevModeKindRef.current = modeKind;
}, [modeKind]);
// Auto-select highest-priority non-empty source when the active source is
// empty. Stops once the user makes an explicit choice. Does not override a
// non-empty active source — so switching tabs then seeing uncommitted files
// arrive does not forcibly reset the user's view.
useEffect(() => {
if (modeKind !== "all") return;
if (userPickedRef.current) return;
const pick = pickFirstNonEmpty(sourceCounts);
if (pick === null) return;
if (sourceCounts[activeSource] > 0) return;
if (pick === activeSource) return;
setActiveSource(pick);
}, [modeKind, sourceCounts, activeSource, setActiveSource]);
const handleUserPick = useCallback(
(s: ReviewSource) => {
userPickedRef.current = true;
setActiveSource(s);
},
[setActiveSource],
);
return { handleUserPick };
}
type SourceTab = { key: ReviewSource; label: string; count: number };
function buildSourceTabs(sourceCounts: SourceCounts): SourceTab[] {
return [
{ key: "uncommitted" as ReviewSource, label: "Uncommitted", count: sourceCounts.uncommitted },
{ key: "committed" as ReviewSource, label: "Committed", count: sourceCounts.committed },
{ key: "pr" as ReviewSource, label: "PR", count: sourceCounts.pr },
].filter((t) => t.count > 0);
}
function deriveTitle(mode: DiffSheetMode | null, sourceTabs: SourceTab[]): string {
if (!mode) return "";
if (mode.kind === "all") {
if (sourceTabs.length === 1) return `${sourceTabs[0].label} Changes`;
return "All Changes";
}
if (mode.kind === "file") return "File Changes";
if (mode.kind === "commit") return "Commit Changes";
return "";
}
function SheetHeader({
title,
showSourceChip,
sourceLabel,
onClose,
}: {
title: string;
showSourceChip: boolean;
sourceLabel: string | null;
onClose: () => void;
}) {
return (
<DrawerHeader className="flex items-center justify-between py-2 px-4 border-b shrink-0">
<DrawerTitle className="text-base flex items-center gap-2">
<span>{title}</span>
{showSourceChip && sourceLabel && (
<span
className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground bg-muted/60 rounded-sm px-1.5 py-0.5"
data-testid="mobile-diff-sheet-source-chip"
>
{sourceLabel}
</span>
)}
</DrawerTitle>
<Button
variant="ghost"
size="sm"
className="px-2"
onClick={onClose}
data-testid="mobile-diff-sheet-close"
>
Close
</Button>
</DrawerHeader>
);
}
function SourceTabBar({
tabs,
activeSource,
onPick,
}: {
tabs: SourceTab[];
activeSource: ReviewSource;
onPick: (s: ReviewSource) => void;
}) {
return (
<div className="px-4 py-2 border-b shrink-0">
<Tabs value={activeSource} onValueChange={(v) => onPick(v as ReviewSource)}>
<TabsList className="h-8">
{tabs.map((tab) => (
<TabsTrigger key={tab.key} value={tab.key} className="text-xs px-2">
{tab.label} ({tab.count})
</TabsTrigger>
))}
</TabsList>
</Tabs>
</div>
);
}
function renderPanel(
mode: DiffSheetMode | null,
activeSource: ReviewSource,
selectedDiff: SelectedDiff | null,
onClearSelected: () => void,
onOpenFile?: (filePath: string, repo?: string) => void,
): React.ReactNode {
if (!mode) return null;
if (mode.kind === "commit") {
return <CommitDiffView target={mode.target} onOpenFile={onOpenFile} wordWrap />;
}
const panelMode = mode.kind;
const filePath = mode.kind === "file" ? mode.path : undefined;
const fileRepositoryName = mode.kind === "file" ? mode.repositoryName : undefined;
const prKey = mode.kind === "file" ? mode.prKey : undefined;
const effectiveSourceFilter = mode.kind === "all" ? activeSource : (mode.sourceFilter ?? "all");
return (
<TaskChangesPanel
mode={panelMode}
filePath={filePath}
fileRepositoryName={fileRepositoryName}
prKey={prKey}
selectedDiff={selectedDiff}
onClearSelected={onClearSelected}
onOpenFile={onOpenFile}
sourceFilter={effectiveSourceFilter}
wordWrap
/>
);
}
/**
* Full-screen mobile diff viewer sheet. Shows either merged diffs (mode=all),
* single-file diffs (mode=file), or commit diffs (mode=commit).
* Uses Drawer with full-screen height to give diff viewer maximum space.
*/
export const MobileDiffSheet = memo(function MobileDiffSheet({
mode,
onClose,
onOpenFile,
selectedDiff,
onClearSelected,
sourceCounts,
}: MobileDiffSheetProps) {
const [activeSource, setActiveSource] = useState<ReviewSource>(() => {
if (typeof window === "undefined") return "uncommitted";
const saved = localStorage.getItem(MOBILE_DIFF_SOURCE_FILTER_KEY);
if (saved === "uncommitted" || saved === "committed" || saved === "pr") return saved;
return "uncommitted";
});
const { handleUserPick } = useAutoSelectSource(
mode?.kind,
sourceCounts,
activeSource,
setActiveSource,
);
useEffect(() => {
if (mode?.kind !== "all") return;
localStorage.setItem(MOBILE_DIFF_SOURCE_FILTER_KEY, activeSource);
}, [mode?.kind, activeSource]);
const sourceTabs = useMemo(() => buildSourceTabs(sourceCounts), [sourceCounts]);
const activeSourceLabel = useMemo(
() => sourceTabs.find((t) => t.key === activeSource)?.label ?? null,
[sourceTabs, activeSource],
);
const title = useMemo(() => deriveTitle(mode, sourceTabs), [mode, sourceTabs]);
const panelContent = useMemo(
() => renderPanel(mode, activeSource, selectedDiff, onClearSelected, onOpenFile),
[mode, activeSource, selectedDiff, onClearSelected, onOpenFile],
);
return (
<Drawer open={mode !== null} onOpenChange={(open) => (!open ? onClose() : undefined)}>
<DrawerContent className="h-full max-h-screen flex flex-col rounded-none">
<SheetHeader
title={title}
showSourceChip={mode?.kind === "all" && sourceTabs.length === 1}
sourceLabel={activeSourceLabel}
onClose={onClose}
/>
{mode?.kind === "all" && sourceTabs.length > 1 && (
<SourceTabBar tabs={sourceTabs} activeSource={activeSource} onPick={handleUserPick} />
)}
<div className="flex-1 min-h-0 overflow-y-auto" data-vaul-no-drag>
{panelContent}
</div>
</DrawerContent>
</Drawer>
);
});