fix(project-tree): scroll horizontally when rows are wider than the pane (#495)

Project view rows were locked to the pane width, so deeply nested files and long names were clipped with no way to read the rest. Rows now size to their content and the view scrolls horizontally.

Rows are virtualised, so the scroll range keeps the widest row until the rows change. Otherwise scrolling past a wide row would remove the scrollbar and reset the horizontal position. Other trees opt out and keep clipping long labels.

Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com>
This commit is contained in:
arookieofc
2026-10-31 01:55:33 +08:00
committed by GitHub
co-authored by a-rookie-of-C-language
parent 8fabf054ef
commit 98c8c55b9e
4 changed files with 64 additions and 0 deletions
@@ -645,6 +645,7 @@ export function ProjectView() {
</div>
)}
<Tree<ProjectNode>
horizontalScroll
aria-label="Project"
handleRef={setTreeHandle}
roots={model.roots}
+12
View File
@@ -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));
}
+25
View File
@@ -214,3 +214,28 @@ describe("siblingPositions", () => {
]);
});
});
describe("Tree horizontal scrolling", () => {
const spacer = (tree: HTMLElement) => tree.querySelector<HTMLElement>("[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();
});
});
+26
View File
@@ -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<T> {
"aria-label"?: string;
handleRef?: Ref<TreeHandle>;
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<T>;
}
/** Widest row seen since `rows` last changed; rows are virtualised, so the scroll range must outlive them. */
function useContentWidth(scroller: RefObject<HTMLDivElement | null>, rows: unknown, enabled: boolean): number {
const [width, setWidth] = useState(0);
const measuredRows = useRef(rows);
useLayoutEffect(() => {
if (!enabled) return;
let widest = 0;
scroller.current?.querySelectorAll<HTMLElement>("[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<T> {
node: T;
key: string;
@@ -216,6 +237,7 @@ export function Tree<T>(props: TreeProps<T>) {
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<T>(props: TreeProps<T>) {
}
>
<div className={styles.sizer} style={{ height: virtualizer.getTotalSize() }}>
{props.horizontalScroll && (
<div aria-hidden data-content-width className={styles.contentWidth} style={{ width: contentWidth }} />
)}
{virtualizer.getVirtualItems().map((v) => {
const row = rows[v.index]!;
const pad = 4 + row.depth * indent;
@@ -546,6 +571,7 @@ export function Tree<T>(props: TreeProps<T>) {
aria-selected={isSelected}
className={clsx(
styles.row,
props.horizontalScroll && styles.wideRow,
rowClassName?.(row.node),
isSelected && styles.selected,
isLead && styles.lead,