(null);
const getViewportWidth = () => (typeof window !== "undefined" ? window.innerWidth : 1280);
@@ -83,6 +88,15 @@ export function ResizablePane({
return () => window.removeEventListener("resize", handleWindowResize);
}, [widthKey, clampWidth]);
+ useLayoutEffect(() => {
+ const overlay = resizeHandleRef.current;
+ if (!overlay) return;
+
+ return bindOverlayWheelToScrollContainer(overlay, () =>
+ querySidebarScrollContainer(contentRef.current),
+ );
+ }, [hidden]);
+
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
@@ -134,11 +148,12 @@ export function ResizablePane({
const totalWidth = hidden ? "0px" : `${width}px`;
const resizeHandle = !hidden ? (
) : null}
{leading ? : null}
{label !== undefined ? (
-
+
{label}
{description ? (
@@ -266,7 +266,7 @@ export const SidebarTreeRow = forwardRef
children
)}
{trailing ? (
-
+
{trailing}
) : null}
diff --git a/windows/tauri/src/ui/scroll-area.tsx b/windows/tauri/src/ui/scroll-area.tsx
index 501835190..429032ce1 100644
--- a/windows/tauri/src/ui/scroll-area.tsx
+++ b/windows/tauri/src/ui/scroll-area.tsx
@@ -1,5 +1,10 @@
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area";
+import { useLayoutEffect, useState } from "react";
import type * as React from "react";
+import {
+ bindOverlayWheelToScrollContainer,
+ bindScrollContainerWheel,
+} from "@/ui/scroll-container-wheel";
import { cn } from "@/utils/cn";
type ScrollAreaOrientation = "vertical" | "horizontal" | "both";
@@ -28,15 +33,37 @@ function ScrollArea({
...props
}: ScrollAreaProps) {
const { ref: viewportRef, style: viewportStyle, ...resolvedViewportProps } = viewportProps ?? {};
+ const [rootNode, setRootNode] = useState(null);
+ const [viewportNode, setViewportNode] = useState(null);
+
+ useLayoutEffect(() => {
+ if (!viewportNode) return;
+ return bindScrollContainerWheel(viewportNode);
+ }, [viewportNode]);
+
+ useLayoutEffect(() => {
+ if (!rootNode) return;
+ return bindOverlayWheelToScrollContainer(rootNode, () => viewportNode);
+ }, [rootNode, viewportNode]);
+
+ const setViewportRef = (node: HTMLDivElement | null) => {
+ setViewportNode(node);
+ if (typeof viewportRef === "function") {
+ viewportRef(node);
+ } else if (viewportRef) {
+ viewportRef.current = node;
+ }
+ };
return (
{
+ test("treats pixel wheel deltas as pixels", () => {
+ expect(
+ getWheelDeltaPixels(
+ { deltaX: 0, deltaY: 120, deltaMode: 0 },
+ { lineHeight: 16, pageWidth: 240, pageHeight: 400 },
+ ),
+ ).toEqual({ x: 0, y: 120 });
+ });
+
+ test("converts line and page wheel deltas to pixels", () => {
+ expect(
+ getWheelDeltaPixels(
+ { deltaX: 0, deltaY: 3, deltaMode: 1 },
+ { lineHeight: 20, pageWidth: 240, pageHeight: 400 },
+ ),
+ ).toEqual({ x: 0, y: 60 });
+ expect(
+ getWheelDeltaPixels(
+ { deltaX: 0, deltaY: 1, deltaMode: 2 },
+ { lineHeight: 20, pageWidth: 240, pageHeight: 400 },
+ ),
+ ).toEqual({ x: 0, y: 400 });
+ });
+
+ test("keeps horizontal project-switch gestures from capturing vertical scroll", () => {
+ expect(isMostlyVerticalWheel(40, 8)).toBe(false);
+ expect(isMostlyVerticalWheel(8, 40)).toBe(true);
+ });
+
+ test("moves a nested overflow container that Chromium would otherwise latch onto a row", () => {
+ const element = {
+ scrollTop: 0,
+ scrollHeight: 800,
+ clientHeight: 200,
+ };
+
+ expect(applyVerticalWheelToScrollContainer(element, 80)).toBe(true);
+ expect(element.scrollTop).toBe(80);
+ });
+
+ test("does not consume wheel events at the scroll boundary", () => {
+ const element = {
+ scrollTop: 0,
+ scrollHeight: 800,
+ clientHeight: 200,
+ };
+
+ expect(applyVerticalWheelToScrollContainer(element, -40)).toBe(false);
+ expect(element.scrollTop).toBe(0);
+
+ element.scrollTop = 600;
+ expect(applyVerticalWheelToScrollContainer(element, 40)).toBe(false);
+ expect(element.scrollTop).toBe(600);
+ });
+});
diff --git a/windows/tauri/src/ui/scroll-container-wheel.ts b/windows/tauri/src/ui/scroll-container-wheel.ts
new file mode 100644
index 000000000..be5fe2d07
--- /dev/null
+++ b/windows/tauri/src/ui/scroll-container-wheel.ts
@@ -0,0 +1,114 @@
+const DOM_DELTA_LINE = 1;
+const DOM_DELTA_PAGE = 2;
+
+// Chromium/WebView2 can latch wheel events onto overflow:hidden tree rows
+// instead of the sidebar scroller. Apply the delta to the real container.
+
+export const SIDEBAR_SCROLL_CONTAINER_SELECTOR =
+ "[data-slot='scroll-area-viewport'], [data-scroll-container], .file-tree-container";
+
+interface WheelDeltaEvent {
+ deltaX: number;
+ deltaY: number;
+ deltaMode: number;
+}
+
+interface WheelDeltaMetrics {
+ lineHeight: number;
+ pageWidth: number;
+ pageHeight: number;
+}
+
+interface VerticalScrollContainer {
+ scrollTop: number;
+ scrollHeight: number;
+ clientHeight: number;
+}
+
+export function isMostlyVerticalWheel(deltaX: number, deltaY: number) {
+ return Math.abs(deltaY) >= Math.abs(deltaX);
+}
+
+export function getWheelDeltaPixels(event: WheelDeltaEvent, metrics: WheelDeltaMetrics) {
+ if (event.deltaMode === DOM_DELTA_LINE) {
+ return {
+ x: event.deltaX * metrics.lineHeight,
+ y: event.deltaY * metrics.lineHeight,
+ };
+ }
+
+ if (event.deltaMode === DOM_DELTA_PAGE) {
+ return {
+ x: event.deltaX * metrics.pageWidth,
+ y: event.deltaY * metrics.pageHeight,
+ };
+ }
+
+ return { x: event.deltaX, y: event.deltaY };
+}
+
+export function applyVerticalWheelToScrollContainer(
+ element: VerticalScrollContainer,
+ deltaY: number,
+) {
+ if (deltaY === 0) return false;
+
+ const maxScrollTop = Math.max(0, element.scrollHeight - element.clientHeight);
+ const nextScrollTop = Math.max(0, Math.min(maxScrollTop, element.scrollTop + deltaY));
+ if (nextScrollTop === element.scrollTop) return false;
+
+ element.scrollTop = nextScrollTop;
+ return true;
+}
+
+function getLineHeight(element: HTMLElement) {
+ const lineHeight = Number.parseFloat(getComputedStyle(element).lineHeight);
+ return Number.isFinite(lineHeight) && lineHeight > 0 ? lineHeight : 16;
+}
+
+function applyVerticalWheelEvent(element: HTMLElement, event: WheelEvent) {
+ if (event.ctrlKey || event.metaKey || event.defaultPrevented) return false;
+ if (!isMostlyVerticalWheel(event.deltaX, event.deltaY)) return false;
+
+ const delta = getWheelDeltaPixels(event, {
+ lineHeight: getLineHeight(element),
+ pageWidth: element.clientWidth,
+ pageHeight: element.clientHeight,
+ });
+
+ return applyVerticalWheelToScrollContainer(element, delta.y);
+}
+
+export function bindScrollContainerWheel(element: HTMLElement) {
+ const onWheel = (event: WheelEvent) => {
+ if (!applyVerticalWheelEvent(element, event)) return;
+ event.preventDefault();
+ };
+
+ element.addEventListener("wheel", onWheel, { capture: true, passive: false });
+ return () => {
+ element.removeEventListener("wheel", onWheel, { capture: true });
+ };
+}
+
+export function bindOverlayWheelToScrollContainer(
+ overlay: HTMLElement,
+ getScrollContainer: () => HTMLElement | null,
+) {
+ const onWheel = (event: WheelEvent) => {
+ const element = getScrollContainer();
+ if (!element) return;
+ if (!applyVerticalWheelEvent(element, event)) return;
+ event.preventDefault();
+ };
+
+ overlay.addEventListener("wheel", onWheel, { passive: false });
+ return () => {
+ overlay.removeEventListener("wheel", onWheel);
+ };
+}
+
+export function querySidebarScrollContainer(root: ParentNode | null) {
+ if (!root) return null;
+ return root.querySelector(SIDEBAR_SCROLL_CONTAINER_SELECTOR);
+}