diff --git a/src/features/project-tree/ProjectView.tsx b/src/features/project-tree/ProjectView.tsx index 2bf0b703..d9e2e8a8 100644 --- a/src/features/project-tree/ProjectView.tsx +++ b/src/features/project-tree/ProjectView.tsx @@ -645,6 +645,7 @@ export function ProjectView() { )} + horizontalScroll aria-label="Project" handleRef={setTreeHandle} roots={model.roots} diff --git a/src/ui/Tree.module.css b/src/ui/Tree.module.css index 3421e8ac..1ea24b8f 100644 --- a/src/ui/Tree.module.css +++ b/src/ui/Tree.module.css @@ -53,6 +53,18 @@ color: var(--fg-strong); white-space: nowrap; } +/* At least the visible width so the selection covers it; wider rows overflow the sizer. */ +.wideRow { + width: max-content; + min-width: 100%; +} +.contentWidth { + position: absolute; + top: 0; + left: 0; + height: 1px; + pointer-events: none; +} .row:hover { background: var(--bg-tree-hover, color-mix(in srgb, var(--fg-strong) 4%, transparent)); } diff --git a/src/ui/Tree.test.tsx b/src/ui/Tree.test.tsx index bbfca7a4..3f271398 100644 --- a/src/ui/Tree.test.tsx +++ b/src/ui/Tree.test.tsx @@ -214,3 +214,28 @@ describe("siblingPositions", () => { ]); }); }); + +describe("Tree horizontal scrolling", () => { + const spacer = (tree: HTMLElement) => tree.querySelector("[data-content-width]"); + + it("is off unless asked for", () => { + const { tree } = setup(); + expect(spacer(tree)).toBeNull(); + }); + + it("keeps the scroll range while scrolling and re-measures when the rows change", () => { + const width = vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockReturnValue(640); + const { tree, key } = setup({ horizontalScroll: true, defaultExpanded: ["src"] }); + expect(spacer(tree)?.style.width).toBe("640px"); + + // The wide row left the rendered window: the same rows, measured narrower. + width.mockReturnValue(100); + key("ArrowDown"); + expect(spacer(tree)?.style.width).toBe("640px"); + + key("Home"); + key("ArrowLeft"); + expect(spacer(tree)?.style.width).toBe("100px"); + width.mockRestore(); + }); +}); diff --git a/src/ui/Tree.tsx b/src/ui/Tree.tsx index a0a7ec67..cf5de5fe 100644 --- a/src/ui/Tree.tsx +++ b/src/ui/Tree.tsx @@ -11,6 +11,7 @@ import { useEffect, useId, useImperativeHandle, + useLayoutEffect, useMemo, useRef, useState, @@ -18,6 +19,7 @@ import { type MouseEvent, type ReactNode, type Ref, + type RefObject, } from "react"; import clsx from "clsx"; import { useVirtualizer } from "@tanstack/react-virtual"; @@ -109,10 +111,29 @@ export interface TreeProps { "aria-label"?: string; handleRef?: Ref; empty?: ReactNode; + /** Rows wider than the pane scroll horizontally instead of clipping. */ + horizontalScroll?: boolean; /** Optional display reordering between siblings; the caller owns the order. */ reorder?: TreeReorder; } +/** Widest row seen since `rows` last changed; rows are virtualised, so the scroll range must outlive them. */ +function useContentWidth(scroller: RefObject, rows: unknown, enabled: boolean): number { + const [width, setWidth] = useState(0); + const measuredRows = useRef(rows); + useLayoutEffect(() => { + if (!enabled) return; + let widest = 0; + scroller.current?.querySelectorAll("[data-row]").forEach((row) => { + widest = Math.max(widest, row.offsetWidth); + }); + const kept = measuredRows.current === rows; + measuredRows.current = rows; + setWidth((current) => (kept ? Math.max(current, widest) : widest)); + }); + return width; +} + interface Row { node: T; key: string; @@ -216,6 +237,7 @@ export function Tree(props: TreeProps) { const byKey = useMemo(() => new Map(allRows.map((r) => [r.key, r])), [allRows]); const emptyState = !rows.length && !!empty && !query; + const contentWidth = useContentWidth(scrollRef, rows, !!props.horizontalScroll); const reordering = useTreeReorder(scrollRef, rows, emptyState ? undefined : props.reorder); // aria-posinset / aria-setsize among visible siblings (rows are virtualised). const positions = useMemo(() => siblingPositions(rows), [rows]); @@ -503,6 +525,9 @@ export function Tree(props: TreeProps) { } >
+ {props.horizontalScroll && ( +
+ )} {virtualizer.getVirtualItems().map((v) => { const row = rows[v.index]!; const pad = 4 + row.depth * indent; @@ -546,6 +571,7 @@ export function Tree(props: TreeProps) { aria-selected={isSelected} className={clsx( styles.row, + props.horizontalScroll && styles.wideRow, rowClassName?.(row.node), isSelected && styles.selected, isLead && styles.lead,