소스 검색

wip(app): custom scroll view

Adam 1 개월 전
부모
커밋
fe89bedfcc

+ 1 - 1
packages/app/e2e/actions.ts

@@ -225,7 +225,7 @@ export async function hoverSessionItem(page: Page, sessionID: string) {
 export async function openSessionMoreMenu(page: Page, sessionID: string) {
   await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`))
 
-  const scroller = page.locator(".session-scroller").first()
+  const scroller = page.locator(".scroll-view__viewport").first()
   await expect(scroller).toBeVisible()
   await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
 

+ 1 - 1
packages/app/e2e/session/session.spec.ts

@@ -44,7 +44,7 @@ test("session can be renamed via header menu", async ({ page, sdk, gotoSession }
     const menu = await openSessionMoreMenu(page, session.id)
     await clickMenuItem(menu, /rename/i)
 
-    const input = page.locator(".session-scroller").locator(inlineInputSelector).first()
+    const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first()
     await expect(input).toBeVisible()
     await expect(input).toBeFocused()
     await input.fill(renamedTitle)

+ 5 - 4
packages/app/src/components/session/session-context-tab.tsx

@@ -11,6 +11,7 @@ import { Accordion } from "@opencode-ai/ui/accordion"
 import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
 import { Code } from "@opencode-ai/ui/code"
 import { Markdown } from "@opencode-ai/ui/markdown"
+import { ScrollView } from "@opencode-ai/ui/scroll-view"
 import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
 import { useLanguage } from "@/context/language"
 import { getSessionContextMetrics } from "./session-context-metrics"
@@ -268,9 +269,9 @@ export function SessionContextTab() {
   })
 
   return (
-    <div
-      class="@container h-full overflow-y-auto no-scrollbar pb-10"
-      ref={(el) => {
+    <ScrollView
+      class="@container h-full pb-10"
+      viewportRef={(el) => {
         scroll = el
         restoreScroll()
       }}
@@ -336,6 +337,6 @@ export function SessionContextTab() {
           </Accordion>
         </div>
       </div>
-    </div>
+    </ScrollView>
   )
 }

+ 2 - 2
packages/app/src/pages/session/composer/session-question-dock.tsx

@@ -62,7 +62,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
   const measure = () => {
     if (!root) return
 
-    const scroller = document.querySelector(".session-scroller")
+    const scroller = document.querySelector(".scroll-view__viewport")
     const head = scroller instanceof HTMLElement ? scroller.firstElementChild : undefined
     const top =
       head instanceof HTMLElement && head.classList.contains("sticky") ? head.getBoundingClientRect().bottom : 0
@@ -95,7 +95,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
     window.addEventListener("resize", update)
 
     const dock = root?.closest('[data-component="session-prompt-dock"]')
-    const scroller = document.querySelector(".session-scroller")
+    const scroller = document.querySelector(".scroll-view__viewport")
     const observer = new ResizeObserver(update)
     if (dock instanceof HTMLElement) observer.observe(dock)
     if (scroller instanceof HTMLElement) observer.observe(scroller)

+ 45 - 43
packages/app/src/pages/session/file-tabs.tsx

@@ -9,6 +9,7 @@ import { showToast } from "@opencode-ai/ui/toast"
 import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment"
 import { Mark } from "@opencode-ai/ui/logo"
 import { Tabs } from "@opencode-ai/ui/tabs"
+import { ScrollView } from "@opencode-ai/ui/scroll-view"
 import { useLayout } from "@/context/layout"
 import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
 import { useComments } from "@/context/comments"
@@ -509,51 +510,52 @@ export function FileTabContent(props: { tab: string }) {
   )
 
   return (
-    <Tabs.Content
-      value={props.tab}
-      class="mt-3 relative"
-      ref={(el: HTMLDivElement) => {
-        scroll = el
-        restoreScroll()
-      }}
-      onScroll={handleScroll}
-    >
-      <Switch>
-        <Match when={state()?.loaded && isImage()}>
-          <div class="px-6 py-4 pb-40">
-            <img
-              src={imageDataUrl()}
-              alt={path()}
-              class="max-w-full"
-              onLoad={() => requestAnimationFrame(restoreScroll)}
-            />
-          </div>
-        </Match>
-        <Match when={state()?.loaded && isSvg()}>
-          <div class="flex flex-col gap-4 px-6 py-4">
-            {renderCode(svgContent() ?? "", "")}
-            <Show when={svgPreviewUrl()}>
-              <div class="flex justify-center pb-40">
-                <img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
+    <Tabs.Content value={props.tab} class="mt-3 relative h-full">
+      <ScrollView
+        class="h-full"
+        viewportRef={(el: HTMLDivElement) => {
+          scroll = el
+          restoreScroll()
+        }}
+        onScroll={handleScroll as any}
+      >
+        <Switch>
+          <Match when={state()?.loaded && isImage()}>
+            <div class="px-6 py-4 pb-40">
+              <img
+                src={imageDataUrl()}
+                alt={path()}
+                class="max-w-full"
+                onLoad={() => requestAnimationFrame(restoreScroll)}
+              />
+            </div>
+          </Match>
+          <Match when={state()?.loaded && isSvg()}>
+            <div class="flex flex-col gap-4 px-6 py-4">
+              {renderCode(svgContent() ?? "", "")}
+              <Show when={svgPreviewUrl()}>
+                <div class="flex justify-center pb-40">
+                  <img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
+                </div>
+              </Show>
+            </div>
+          </Match>
+          <Match when={state()?.loaded && isBinary()}>
+            <div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
+              <Mark class="w-14 opacity-10" />
+              <div class="flex flex-col gap-2 max-w-md">
+                <div class="text-14-semibold text-text-strong truncate">{path()?.split("/").pop()}</div>
+                <div class="text-14-regular text-text-weak">{language.t("session.files.binaryContent")}</div>
               </div>
-            </Show>
-          </div>
-        </Match>
-        <Match when={state()?.loaded && isBinary()}>
-          <div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
-            <Mark class="w-14 opacity-10" />
-            <div class="flex flex-col gap-2 max-w-md">
-              <div class="text-14-semibold text-text-strong truncate">{path()?.split("/").pop()}</div>
-              <div class="text-14-regular text-text-weak">{language.t("session.files.binaryContent")}</div>
             </div>
-          </div>
-        </Match>
-        <Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match>
-        <Match when={state()?.loading}>
-          <div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div>
-        </Match>
-        <Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
-      </Switch>
+          </Match>
+          <Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match>
+          <Match when={state()?.loading}>
+            <div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div>
+          </Match>
+          <Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
+        </Switch>
+      </ScrollView>
     </Tabs.Content>
   )
 }

+ 5 - 4
packages/app/src/pages/session/message-timeline.tsx

@@ -8,6 +8,7 @@ import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
 import { Dialog } from "@opencode-ai/ui/dialog"
 import { InlineInput } from "@opencode-ai/ui/inline-input"
 import { SessionTurn } from "@opencode-ai/ui/session-turn"
+import { ScrollView } from "@opencode-ai/ui/scroll-view"
 import type { UserMessage } from "@opencode-ai/sdk/v2"
 import { showToast } from "@opencode-ai/ui/toast"
 import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
@@ -322,8 +323,8 @@ export function MessageTimeline(props: {
             <Icon name="arrow-down-to-line" />
           </button>
         </div>
-        <div
-          ref={props.setScrollRef}
+        <ScrollView
+          viewportRef={props.setScrollRef}
           onWheel={(e) => {
             const root = e.currentTarget
             const delta = normalizeWheelDelta({
@@ -367,7 +368,7 @@ export function MessageTimeline(props: {
             if (props.isDesktop) props.onScrollSpyScroll()
           }}
           onClick={props.onAutoScrollInteraction}
-          class="relative min-w-0 w-full h-full overflow-y-auto session-scroller"
+          class="relative min-w-0 w-full h-full"
           style={{
             "--session-title-height": showHeader() ? "40px" : "0px",
             "--sticky-accordion-top": showHeader() ? "48px" : "0px",
@@ -548,7 +549,7 @@ export function MessageTimeline(props: {
               )}
             </For>
           </div>
-        </div>
+        </ScrollView>
       </div>
     </Show>
   )

+ 2 - 2
packages/app/src/pages/session/review-tab.tsx

@@ -143,9 +143,9 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
       open={props.view().review.open()}
       onOpenChange={props.view().review.setOpen}
       classes={{
-        root: props.classes?.root ?? "pb-6",
+        root: props.classes?.root ?? "pb-6 pr-3",
         header: props.classes?.header ?? "px-3",
-        container: props.classes?.container ?? "px-3",
+        container: props.classes?.container ?? "pl-3",
       }}
       diffs={props.diffs()}
       diffStyle={props.diffStyle}

+ 61 - 0
packages/ui/src/components/scroll-view.css

@@ -0,0 +1,61 @@
+.scroll-view {
+  position: relative;
+  overflow: hidden;
+}
+
+.scroll-view__viewport {
+  height: 100%;
+  width: 100%;
+  overflow-y: auto;
+  scrollbar-width: none;
+  outline: none;
+}
+
+.scroll-view__viewport::-webkit-scrollbar {
+  display: none;
+}
+
+.scroll-view__thumb {
+  position: absolute;
+  right: 0;
+  top: 0;
+  width: 16px;
+  transition: opacity 200ms ease;
+  cursor: default;
+  user-select: none;
+  opacity: 0;
+}
+
+.scroll-view__thumb::after {
+  content: "";
+  position: absolute;
+  right: 4px;
+  top: 0;
+  bottom: 0;
+  width: 6px;
+  border-radius: 9999px;
+  background-color: var(--border-weak-base);
+  backdrop-filter: blur(4px);
+  transition: background-color 150ms ease;
+}
+
+.scroll-view__thumb:hover::after,
+.scroll-view__thumb[data-dragging="true"]::after {
+  background-color: var(--border-strong-base);
+}
+
+.dark .scroll-view__thumb::after,
+[data-theme="dark"] .scroll-view__thumb::after {
+  background-color: var(--border-weak-base);
+}
+
+.dark .scroll-view__thumb:hover::after,
+[data-theme="dark"] .scroll-view__thumb:hover::after,
+.dark .scroll-view__thumb[data-dragging="true"]::after,
+[data-theme="dark"] .scroll-view__thumb[data-dragging="true"]::after {
+  background-color: var(--border-strong-base);
+}
+
+.scroll-view__thumb[data-visible="true"] {
+  opacity: 1;
+}

+ 217 - 0
packages/ui/src/components/scroll-view.tsx

@@ -0,0 +1,217 @@
+import { createSignal, onCleanup, onMount, splitProps, type ComponentProps, Show, mergeProps } from "solid-js"
+
+export interface ScrollViewProps extends ComponentProps<"div"> {
+  viewportRef?: (el: HTMLDivElement) => void
+  orientation?: "vertical" | "horizontal" // currently only vertical is fully implemented for thumb
+}
+
+export function ScrollView(props: ScrollViewProps) {
+  const merged = mergeProps({ orientation: "vertical" }, props)
+  const [local, events, rest] = splitProps(
+    merged,
+    ["class", "children", "viewportRef", "orientation", "style"],
+    [
+      "onScroll",
+      "onWheel",
+      "onTouchStart",
+      "onTouchMove",
+      "onTouchEnd",
+      "onTouchCancel",
+      "onPointerDown",
+      "onClick",
+      "onKeyDown",
+    ],
+  )
+
+  let rootRef!: HTMLDivElement
+  let viewportRef!: HTMLDivElement
+  let thumbRef!: HTMLDivElement
+
+  const [isHovered, setIsHovered] = createSignal(false)
+  const [isDragging, setIsDragging] = createSignal(false)
+
+  const [thumbHeight, setThumbHeight] = createSignal(0)
+  const [thumbTop, setThumbTop] = createSignal(0)
+  const [showThumb, setShowThumb] = createSignal(false)
+
+  const updateThumb = () => {
+    if (!viewportRef) return
+    const { scrollTop, scrollHeight, clientHeight } = viewportRef
+
+    if (scrollHeight <= clientHeight || scrollHeight === 0) {
+      setShowThumb(false)
+      return
+    }
+
+    setShowThumb(true)
+    const trackPadding = 8
+    const trackHeight = clientHeight - trackPadding * 2
+
+    const minThumbHeight = 32
+    // Calculate raw thumb height based on ratio
+    let height = (clientHeight / scrollHeight) * trackHeight
+    height = Math.max(height, minThumbHeight)
+
+    const maxScrollTop = scrollHeight - clientHeight
+    const maxThumbTop = trackHeight - height
+
+    const top = maxScrollTop > 0 ? (scrollTop / maxScrollTop) * maxThumbTop : 0
+
+    // Ensure thumb stays within bounds (shouldn't be necessary due to math above, but good for safety)
+    const boundedTop = trackPadding + Math.max(0, Math.min(top, maxThumbTop))
+
+    setThumbHeight(height)
+    setThumbTop(boundedTop)
+  }
+
+  onMount(() => {
+    if (local.viewportRef) {
+      local.viewportRef(viewportRef)
+    }
+
+    const observer = new ResizeObserver(() => {
+      updateThumb()
+    })
+
+    observer.observe(viewportRef)
+    // Also observe the first child if possible to catch content changes
+    if (viewportRef.firstElementChild) {
+      observer.observe(viewportRef.firstElementChild)
+    }
+
+    onCleanup(() => {
+      observer.disconnect()
+    })
+
+    updateThumb()
+  })
+
+  let startY = 0
+  let startScrollTop = 0
+
+  const onThumbPointerDown = (e: PointerEvent) => {
+    e.preventDefault()
+    e.stopPropagation()
+    setIsDragging(true)
+    startY = e.clientY
+    startScrollTop = viewportRef.scrollTop
+
+    thumbRef.setPointerCapture(e.pointerId)
+
+    const onPointerMove = (e: PointerEvent) => {
+      const deltaY = e.clientY - startY
+      const { scrollHeight, clientHeight } = viewportRef
+      const maxScrollTop = scrollHeight - clientHeight
+      const maxThumbTop = clientHeight - thumbHeight()
+
+      if (maxThumbTop > 0) {
+        const scrollDelta = deltaY * (maxScrollTop / maxThumbTop)
+        viewportRef.scrollTop = startScrollTop + scrollDelta
+      }
+    }
+
+    const onPointerUp = (e: PointerEvent) => {
+      setIsDragging(false)
+      thumbRef.releasePointerCapture(e.pointerId)
+      thumbRef.removeEventListener("pointermove", onPointerMove)
+      thumbRef.removeEventListener("pointerup", onPointerUp)
+    }
+
+    thumbRef.addEventListener("pointermove", onPointerMove)
+    thumbRef.addEventListener("pointerup", onPointerUp)
+  }
+
+  // Keybinds implementation
+  // We ensure the viewport has a tabindex so it can receive focus
+  // We can also explicitly catch PageUp/Down if we want smooth scroll or specific behavior,
+  // but native usually handles this perfectly. Let's explicitly ensure it behaves well.
+  const onKeyDown = (e: KeyboardEvent) => {
+    // If user is focused on an input inside the scroll view, don't hijack keys
+    if (document.activeElement && ["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement.tagName)) {
+      return
+    }
+
+    const scrollAmount = viewportRef.clientHeight * 0.8
+    const lineAmount = 40
+
+    switch (e.key) {
+      case "PageDown":
+        e.preventDefault()
+        viewportRef.scrollBy({ top: scrollAmount, behavior: "smooth" })
+        break
+      case "PageUp":
+        e.preventDefault()
+        viewportRef.scrollBy({ top: -scrollAmount, behavior: "smooth" })
+        break
+      case "Home":
+        e.preventDefault()
+        viewportRef.scrollTo({ top: 0, behavior: "smooth" })
+        break
+      case "End":
+        e.preventDefault()
+        viewportRef.scrollTo({ top: viewportRef.scrollHeight, behavior: "smooth" })
+        break
+      case "ArrowUp":
+        e.preventDefault()
+        viewportRef.scrollBy({ top: -lineAmount, behavior: "smooth" })
+        break
+      case "ArrowDown":
+        e.preventDefault()
+        viewportRef.scrollBy({ top: lineAmount, behavior: "smooth" })
+        break
+    }
+  }
+
+  return (
+    <div
+      ref={rootRef}
+      class={`scroll-view ${local.class || ""}`}
+      style={local.style}
+      onPointerEnter={() => setIsHovered(true)}
+      onPointerLeave={() => setIsHovered(false)}
+      {...rest}
+    >
+      {/* Viewport */}
+      <div
+        ref={viewportRef}
+        class="scroll-view__viewport"
+        onScroll={(e) => {
+          updateThumb()
+          if (typeof events.onScroll === "function") events.onScroll(e as any)
+        }}
+        onWheel={events.onWheel as any}
+        onTouchStart={events.onTouchStart as any}
+        onTouchMove={events.onTouchMove as any}
+        onTouchEnd={events.onTouchEnd as any}
+        onTouchCancel={events.onTouchCancel as any}
+        onPointerDown={events.onPointerDown as any}
+        onClick={events.onClick as any}
+        tabIndex={0}
+        role="region"
+        aria-label="scrollable content"
+        onKeyDown={(e) => {
+          onKeyDown(e)
+          if (typeof events.onKeyDown === "function") events.onKeyDown(e as any)
+        }}
+      >
+        {local.children}
+      </div>
+
+      {/* Thumb Overlay */}
+      <Show when={showThumb()}>
+        <div
+          ref={thumbRef}
+          onPointerDown={onThumbPointerDown}
+          class="scroll-view__thumb"
+          data-visible={isHovered() || isDragging()}
+          data-dragging={isDragging()}
+          style={{
+            height: `${thumbHeight()}px`,
+            transform: `translateY(${thumbTop()}px)`,
+            "z-index": 100, // ensure it displays over content
+          }}
+        />
+      </Show>
+    </div>
+  )
+}

+ 5 - 4
packages/ui/src/components/session-review.tsx

@@ -7,6 +7,7 @@ import { Icon } from "./icon"
 import { LineComment, LineCommentEditor } from "./line-comment"
 import { StickyAccordionHeader } from "./sticky-accordion-header"
 import { Tooltip } from "./tooltip"
+import { ScrollView } from "./scroll-view"
 import { useDiffComponent } from "../context/diff"
 import { useI18n } from "../context/i18n"
 import { getDirectory, getFilename } from "@opencode-ai/util/path"
@@ -274,13 +275,13 @@ export const SessionReview = (props: SessionReviewProps) => {
   })
 
   return (
-    <div
+    <ScrollView
       data-component="session-review"
-      ref={(el) => {
+      viewportRef={(el) => {
         scroll = el
         props.scrollRef?.(el)
       }}
-      onScroll={props.onScroll}
+      onScroll={props.onScroll as any}
       classList={{
         ...(props.classList ?? {}),
         [props.classes?.root ?? ""]: !!props.classes?.root,
@@ -709,6 +710,6 @@ export const SessionReview = (props: SessionReviewProps) => {
           </Accordion>
         </Show>
       </div>
-    </div>
+    </ScrollView>
   )
 }

+ 1 - 0
packages/ui/src/styles/index.css

@@ -44,6 +44,7 @@
 @import "../components/select.css" layer(components);
 @import "../components/spinner.css" layer(components);
 @import "../components/switch.css" layer(components);
+@import "../components/scroll-view.css" layer(components);
 @import "../components/session-review.css" layer(components);
 @import "../components/session-turn.css" layer(components);
 @import "../components/sticky-accordion-header.css" layer(components);

+ 0 - 28
packages/ui/src/styles/tailwind/utilities.css

@@ -8,34 +8,6 @@
   }
 }
 
-@utility session-scroller {
-  &::-webkit-scrollbar {
-    width: 10px;
-    height: 10px;
-  }
-
-  &::-webkit-scrollbar-track {
-    background: transparent;
-    border-radius: 5px;
-  }
-
-  &::-webkit-scrollbar-thumb {
-    background: var(--border-weak-base);
-    border-radius: 5px;
-    border: 3px solid transparent;
-    background-clip: padding-box;
-  }
-
-  &::-webkit-scrollbar-thumb:hover {
-    background: var(--border-weak-base);
-  }
-
-  & {
-    scrollbar-width: thin;
-    scrollbar-color: var(--border-weak-base) transparent;
-  }
-}
-
 @utility badge-mask {
   -webkit-mask-image: radial-gradient(circle 5px at calc(100% - 4px) 4px, transparent 5px, black 5.5px);
   mask-image: radial-gradient(circle 5px at calc(100% - 4px) 4px, transparent 5px, black 5.5px);