Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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: 1 addition & 8 deletions packages/web/src/app/runs.$runId.files.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,11 @@
import { createFileRoute } from "@tanstack/react-router";
import { z } from "zod";
import { FilesPage } from "@/routes/files-page";

const filesSearchSchema = z.object({
scrollTo: z.string().optional(),
});

export const Route = createFileRoute("/runs/$runId/files")({
validateSearch: (search) => filesSearchSchema.parse(search),
component: FilesRoute,
});

function FilesRoute() {
const { runId } = Route.useParams();
const { scrollTo } = Route.useSearch();
return <FilesPage runId={runId} scrollTo={scrollTo} />;
return <FilesPage runId={runId} />;
}
2 changes: 1 addition & 1 deletion packages/web/src/app/runs.$runId.index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ function ChaptersRoute() {
<div className="@container">
<div className="grid grid-cols-1 gap-6 @4xl:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]">
<div className="scrollbar-thin min-w-0 @4xl:sticky @4xl:top-[var(--content-top)] @4xl:max-h-[calc(var(--main-height)_-_var(--content-top))] @4xl:overflow-y-auto @4xl:pr-4 @4xl:pb-6">
<PrologueSection prologue={prologue} runId={runId} />
<PrologueSection prologue={prologue} />
</div>
<div className="min-w-0">
<ChaptersIndexPage
Expand Down
62 changes: 36 additions & 26 deletions packages/web/src/components/chapter/chapter-file-list.tsx
Original file line number Diff line number Diff line change
@@ -1,45 +1,55 @@
import {
FILE_VIEWED_STATE,
type FileViewedState,
FileViewRow,
} from "@/components/chapter/file-view-row";
import type { FileDiffEntry } from "@/lib/parse-diff";
import { useMemo, useState } from "react";
import { FileFilterInput } from "@/components/files/file-filter-input";
import { FileTree, type ViewedConfig } from "@/components/files/file-tree";
import { FILE_VIEWED_STATE, type PullRequestFile } from "@/lib/diff-types";

// The CLI has no review comments, so the tree never renders comment badges.
const NO_COMMENT_COUNTS: Map<string, number> = new Map();

interface ChapterFileListProps {
entries: FileDiffEntry[];
files: PullRequestFile[];
focusedFilePath?: string;
viewedPathSet: ReadonlySet<string>;
onToggleFileViewed: (filePath: string) => void;
onSelectFile: (filePath: string) => void;
}

export function ChapterFileList({
entries,
files,
focusedFilePath,
viewedPathSet,
onToggleFileViewed,
onSelectFile,
}: ChapterFileListProps) {
const [filter, setFilter] = useState("");

const viewed = useMemo<ViewedConfig>(
() => ({
stateByPath: new Map(
files.map((file) => [
file.path,
viewedPathSet.has(file.path) ? FILE_VIEWED_STATE.VIEWED : FILE_VIEWED_STATE.UNVIEWED,
]),
),
onToggle: onToggleFileViewed,
}),
[files, viewedPathSet, onToggleFileViewed],
);

return (
<div className="py-3 pl-6 pr-4 lg:pl-8">
<h2 className="mb-2 font-medium text-[11px] text-muted-foreground uppercase tracking-wider">
Files <span className="text-muted-foreground/60">({entries.length})</span>
Files <span className="text-muted-foreground/60">({files.length})</span>
</h2>
<div className="space-y-0.5">
{entries.map(({ file }) => {
const viewedState: FileViewedState = viewedPathSet.has(file.path)
? FILE_VIEWED_STATE.VIEWED
: FILE_VIEWED_STATE.UNVIEWED;
return (
<FileViewRow
key={file.path}
filePath={file.path}
status={file.status}
viewedState={viewedState}
onToggleViewed={onToggleFileViewed}
onSelect={onSelectFile}
/>
);
})}
</div>
<FileFilterInput value={filter} onChange={setFilter} className="mb-2" />
<FileTree
Comment thread
dastratakos marked this conversation as resolved.
files={files}
Comment thread
dastratakos marked this conversation as resolved.
focusedFilePath={focusedFilePath}
onSelectFile={onSelectFile}
viewed={viewed}
commentCountsByPath={NO_COMMENT_COUNTS}
filter={filter}
/>
Comment thread
dastratakos marked this conversation as resolved.
</div>
);
}
3 changes: 3 additions & 0 deletions packages/web/src/components/chapter/chapter-navigator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export function ChapterNavigator({
<Link
to="/runs/$runId/chapters/$chapterNumber"
params={{ runId, chapterNumber: String(prevChapter.order) }}
resetScroll={false}
className="inline-flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<ChevronLeft className="size-4" />
Expand Down Expand Up @@ -105,6 +106,7 @@ export function ChapterNavigator({
<Link
to="/runs/$runId/chapters/$chapterNumber"
params={{ runId, chapterNumber: String(ch.order) }}
resetScroll={false}
className={cn("cursor-pointer", isActive && "bg-accent")}
>
<StatusBadge
Expand Down Expand Up @@ -146,6 +148,7 @@ export function ChapterNavigator({
<Link
to="/runs/$runId/chapters/$chapterNumber"
params={{ runId, chapterNumber: String(nextChapter.order) }}
resetScroll={false}
className="inline-flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<ChevronRight className="size-4" />
Expand Down
12 changes: 12 additions & 0 deletions packages/web/src/components/chapter/chapter-panel-constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* Shared resize bounds for the chapter side panel. Width is viewport-relative:
* a 30% default capped at 50%, with a fixed pixel floor.
*/
export const CHAPTER_PANEL_MIN_WIDTH = 280;
export const CHAPTER_PANEL_MAX_WIDTH_FRACTION = 0.5;

export const resolveChapterPanelDefaultWidth = (viewportWidth: number) =>
Comment thread
dastratakos marked this conversation as resolved.
Math.round(viewportWidth * 0.3);

export const resolveChapterPanelMaxWidth = (viewportWidth: number) =>
Math.round(viewportWidth * CHAPTER_PANEL_MAX_WIDTH_FRACTION);
82 changes: 26 additions & 56 deletions packages/web/src/components/chapter/chapter-side-panel.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
import type { Chapter } from "@stagereview/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 type { PullRequestFile } from "@/lib/diff-types";
import { useResizablePanel } from "@/lib/use-resizable-panel";
import { ChapterFileList } from "./chapter-file-list";
import { ChapterNavigator } from "./chapter-navigator";
import {
CHAPTER_PANEL_MAX_WIDTH_FRACTION,
CHAPTER_PANEL_MIN_WIDTH,
resolveChapterPanelDefaultWidth,
resolveChapterPanelMaxWidth,
} from "./chapter-panel-constants";
import { ChapterSummary } from "./chapter-summary";

const MIN_WIDTH = 280;
const DEFAULT_WIDTH_FRACTION = 0.3;
const MAX_WIDTH_FRACTION = 0.5;
const SSR_FALLBACK_WIDTH = Math.round(1440 * DEFAULT_WIDTH_FRACTION);

interface ChapterSidePanelProps {
chapter: Chapter;
chapterIndex: number;
chapterEntries: FileDiffEntry[];
files: PullRequestFile[];
focusedFilePath?: string;
viewedChapterIds: ReadonlySet<string>;
checkedKeyChangeIds: ReadonlySet<string>;
viewedFilePathSet: ReadonlySet<string>;
Expand All @@ -32,7 +34,8 @@ interface ChapterSidePanelProps {
export function ChapterSidePanel({
chapter,
chapterIndex,
chapterEntries,
files,
focusedFilePath,
viewedChapterIds,
checkedKeyChangeIds,
viewedFilePathSet,
Expand All @@ -46,54 +49,22 @@ export function ChapterSidePanel({
}: ChapterSidePanelProps) {
const { chapterLineCountsMap } = useChapterContext();
const lineCounts = chapterLineCountsMap.get(chapter.id);
const [width, setWidth] = useState(SSR_FALLBACK_WIDTH);
const cleanupRef = useRef<(() => void) | null>(null);

useEffect(() => {
const max = Math.round(window.innerWidth * MAX_WIDTH_FRACTION);
const def = Math.round(window.innerWidth * DEFAULT_WIDTH_FRACTION);
setWidth(Math.min(max, Math.max(MIN_WIDTH, def)));
}, []);

const handleDoubleClick = useCallback(() => {
const max = Math.round(window.innerWidth * MAX_WIDTH_FRACTION);
const def = Math.round(window.innerWidth * DEFAULT_WIDTH_FRACTION);
setWidth(Math.min(max, Math.max(MIN_WIDTH, def)));
}, []);

const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
const startX = e.clientX;
const startWidth = width;

const onMove = (ev: MouseEvent) => {
const max = Math.round(window.innerWidth * MAX_WIDTH_FRACTION);
setWidth(Math.min(max, Math.max(MIN_WIDTH, startWidth + ev.clientX - startX)));
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
cleanupRef.current = null;
};

document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
cleanupRef.current = onUp;
},
[width],
);

useEffect(() => () => cleanupRef.current?.(), []);
const { width, panelRef, resizeHandleProps } = useResizablePanel({
minWidth: CHAPTER_PANEL_MIN_WIDTH,
maxWidth: resolveChapterPanelMaxWidth,
defaultWidth: resolveChapterPanelDefaultWidth,
});

return (
<div
ref={panelRef}
className="sticky top-[var(--content-top)] flex h-[calc(100vh_-_var(--content-top))] flex-col border-border border-r bg-card/30"
style={{ width, minWidth: MIN_WIDTH, maxWidth: `${MAX_WIDTH_FRACTION * 100}vw` }}
style={{
width,
minWidth: CHAPTER_PANEL_MIN_WIDTH,
maxWidth: `${CHAPTER_PANEL_MAX_WIDTH_FRACTION * 100}vw`,
}}
>
<div className="shrink-0 border-border border-b">
<ChapterNavigator
Expand Down Expand Up @@ -128,17 +99,16 @@ export function ChapterSidePanel({
/>
<div className="border-border border-t">
<ChapterFileList
entries={chapterEntries}
files={files}
focusedFilePath={focusedFilePath}
viewedPathSet={viewedFilePathSet}
onToggleFileViewed={onToggleFileViewed}
onSelectFile={onSelectFile}
/>
</div>
</div>
{/* biome-ignore lint/a11y/noStaticElementInteractions: resize handle is a drag target, not an interactive widget */}
<div
onDoubleClick={handleDoubleClick}
onMouseDown={handleMouseDown}
{...resizeHandleProps}
className="absolute top-0 right-0 z-10 h-full w-1 cursor-col-resize hover:bg-primary/30 active:bg-primary/50"
/>
</div>
Expand Down
51 changes: 29 additions & 22 deletions packages/web/src/components/chapter/file-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,15 @@ import {
} from "lucide-react";
import type { MouseEvent } from "react";
import { useCallback } from "react";
import { ShortcutLabel } from "@/components/keyboard/shortcut-label";
import { LineCounts } from "@/components/shared/line-counts";
import { ShortcutTooltip } from "@/components/shared/shortcut-tooltip";
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";
import { SHORTCUT_KEY } from "@/lib/keyboard-shortcuts";
import { useIsMac } from "@/lib/use-is-mac";
import { useShortcut } from "@/lib/use-shortcut";
import { cn } from "@/lib/utils";

function CopyableFilename({
Expand Down Expand Up @@ -71,6 +75,7 @@ export function FileHeader({
}: FileHeaderProps) {
const isMac = useIsMac();
const altLabel = isMac ? "⌥" : "Alt";
const { label: collapseShortcutLabel } = useShortcut(SHORTCUT_KEY.TOGGLE_FILE_COLLAPSED);

const copyPath = useCallback(
(path: string, label: string) => {
Expand Down Expand Up @@ -146,7 +151,10 @@ export function FileHeader({
</button>
</TooltipTrigger>
<TooltipContent side="top" className="text-center">
<p>{isCollapsed ? "Expand file" : "Collapse file"}</p>
<p className="flex items-center justify-center gap-1">
{isCollapsed ? "Expand file" : "Collapse file"}
<ShortcutLabel label={collapseShortcutLabel} />
</p>
<p className="text-muted-foreground">
{altLabel}-click to {isCollapsed ? "expand" : "collapse"} all files
</p>
Expand Down Expand Up @@ -213,27 +221,26 @@ export function FileHeader({
className="relative z-10 shrink-0"
/>
{onToggleViewed && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onToggleViewed();
}}
className={cn(
"relative z-10 flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md transition-colors hover:bg-accent",
isViewed
? "text-green-600 hover:text-green-700 dark:text-green-500 dark:hover:text-green-400"
: "text-muted-foreground hover:text-foreground",
)}
aria-label={isViewed ? "Mark file as unviewed" : "Mark file as viewed"}
>
{isViewed ? <CircleCheck className="size-3.5" /> : <Circle className="size-3.5" />}
</button>
</TooltipTrigger>
<TooltipContent>{isViewed ? "Mark as unviewed" : "Mark as viewed"}</TooltipContent>
</Tooltip>
<ShortcutTooltip
shortcutKey={SHORTCUT_KEY.MARK_FILE_AS_VIEWED}
label={isViewed ? "Mark as unviewed" : "Mark as viewed"}
>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onToggleViewed();
}}
className={cn(
"relative z-10 flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-md transition-colors hover:bg-accent",
isViewed
? "text-green-600 hover:text-green-700 dark:text-green-500 dark:hover:text-green-400"
: "text-muted-foreground hover:text-foreground",
)}
>
{isViewed ? <CircleCheck className="size-3.5" /> : <Circle className="size-3.5" />}
</button>
</ShortcutTooltip>
)}
{handleCommentClick && (
<Tooltip>
Expand Down
Loading
Loading