From f02bf4973fbe5cf132472879f105d5928f735865 Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Mon, 1 Jun 2026 21:40:18 -0700 Subject: [PATCH] feat(web): vendor shared file tree + resizable picker from monorepo Syncs the CLI's file picker / chapter sidebar with the monorepo overhauls that landed after the fork: - #980: extract a single shared FileTree (+ FileFilterInput) rendered by both the Files-changed picker and the chapter detail sidebar. Replaces the chapter sidebar's flat FileViewRow list and the file picker's own inlined tree reimplementation. - #984: order the collapsed picker indicators by file-tree order rather than raw API order. - #985: add a shared useResizablePanel hook + chapter-panel-constants; CollapsiblePicker gains an opt-in resize handle and the chapter side panel drops its hand-rolled drag logic for the hook. Adaptations for the CLI: imports rewritten to @/lib/*; FILE_VIEWED_STATE moved into lib/diff-types; comment counts are absent so callers pass an empty map; CollapsiblePicker drops the command-board event the CLI lacks. file-view-row.tsx is deleted (its last consumer, the chapters index page, now renders a small local FilePathRow). Typecheck, lint, test (299), and build all pass. --- .../components/chapter/chapter-file-list.tsx | 62 ++-- .../chapter/chapter-panel-constants.ts | 12 + .../components/chapter/chapter-side-panel.tsx | 82 ++---- .../src/components/chapter/file-view-row.tsx | 116 -------- packages/web/src/components/chapter/index.ts | 1 - .../components/files/collapsible-picker.tsx | 102 +++++-- .../components/files/file-filter-input.tsx | 26 ++ .../web/src/components/files/file-picker.tsx | 241 +++------------- .../web/src/components/files/file-tree.tsx | 265 ++++++++++++++++++ packages/web/src/components/files/index.ts | 2 + packages/web/src/lib/diff-types.ts | 7 + packages/web/src/lib/use-resizable-panel.ts | 228 +++++++++++++++ .../web/src/routes/chapter-detail-page.tsx | 3 +- .../web/src/routes/chapters-index-page.tsx | 20 +- packages/web/src/routes/files-page.tsx | 35 ++- 15 files changed, 752 insertions(+), 450 deletions(-) create mode 100644 packages/web/src/components/chapter/chapter-panel-constants.ts delete mode 100644 packages/web/src/components/chapter/file-view-row.tsx create mode 100644 packages/web/src/components/files/file-filter-input.tsx create mode 100644 packages/web/src/components/files/file-tree.tsx create mode 100644 packages/web/src/lib/use-resizable-panel.ts diff --git a/packages/web/src/components/chapter/chapter-file-list.tsx b/packages/web/src/components/chapter/chapter-file-list.tsx index dafbfb3..1118033 100644 --- a/packages/web/src/components/chapter/chapter-file-list.tsx +++ b/packages/web/src/components/chapter/chapter-file-list.tsx @@ -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 = new Map(); interface ChapterFileListProps { - entries: FileDiffEntry[]; + files: PullRequestFile[]; + focusedFilePath?: string; viewedPathSet: ReadonlySet; 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( + () => ({ + 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 (

- Files ({entries.length}) + Files ({files.length})

-
- {entries.map(({ file }) => { - const viewedState: FileViewedState = viewedPathSet.has(file.path) - ? FILE_VIEWED_STATE.VIEWED - : FILE_VIEWED_STATE.UNVIEWED; - return ( - - ); - })} -
+ +
); } diff --git a/packages/web/src/components/chapter/chapter-panel-constants.ts b/packages/web/src/components/chapter/chapter-panel-constants.ts new file mode 100644 index 0000000..e2e0e29 --- /dev/null +++ b/packages/web/src/components/chapter/chapter-panel-constants.ts @@ -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) => + Math.round(viewportWidth * 0.3); + +export const resolveChapterPanelMaxWidth = (viewportWidth: number) => + Math.round(viewportWidth * CHAPTER_PANEL_MAX_WIDTH_FRACTION); diff --git a/packages/web/src/components/chapter/chapter-side-panel.tsx b/packages/web/src/components/chapter/chapter-side-panel.tsx index 5bef072..9278f15 100644 --- a/packages/web/src/components/chapter/chapter-side-panel.tsx +++ b/packages/web/src/components/chapter/chapter-side-panel.tsx @@ -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; checkedKeyChangeIds: ReadonlySet; viewedFilePathSet: ReadonlySet; @@ -32,7 +34,8 @@ interface ChapterSidePanelProps { export function ChapterSidePanel({ chapter, chapterIndex, - chapterEntries, + files, + focusedFilePath, viewedChapterIds, checkedKeyChangeIds, viewedFilePathSet, @@ -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 (
- {/* biome-ignore lint/a11y/noStaticElementInteractions: resize handle is a drag target, not an interactive widget */}
diff --git a/packages/web/src/components/chapter/file-view-row.tsx b/packages/web/src/components/chapter/file-view-row.tsx deleted file mode 100644 index b32aab9..0000000 --- a/packages/web/src/components/chapter/file-view-row.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { Circle, CircleCheck, MessageSquare } from "lucide-react"; -import type { MouseEvent } from "react"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import type { FileStatus } from "@/lib/diff-types"; -import { FILE_STATUS_ICONS, FILE_STATUS_TEXT_COLORS } from "@/lib/file-status"; -import { cn } from "@/lib/utils"; - -export const FILE_VIEWED_STATE = { - DISMISSED: "DISMISSED", - UNVIEWED: "UNVIEWED", - VIEWED: "VIEWED", -} as const; -export type FileViewedState = (typeof FILE_VIEWED_STATE)[keyof typeof FILE_VIEWED_STATE]; - -interface FileViewRowProps { - filePath: string; - // Optional in stage-cli: the chapters API returns hunkRefs (filePath + oldStart) but no - // file-status metadata. The leading status icon is omitted when status is absent so we - // don't render a misleading "modified" placeholder. The forthcoming /api/diff.patch - // route will let callers pass the real status. - status?: FileStatus; - viewedState?: FileViewedState; - commentCount?: number; - onToggleViewed?: (filePath: string) => void; - onSelect?: (filePath: string) => void; -} - -export function FileViewRow({ - filePath, - status, - viewedState = FILE_VIEWED_STATE.UNVIEWED, - commentCount = 0, - onToggleViewed, - onSelect, -}: FileViewRowProps) { - const Icon = status ? FILE_STATUS_ICONS[status] : null; - const iconColorClass = status ? FILE_STATUS_TEXT_COLORS[status] : ""; - const isViewed = viewedState === FILE_VIEWED_STATE.VIEWED; - - const handleToggleViewed = onToggleViewed - ? (e: MouseEvent) => { - e.stopPropagation(); - onToggleViewed(filePath); - } - : undefined; - - const lastSlashIndex = filePath.lastIndexOf("/"); - const directory = lastSlashIndex === -1 ? null : filePath.slice(0, lastSlashIndex + 1); - const displayFilename = lastSlashIndex === -1 ? filePath : filePath.slice(lastSlashIndex + 1); - - const commentBadge = commentCount > 0 && ( - - - {commentCount} - - ); - - const fileContent = ( - - {Icon && } - - {directory && ( - {directory} - )} - {displayFilename} - - {commentBadge} - - ); - - return ( -
- {handleToggleViewed && ( - - - - - - {isViewed ? "Mark as unviewed" : "Mark as viewed"} - - - )} - {onSelect ? ( - - - - - -

{filePath}

-
-
- ) : ( -
{fileContent}
- )} -
- ); -} diff --git a/packages/web/src/components/chapter/index.ts b/packages/web/src/components/chapter/index.ts index ae8b6fb..81d077c 100644 --- a/packages/web/src/components/chapter/index.ts +++ b/packages/web/src/components/chapter/index.ts @@ -3,7 +3,6 @@ export { ChapterNavigator } from "./chapter-navigator"; export { ChapterSidePanel } from "./chapter-side-panel"; export { ChapterSummary } from "./chapter-summary"; export { FileHeader } from "./file-header"; -export { FILE_VIEWED_STATE, type FileViewedState, FileViewRow } from "./file-view-row"; export { findKeyChangeIdAtPoint, isPointInReviewStateBadge, diff --git a/packages/web/src/components/files/collapsible-picker.tsx b/packages/web/src/components/files/collapsible-picker.tsx index a603851..9a05f3d 100644 --- a/packages/web/src/components/files/collapsible-picker.tsx +++ b/packages/web/src/components/files/collapsible-picker.tsx @@ -1,47 +1,74 @@ import type { LucideIcon } from "lucide-react"; import { PanelLeftClose, PanelLeftOpen } from "lucide-react"; -import { type ReactNode, useCallback, useEffect } from "react"; +import { type ReactNode, useCallback, useEffect, useState } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; +import { ShortcutTooltip } from "@/components/shared/shortcut-tooltip"; +import { KEYBOARD_SHORTCUTS, type ShortcutKey } from "@/lib/keyboard-shortcuts"; +import { useResizablePanel } from "@/lib/use-resizable-panel"; import { cn } from "@/lib/utils"; +/** Fixed width (px) used for the expanded panel when resizing is disabled. */ +const FIXED_PANEL_WIDTH = 256; + +interface ResizeConfig { + minWidth: number; + defaultWidth: number; + maxWidth: number; +} + interface CollapsiblePickerProps { icon: LucideIcon; title: string; count: number; + shortcutKey: ShortcutKey; collapsedIndicators: ReactNode; headerExtra?: ReactNode; children: ReactNode; className?: string; zIndex?: number; - isCollapsed: boolean; - onCollapsedChange: (collapsed: boolean) => void; + defaultExpanded?: boolean; + /** When provided, the expanded panel can be dragged to resize within these bounds. */ + resize?: ResizeConfig; } export function CollapsiblePicker({ icon: Icon, title, count, + shortcutKey, collapsedIndicators, headerExtra, children, className, zIndex = 30, - isCollapsed, - onCollapsedChange, + defaultExpanded = true, + resize, }: CollapsiblePickerProps) { + const [isCollapsed, setIsCollapsed] = useState(!defaultExpanded); + const { hotkey } = KEYBOARD_SHORTCUTS[shortcutKey]; + + const { width, panelRef, resizeHandleProps } = useResizablePanel({ + minWidth: resize?.minWidth ?? FIXED_PANEL_WIDTH, + maxWidth: resize?.maxWidth ?? FIXED_PANEL_WIDTH, + defaultWidth: resize?.defaultWidth ?? FIXED_PANEL_WIDTH, + }); + + // Auto-collapse when viewport is too narrow for the expanded panel useEffect(() => { const mql = window.matchMedia("(max-width: 768px)"); const handleChange = (e: MediaQueryListEvent | MediaQueryList) => { - if (e.matches) onCollapsedChange(true); + if (e.matches) setIsCollapsed(true); }; handleChange(mql); mql.addEventListener("change", handleChange); return () => mql.removeEventListener("change", handleChange); - }, [onCollapsedChange]); + }, []); - const toggleCollapsed = useCallback( - () => onCollapsedChange(!isCollapsed), - [isCollapsed, onCollapsedChange], - ); + const toggleCollapsed = useCallback(() => setIsCollapsed((prev) => !prev), []); + + useHotkeys(hotkey, toggleCollapsed, { preventDefault: true, enableOnFormTags: false }, [ + toggleCollapsed, + ]); const header = ( {headerExtra}
@@ -85,6 +116,7 @@ export function CollapsiblePicker({ )} style={{ zIndex }} > + {/* Collapsed strip */} - {/* Clip wrapper hides the slid-left panel until the strip is hovered. */} + {/* Hover overlay — clip wrapper hides the panel when slid left. Match + the resizable width so the preview doesn't jump when expanded. */}