Skip to content
15 changes: 11 additions & 4 deletions packages/web/src/components/chapter/chapter-navigator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
Copy,
MoreHorizontal,
} from "lucide-react";
import { LineCounts } from "@/components/shared/line-counts";
import { ShortcutTooltip } from "@/components/shared/shortcut-tooltip";
import { StatusBadge } from "@/components/shared/status-badge";
import { Button } from "@/components/ui/button";
Expand All @@ -19,28 +20,26 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useChapterContext } from "@/lib/chapter-context";
import { SHORTCUT_KEY } from "@/lib/keyboard-shortcuts";
import { cn } from "@/lib/utils";

interface ChapterNavigatorProps {
runId: string;
chapter: Chapter;
chapterIndex: number;
allChapters: Chapter[];
viewedChapterIds: ReadonlySet<string>;
onToggleViewed: (externalId: string) => void;
onCopyChapter: () => void;
}

export function ChapterNavigator({
runId,
chapter,
chapterIndex,
allChapters,
viewedChapterIds,
onToggleViewed,
onCopyChapter,
}: ChapterNavigatorProps) {
const { runId, chapters: allChapters, chapterLineCountsMap } = useChapterContext();
const isViewed = viewedChapterIds.has(chapter.externalId);
const canPrev = chapterIndex > 0;
const canNext = chapterIndex < allChapters.length - 1;
Expand Down Expand Up @@ -100,6 +99,7 @@ export function ChapterNavigator({
{allChapters.map((ch, index) => {
const isActive = index === chapterIndex;
const isChViewed = viewedChapterIds.has(ch.externalId);
const counts = chapterLineCountsMap.get(ch.id);
return (
<DropdownMenuItem key={ch.id} asChild className="gap-3 px-3 py-2.5">
<Link
Expand Down Expand Up @@ -127,6 +127,13 @@ export function ChapterNavigator({
</div>
</StatusBadge>
<span className="min-w-0 flex-1 truncate text-sm">{ch.title}</span>
{counts && (
<LineCounts
additions={counts.linesAdded}
deletions={counts.linesDeleted}
className="shrink-0 opacity-70"
/>
)}
</Link>
</DropdownMenuItem>
);
Expand Down
21 changes: 14 additions & 7 deletions packages/web/src/components/chapter/chapter-side-panel.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { Chapter } from "@stage-cli/types/chapters";
import { useCallback, useEffect, useRef, useState } from "react";
import { LineCounts } from "@/components/shared/line-counts";
import { Markdown } from "@/components/ui/markdown";
import { useChapterContext } from "@/lib/chapter-context";
import type { FileDiffEntry } from "@/lib/parse-diff";
import { ChapterFileList } from "./chapter-file-list";
import { ChapterNavigator } from "./chapter-navigator";
Expand All @@ -12,10 +14,8 @@ const MAX_WIDTH_FRACTION = 0.5;
const SSR_FALLBACK_WIDTH = Math.round(1440 * DEFAULT_WIDTH_FRACTION);

interface ChapterSidePanelProps {
runId: string;
chapter: Chapter;
chapterIndex: number;
allChapters: Chapter[];
chapterEntries: FileDiffEntry[];
viewedChapterIds: ReadonlySet<string>;
checkedKeyChangeIds: ReadonlySet<string>;
Expand All @@ -30,10 +30,8 @@ interface ChapterSidePanelProps {
}

export function ChapterSidePanel({
runId,
chapter,
chapterIndex,
allChapters,
chapterEntries,
viewedChapterIds,
checkedKeyChangeIds,
Expand All @@ -46,6 +44,8 @@ export function ChapterSidePanel({
onSelectFile,
onCopyChapter,
}: ChapterSidePanelProps) {
const { chapterLineCountsMap } = useChapterContext();
const lineCounts = chapterLineCountsMap.get(chapter.id);
const [width, setWidth] = useState(SSR_FALLBACK_WIDTH);
const cleanupRef = useRef<(() => void) | null>(null);

Expand Down Expand Up @@ -97,19 +97,26 @@ export function ChapterSidePanel({
>
<div className="shrink-0 border-border border-b">
<ChapterNavigator
runId={runId}
chapter={chapter}
chapterIndex={chapterIndex}
allChapters={allChapters}
viewedChapterIds={viewedChapterIds}
onToggleViewed={onToggleChapterViewed}
onCopyChapter={onCopyChapter}
/>
<Markdown
content={chapter.title}
inheritSize
className="pb-3 pl-6 pr-4 font-semibold text-base leading-snug [&_.md-p]:my-0 lg:pl-8"
className="pb-1 pl-6 pr-4 font-semibold text-base leading-snug [&_.md-p]:my-0 lg:pl-8"
/>
{lineCounts ? (
<LineCounts
additions={lineCounts.linesAdded}
deletions={lineCounts.linesDeleted}
className="pb-3 pl-6 pr-4 lg:pl-8"
/>
) : (
<div className="pb-2" />
)}
Comment thread
cursor[bot] marked this conversation as resolved.
</div>
<div className="flex-1 overflow-y-auto">
<ChapterSummary
Expand Down
14 changes: 6 additions & 8 deletions packages/web/src/components/chapter/file-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from "lucide-react";
import type { MouseEvent } from "react";
import { useCallback } from "react";
import { LineCounts } from "@/components/shared/line-counts";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { FILE_STATUS, type PullRequestFile } from "@/lib/diff-types";
import { FILE_STATUS_ICONS, FILE_STATUS_LABELS, FILE_STATUS_TEXT_COLORS } from "@/lib/file-status";
Expand Down Expand Up @@ -206,14 +207,11 @@ export function FileHeader({
</Tooltip>
)}
<div className="flex-1" />
<div className="relative z-10 flex shrink-0 items-center gap-1 font-medium text-[10px] tabular-nums">
{file.additions > 0 && (
<span className="text-green-600 dark:text-green-500">+{file.additions}</span>
)}
{file.deletions > 0 && (
<span className="text-red-600 dark:text-red-500">-{file.deletions}</span>
)}
</div>
<LineCounts
additions={file.additions}
deletions={file.deletions}
className="relative z-10 shrink-0"
/>
{onToggleViewed && (
<Tooltip>
<TooltipTrigger asChild>
Expand Down
10 changes: 2 additions & 8 deletions packages/web/src/components/files/file-picker.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { ChevronRight, CircleCheck, FileText, Folder, Search } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { LineCounts } from "@/components/shared/line-counts";
import { FILE_STATUS, type PullRequestFile } from "@/lib/diff-types";
import { FILE_STATUS_ICONS, FILE_STATUS_TEXT_COLORS } from "@/lib/file-status";
import { buildFileTree, collapseEmptyFolders, type FileNode, sortFileTree } from "@/lib/file-tree";
Expand Down Expand Up @@ -182,14 +183,7 @@ function FilePickerTreeItem({
>
{node.name}
</span>
<div className="flex items-center gap-1 font-medium text-[10px] tabular-nums opacity-70">
{file.additions > 0 && (
<span className="text-green-600 dark:text-green-500">+{file.additions}</span>
)}
{file.deletions > 0 && (
<span className="text-red-600 dark:text-red-500">-{file.deletions}</span>
)}
</div>
<LineCounts additions={file.additions} deletions={file.deletions} className="opacity-70" />
{isViewed && (
<CircleCheck
className="size-3 shrink-0 text-green-600 dark:text-green-500"
Expand Down
17 changes: 17 additions & 0 deletions packages/web/src/components/shared/line-counts.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { cn } from "@/lib/utils";

interface LineCountsProps {
additions: number;
deletions: number;
className?: string;
}

export function LineCounts({ additions, deletions, className }: LineCountsProps) {
if (additions === 0 && deletions === 0) return null;
return (
<div className={cn("flex items-center gap-1 font-medium text-[10px] tabular-nums", className)}>
{additions > 0 && <span className="text-green-600 dark:text-green-500">+{additions}</span>}
{deletions > 0 && <span className="text-red-600 dark:text-red-500">-{deletions}</span>}
</div>
);
}
67 changes: 67 additions & 0 deletions packages/web/src/lib/chapter-context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type { Chapter } from "@stage-cli/types/chapters";
import { createContext, type ReactNode, use, useMemo } from "react";
import { filterFilesForChapter } from "./filter-files-for-chapter";
import { useChapters } from "./use-chapters";
import { useDiffPatch } from "./use-diff-patch";

export interface ChapterLineCounts {
linesAdded: number;
linesDeleted: number;
}

interface ChapterContextValue {
runId: string;
chapters: readonly Chapter[];
chapterLineCountsMap: ReadonlyMap<string, ChapterLineCounts>;
}

const ChapterContext = createContext<ChapterContextValue | null>(null);

function buildChapterLineCountsMap(
chapters: readonly Chapter[],
patch: string | undefined,
): ReadonlyMap<string, ChapterLineCounts> {
const map = new Map<string, ChapterLineCounts>();
if (!patch) return map;
for (const chapter of chapters) {
const entries = filterFilesForChapter(patch, chapter.hunkRefs);
let linesAdded = 0;
let linesDeleted = 0;
for (const entry of entries) {
linesAdded += entry.file.additions;
linesDeleted += entry.file.deletions;
Comment thread
dastratakos marked this conversation as resolved.
}
map.set(chapter.id, { linesAdded, linesDeleted });
}
Comment thread
dastratakos marked this conversation as resolved.
return map;
Comment thread
dastratakos marked this conversation as resolved.
}

export function ChapterProvider({ runId, children }: { runId: string; children: ReactNode }) {
const { data: chaptersData } = useChapters(runId);
const { data: patch } = useDiffPatch(runId);

const chapters = useMemo<readonly Chapter[]>(() => {
if (!chaptersData?.chapters) return [];
return [...chaptersData.chapters].sort((a, b) => a.order - b.order);
}, [chaptersData?.chapters]);

const chapterLineCountsMap = useMemo(
() => buildChapterLineCountsMap(chapters, patch),
[chapters, patch],
);

const value = useMemo<ChapterContextValue>(
() => ({ runId, chapters, chapterLineCountsMap }),
[runId, chapters, chapterLineCountsMap],
);

return <ChapterContext value={value}>{children}</ChapterContext>;
}

export function useChapterContext(): ChapterContextValue {
const ctx = use(ChapterContext);
if (!ctx) {
throw new Error("useChapterContext must be used within a ChapterProvider");
}
return ctx;
}
24 changes: 4 additions & 20 deletions packages/web/src/routes/chapter-detail-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from "@/components/files";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { useChapterContext } from "@/lib/chapter-context";
import { FILE_STATUS } from "@/lib/diff-types";
import { filterFilesForChapter } from "@/lib/filter-files-for-chapter";
import { formatChapterAsMarkdown } from "@/lib/format-chapter-markdown";
Expand Down Expand Up @@ -67,32 +68,17 @@ export function ChapterDetailPage({ runId, chapterNumber }: ChapterDetailPagePro
return <ErrorState runId={runId} error={new Error("Diff patch unavailable")} />;
}

return (
Comment thread
cursor[bot] marked this conversation as resolved.
<ChapterDetailContent
runId={runId}
chapter={chapter}
chapterIndex={chapterIndex}
allChapters={allChapters}
patch={patch}
/>
);
return <ChapterDetailContent chapter={chapter} chapterIndex={chapterIndex} patch={patch} />;
}

interface ChapterDetailContentProps {
runId: string;
chapter: Chapter;
chapterIndex: number;
allChapters: Chapter[];
patch: string;
}

function ChapterDetailContent({
runId,
chapter,
chapterIndex,
allChapters,
patch,
}: ChapterDetailContentProps) {
function ChapterDetailContent({ chapter, chapterIndex, patch }: ChapterDetailContentProps) {
const { runId, chapters: allChapters } = useChapterContext();
const view = useViewState(runId);
const [focusedKeyChangeId, setFocusedKeyChangeId] = useState<string | null>(null);

Expand Down Expand Up @@ -253,10 +239,8 @@ function ChapterDetailContent({
<SidebarLayout
sidebar={
<ChapterSidePanel
runId={runId}
chapter={chapter}
chapterIndex={chapterIndex}
allChapters={allChapters}
chapterEntries={chapterEntries}
viewedChapterIds={view.chapterIdSet}
checkedKeyChangeIds={view.keyChangeIdSet}
Expand Down
13 changes: 13 additions & 0 deletions packages/web/src/routes/chapters-index-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import { Link } from "@tanstack/react-router";
import { ChevronRight, Circle, CircleCheck } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { FileViewRow } from "@/components/chapter";
import { LineCounts } from "@/components/shared/line-counts";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { type ChapterLineCounts, useChapterContext } from "@/lib/chapter-context";
import { useViewState } from "@/lib/use-view-state";
import { cn } from "@/lib/utils";

Expand Down Expand Up @@ -65,6 +67,7 @@ interface ChapterEntryProps {
filePaths: string[];
isFilesOpen: boolean;
runId: string;
lineCounts: ChapterLineCounts | undefined;
onToggleViewed: () => void;
onToggleFiles: () => void;
onToggleAllFiles: () => void;
Expand All @@ -76,6 +79,7 @@ function ChapterEntry({
filePaths,
isFilesOpen,
runId,
lineCounts,
onToggleViewed,
onToggleFiles,
onToggleAllFiles,
Expand Down Expand Up @@ -115,6 +119,13 @@ function ChapterEntry({
backgroundPosition: "0 calc(100% - 0.35em)",
}}
>
{lineCounts && (
<LineCounts
additions={lineCounts.linesAdded}
deletions={lineCounts.linesDeleted}
className="float-right clear-right bg-background pl-1.5 font-mono text-xs text-muted-foreground"
/>
)}
<span
className={cn(
"[box-decoration-break:clone] bg-background pr-1.5 font-semibold text-base hover:underline",
Expand Down Expand Up @@ -161,6 +172,7 @@ interface ChaptersListProps {
}

function ChaptersList({ chapters, runId, viewedCount }: ChaptersListProps) {
const { chapterLineCountsMap } = useChapterContext();
const view = useViewState(runId);
const [openFiles, setOpenFiles] = useState<Set<string>>(() => new Set());
const [mounted, setMounted] = useState(false);
Expand Down Expand Up @@ -221,6 +233,7 @@ function ChaptersList({ chapters, runId, viewedCount }: ChaptersListProps) {
filePaths={filePathsByChapter.get(c.id) ?? []}
isFilesOpen={openFiles.has(c.id)}
runId={runId}
lineCounts={chapterLineCountsMap.get(c.id)}
onToggleViewed={() =>
isViewed ? view.unmarkChapterViewed(externalId) : view.markChapterViewed(externalId)
}
Expand Down
Loading
Loading