Skip to content
Merged
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: 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