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
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
getFileTreeVirtualRange,
type FileTreeScrollAlignment,
} from "@/features/file-explorer/lib/file-tree-viewport";
import { bindScrollContainerWheel } from "@/ui/scroll-container-wheel";
import { cn } from "@/utils/cn";

export interface FileExplorerViewportHandle {
Expand Down Expand Up @@ -83,11 +84,13 @@ export const FileExplorerViewport = forwardRef<
const resizeObserver = new ResizeObserver(updateLayout);
resizeObserver.observe(element);
element.addEventListener("scroll", scheduleLayoutUpdate, { passive: true });
const unbindWheel = bindScrollContainerWheel(element);
updateLayout();

return () => {
resizeObserver.disconnect();
element.removeEventListener("scroll", scheduleLayoutUpdate);
unbindWheel();
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
Expand Down Expand Up @@ -166,6 +169,7 @@ export const FileExplorerViewport = forwardRef<
return (
<div
ref={scrollRef}
data-scroll-container=""
className={cn("file-tree-container", className)}
style={
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
width: 100% !important;
min-width: 0 !important;
height: 100%;
overflow: hidden;
overflow: clip;
display: flex !important;
justify-content: flex-start !important;
}
Expand Down Expand Up @@ -73,13 +73,13 @@
height: 100%;
max-width: 100%;
min-width: 0;
overflow: hidden;
overflow: clip;
}

.file-tree-container .file-tree-row > span:last-child {
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
overflow: clip;
text-overflow: ellipsis;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export const GitFileItem = ({
return (
<SidebarTreeRow
depth={indentLevel}
className={cn("group overflow-hidden", className)}
className={cn("group overflow-clip", className)}
onClick={onClick}
onContextMenu={onContextMenu}
reserveDisclosureSpace={reserveDisclosureSpace}
Expand All @@ -65,7 +65,7 @@ export const GitFileItem = ({
hasDiffStats ? (
<div
className={cn(
"flex max-w-19 shrink-0 items-center justify-end overflow-hidden leading-row tabular-nums",
"flex max-w-19 shrink-0 items-center justify-end overflow-clip leading-row tabular-nums",
compactGitStatusBadges ? "ui-text-sm gap-0.5" : "ui-text-sm gap-1",
)}
>
Expand Down
21 changes: 18 additions & 3 deletions windows/tauri/src/features/layout/components/resizable-pane.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import type React from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { useSettingsStore } from "@/features/settings/stores/settings.store";
import {
bindOverlayWheelToScrollContainer,
querySidebarScrollContainer,
} from "@/ui/scroll-container-wheel";
import { cn } from "@/utils/cn";
import {
clampResponsivePaneWidth,
Expand Down Expand Up @@ -39,6 +43,7 @@ export function ResizablePane({
const [isResizing, setIsResizing] = useState(false);
const paneRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const resizeHandleRef = useRef<HTMLDivElement>(null);

const getViewportWidth = () => (typeof window !== "undefined" ? window.innerWidth : 1280);

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -134,11 +148,12 @@ export function ResizablePane({
const totalWidth = hidden ? "0px" : `${width}px`;
const resizeHandle = !hidden ? (
<div
ref={resizeHandleRef}
onMouseDown={handleMouseDown}
style={
position === "left"
? { right: "calc(var(--lithe-workbench-gap) / -2)" }
: { left: "calc(var(--lithe-workbench-gap) / -2)" }
? { right: "calc(var(--lithe-workbench-gap) * -1)" }
: { left: "calc(var(--lithe-workbench-gap) * -1)" }
}
className={cn(
"group absolute top-0 z-30 flex h-full w-(--lithe-workbench-gap) cursor-col-resize items-center justify-center",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ export const SidebarTreeRow = forwardRef<HTMLButtonElement, SidebarTreeRowProps>
) : null}
{leading ? <SidebarTreeIcon icon={leading} /> : null}
{label !== undefined ? (
<span className="relative z-1 flex min-w-0 flex-1 items-baseline gap-1.5 overflow-hidden">
<span className="relative z-1 flex min-w-0 flex-1 items-baseline gap-1.5 overflow-clip">
<span className="min-w-0 truncate">{label}</span>
{description ? (
<span className="min-w-0 flex-1 truncate text-subtle-foreground/80">
Expand All @@ -266,7 +266,7 @@ export const SidebarTreeRow = forwardRef<HTMLButtonElement, SidebarTreeRowProps>
children
)}
{trailing ? (
<span className="relative z-1 ml-auto flex min-w-0 shrink items-center overflow-hidden">
<span className="relative z-1 ml-auto flex min-w-0 shrink items-center overflow-clip">
{trailing}
</span>
) : null}
Expand Down
31 changes: 29 additions & 2 deletions windows/tauri/src/ui/scroll-area.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -28,15 +33,37 @@ function ScrollArea({
...props
}: ScrollAreaProps) {
const { ref: viewportRef, style: viewportStyle, ...resolvedViewportProps } = viewportProps ?? {};
const [rootNode, setRootNode] = useState<HTMLDivElement | null>(null);
const [viewportNode, setViewportNode] = useState<HTMLDivElement | null>(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 (
<ScrollAreaPrimitive.Root
{...props}
ref={setRootNode}
data-slot="scroll-area"
className={cn("group/scroll-area relative min-h-0 overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
ref={viewportRef}
ref={setViewportRef}
data-slot="scroll-area-viewport"
className={cn(
"size-full min-h-0 rounded-[inherit] outline-none focus-visible:ring-2 focus-visible:ring-primary/20",
Expand Down
63 changes: 63 additions & 0 deletions windows/tauri/src/ui/scroll-container-wheel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, expect, test } from "bun:test";
import {
applyVerticalWheelToScrollContainer,
getWheelDeltaPixels,
isMostlyVerticalWheel,
} from "./scroll-container-wheel";

describe("scroll container wheel", () => {
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);
});
});
114 changes: 114 additions & 0 deletions windows/tauri/src/ui/scroll-container-wheel.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>(SIDEBAR_SCROLL_CONTAINER_SELECTOR);
}
Loading