feat(frontend): replace native scrollbar styling with OverlayScrollbars
Adopt OverlayScrollbars for cross-platform consistent overlay scrollbars with auto-hide on pointer leave, hover grow effect, and click-to-scroll. - Add overlayscrollbars + overlayscrollbars-react dependencies - Rewrite ScrollArea component from Radix to OverlayScrollbars wrapper - Define custom theme `os-theme-codeg` in globals.css (6px → 8px on hover) - Initialize body-level overlay scrollbar via OverlayScrollbarsInit - Migrate all scrollbar-thin / scrollbar-thin-edge usages to ScrollArea - Keep native .scrollbar-thin fallback for virtua scroll containers Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -44,6 +44,8 @@
|
|||||||
"next": "^16",
|
"next": "^16",
|
||||||
"next-intl": "^4.8.3",
|
"next-intl": "^4.8.3",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"overlayscrollbars": "^2.15.1",
|
||||||
|
"overlayscrollbars-react": "^0.5.6",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
|
|||||||
22
pnpm-lock.yaml
generated
22
pnpm-lock.yaml
generated
@@ -107,6 +107,12 @@ importers:
|
|||||||
next-themes:
|
next-themes:
|
||||||
specifier: ^0.4.6
|
specifier: ^0.4.6
|
||||||
version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
|
overlayscrollbars:
|
||||||
|
specifier: ^2.15.1
|
||||||
|
version: 2.15.1
|
||||||
|
overlayscrollbars-react:
|
||||||
|
specifier: ^0.5.6
|
||||||
|
version: 0.5.6(overlayscrollbars@2.15.1)(react@19.2.4)
|
||||||
postcss:
|
postcss:
|
||||||
specifier: ^8.5.6
|
specifier: ^8.5.6
|
||||||
version: 8.5.6
|
version: 8.5.6
|
||||||
@@ -5227,6 +5233,15 @@ packages:
|
|||||||
outvariant@1.4.3:
|
outvariant@1.4.3:
|
||||||
resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==}
|
resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==}
|
||||||
|
|
||||||
|
overlayscrollbars-react@0.5.6:
|
||||||
|
resolution: {integrity: sha512-E5To04bL5brn9GVCZ36SnfGanxa2I2MDkWoa4Cjo5wol7l+diAgi4DBc983V7l2nOk/OLJ6Feg4kySspQEGDBw==}
|
||||||
|
peerDependencies:
|
||||||
|
overlayscrollbars: ^2.0.0
|
||||||
|
react: '>=16.8.0'
|
||||||
|
|
||||||
|
overlayscrollbars@2.15.1:
|
||||||
|
resolution: {integrity: sha512-glX26JwjL+Tkzv0JNOWdW4VozP5dGXO+Wx8+TPrdTEJTSYT/8eJS8yXM+fewjU0nFq/JeCa+X+BqABNjC4YZSA==}
|
||||||
|
|
||||||
own-keys@1.0.1:
|
own-keys@1.0.1:
|
||||||
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
|
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -12402,6 +12417,13 @@ snapshots:
|
|||||||
|
|
||||||
outvariant@1.4.3: {}
|
outvariant@1.4.3: {}
|
||||||
|
|
||||||
|
overlayscrollbars-react@0.5.6(overlayscrollbars@2.15.1)(react@19.2.4):
|
||||||
|
dependencies:
|
||||||
|
overlayscrollbars: 2.15.1
|
||||||
|
react: 19.2.4
|
||||||
|
|
||||||
|
overlayscrollbars@2.15.1: {}
|
||||||
|
|
||||||
own-keys@1.0.1:
|
own-keys@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
get-intrinsic: 1.3.0
|
get-intrinsic: 1.3.0
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
@import "shadcn/tailwind.css";
|
@import "shadcn/tailwind.css";
|
||||||
@import "@xterm/xterm/css/xterm.css";
|
@import "@xterm/xterm/css/xterm.css";
|
||||||
|
@import "overlayscrollbars/overlayscrollbars.css";
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@@ -1002,14 +1003,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Unified scrollbar style for scrollable containers.
|
/* Native fallback for containers that cannot use OverlayScrollbars (e.g. virtua) */
|
||||||
Thin overlay scrollbar — no gutter reserved, no layout shift. */
|
.scrollbar-thin {
|
||||||
.scrollbar-thin,
|
|
||||||
.scrollbar-thin-edge {
|
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: var(--border) transparent;
|
scrollbar-color: var(--border) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* OverlayScrollbars custom theme */
|
||||||
|
.os-theme-codeg {
|
||||||
|
--os-size: 6px;
|
||||||
|
--os-handle-bg: var(--border);
|
||||||
|
--os-handle-bg-hover: var(--muted-foreground);
|
||||||
|
--os-handle-bg-active: var(--muted-foreground);
|
||||||
|
--os-handle-border-radius: 999px;
|
||||||
|
--os-handle-perpendicular-size: 100%;
|
||||||
|
--os-handle-perpendicular-size-hover: 100%;
|
||||||
|
--os-handle-perpendicular-size-active: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.os-theme-codeg:hover {
|
||||||
|
--os-size: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Streamdown code blocks: dark mode via shiki dual-theme CSS variables */
|
/* Streamdown code blocks: dark mode via shiki dual-theme CSS variables */
|
||||||
.dark [data-streamdown="code-block-body"] {
|
.dark [data-streamdown="code-block-body"] {
|
||||||
background-color: var(--shiki-dark-bg, var(--sdm-bg, transparent)) !important;
|
background-color: var(--shiki-dark-bg, var(--sdm-bg, transparent)) !important;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { ThemeProvider } from "@/components/theme-provider"
|
|||||||
import { toIntlLocale } from "@/lib/i18n"
|
import { toIntlLocale } from "@/lib/i18n"
|
||||||
import { APPEARANCE_INIT_SCRIPT } from "@/lib/appearance-script"
|
import { APPEARANCE_INIT_SCRIPT } from "@/lib/appearance-script"
|
||||||
import { AppearanceProvider } from "@/components/appearance-provider"
|
import { AppearanceProvider } from "@/components/appearance-provider"
|
||||||
|
import { OverlayScrollbarsInit } from "@/components/overlay-scrollbars-init"
|
||||||
|
|
||||||
const jetbrainsMono = JetBrains_Mono({
|
const jetbrainsMono = JetBrains_Mono({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
@@ -68,7 +69,10 @@ export default async function RootLayout({
|
|||||||
enableSystem
|
enableSystem
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
<AppearanceProvider>{children}</AppearanceProvider>
|
<AppearanceProvider>
|
||||||
|
<OverlayScrollbarsInit />
|
||||||
|
{children}
|
||||||
|
</AppearanceProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</AppI18nProvider>
|
</AppI18nProvider>
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { STATUS_ORDER, STATUS_COLORS } from "@/lib/types"
|
|||||||
import { SidebarConversationCard } from "./sidebar-conversation-card"
|
import { SidebarConversationCard } from "./sidebar-conversation-card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
@@ -205,8 +206,6 @@ export function SidebarConversationList({
|
|||||||
cancelled: false,
|
cancelled: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
const scrollToActiveRef = useRef<() => void>(() => {})
|
const scrollToActiveRef = useRef<() => void>(() => {})
|
||||||
const pendingScrollRef = useRef(false)
|
const pendingScrollRef = useRef(false)
|
||||||
const virtualizerRef = useRef<VirtualizerHandle>(null)
|
const virtualizerRef = useRef<VirtualizerHandle>(null)
|
||||||
@@ -479,12 +478,8 @@ export function SidebarConversationList({
|
|||||||
) : (
|
) : (
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
<div
|
<ScrollArea
|
||||||
ref={scrollContainerRef}
|
className={cn("flex-1 min-h-0 px-2", "[overflow-anchor:none]")}
|
||||||
className={cn(
|
|
||||||
"flex-1 min-h-0 overflow-y-auto scrollbar-thin px-2",
|
|
||||||
"[overflow-anchor:none]"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Virtualizer ref={virtualizerRef} itemSize={CARD_HEIGHT}>
|
<Virtualizer ref={virtualizerRef} itemSize={CARD_HEIGHT}>
|
||||||
{flatItems.map((item) => {
|
{flatItems.map((item) => {
|
||||||
@@ -537,7 +532,7 @@ export function SidebarConversationList({
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</Virtualizer>
|
</Virtualizer>
|
||||||
</div>
|
</ScrollArea>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
<ContextMenuContent>
|
<ContextMenuContent>
|
||||||
<ContextMenuItem onSelect={handleNewConversation}>
|
<ContextMenuItem onSelect={handleNewConversation}>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useMemo } from "react"
|
|||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { useFolderContext } from "@/contexts/folder-context"
|
import { useFolderContext } from "@/contexts/folder-context"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
|
|
||||||
type RowMarker = "none" | "added" | "deleted" | "modified"
|
type RowMarker = "none" | "added" | "deleted" | "modified"
|
||||||
type DiffFileMode = "modified" | "added" | "deleted" | "renamed"
|
type DiffFileMode = "modified" | "added" | "deleted" | "renamed"
|
||||||
@@ -546,16 +547,16 @@ export function UnifiedDiffPreview({
|
|||||||
|
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("h-full overflow-auto scrollbar-thin", className)}>
|
<ScrollArea className={cn("h-full", className)} x="scroll">
|
||||||
<pre className="font-mono text-[11px] leading-5 whitespace-pre-wrap text-muted-foreground p-3">
|
<pre className="font-mono text-[11px] leading-5 whitespace-pre-wrap text-muted-foreground p-3">
|
||||||
{diffText}
|
{diffText}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</ScrollArea>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("h-full overflow-auto scrollbar-thin", className)}>
|
<ScrollArea className={cn("h-full", className)} x="scroll">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{files.map((file) => {
|
{files.map((file) => {
|
||||||
const newFile = isNewFileOnly(file)
|
const newFile = isNewFileOnly(file)
|
||||||
@@ -586,7 +587,7 @@ export function UnifiedDiffPreview({
|
|||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="overflow-auto scrollbar-thin">
|
<ScrollArea x="scroll">
|
||||||
<div className="inline-block min-w-full">
|
<div className="inline-block min-w-full">
|
||||||
{newFile
|
{newFile
|
||||||
? file.hunks.map((hunk) => (
|
? file.hunks.map((hunk) => (
|
||||||
@@ -599,11 +600,11 @@ export function UnifiedDiffPreview({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ScrollArea>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ScrollArea>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { Streamdown } from "streamdown"
|
|||||||
import { readFileBase64 } from "@/lib/api"
|
import { readFileBase64 } from "@/lib/api"
|
||||||
import { normalizeMathDelimiters } from "@/components/ai-elements/message"
|
import { normalizeMathDelimiters } from "@/components/ai-elements/message"
|
||||||
import { defineMonacoThemes, useMonacoThemeSync } from "@/lib/monaco-themes"
|
import { defineMonacoThemes, useMonacoThemeSync } from "@/lib/monaco-themes"
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
import "@/lib/monaco-local"
|
import "@/lib/monaco-local"
|
||||||
|
|
||||||
const math = createMathPlugin({ singleDollarTextMath: true })
|
const math = createMathPlugin({ singleDollarTextMath: true })
|
||||||
@@ -693,7 +694,7 @@ function DiffFileList({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto scrollbar-thin">
|
<ScrollArea className="flex-1 min-h-0">
|
||||||
<div className="py-1">
|
<div className="py-1">
|
||||||
{diffOutline.files.map((file) => (
|
{diffOutline.files.map((file) => (
|
||||||
<ContextMenu key={file.key}>
|
<ContextMenu key={file.key}>
|
||||||
@@ -743,7 +744,7 @@ function DiffFileList({
|
|||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
stopFileTreeWatch,
|
stopFileTreeWatch,
|
||||||
} from "@/lib/api"
|
} from "@/lib/api"
|
||||||
import { emitAttachFileToSession } from "@/lib/session-attachment-events"
|
import { emitAttachFileToSession } from "@/lib/session-attachment-events"
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
import type {
|
import type {
|
||||||
FileTreeChangedEvent,
|
FileTreeChangedEvent,
|
||||||
FileTreeNode,
|
FileTreeNode,
|
||||||
@@ -2167,7 +2168,7 @@ export function FileTreeTab() {
|
|||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
<div className="flex-1 min-h-0 overflow-auto pb-1 scrollbar-thin-edge">
|
<ScrollArea className="flex-1 min-h-0 pb-1" x="scroll">
|
||||||
<FileTree
|
<FileTree
|
||||||
key={folder?.path ?? "file-tree-empty"}
|
key={folder?.path ?? "file-tree-empty"}
|
||||||
className="border-0 rounded-none bg-transparent w-max min-w-full"
|
className="border-0 rounded-none bg-transparent w-max min-w-full"
|
||||||
@@ -2319,7 +2320,7 @@ export function FileTreeTab() {
|
|||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
)}
|
)}
|
||||||
</FileTree>
|
</FileTree>
|
||||||
</div>
|
</ScrollArea>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
<ContextMenuContent>
|
<ContextMenuContent>
|
||||||
<ContextMenuSub>
|
<ContextMenuSub>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
FileTreeFolder,
|
FileTreeFolder,
|
||||||
} from "@/components/ai-elements/file-tree"
|
} from "@/components/ai-elements/file-tree"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
ContextMenuContent,
|
ContextMenuContent,
|
||||||
@@ -1287,7 +1288,7 @@ export function GitChangesTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="h-full min-h-0 overflow-y-auto scrollbar-thin-edge">
|
<ScrollArea className="h-full min-h-0" x="scroll">
|
||||||
{trackedChanges.length === 0 && untrackedChanges.length === 0 ? (
|
{trackedChanges.length === 0 && untrackedChanges.length === 0 ? (
|
||||||
<div className="flex items-center justify-center h-full p-4">
|
<div className="flex items-center justify-center h-full p-4">
|
||||||
<p className="text-xs text-muted-foreground text-center">
|
<p className="text-xs text-muted-foreground text-center">
|
||||||
@@ -1506,7 +1507,7 @@ export function GitChangesTab() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</ScrollArea>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
open={Boolean(directoryGitActionType && directoryGitActionTarget)}
|
open={Boolean(directoryGitActionType && directoryGitActionTarget)}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
type ReactElement,
|
type ReactElement,
|
||||||
type UIEvent,
|
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
@@ -89,6 +88,7 @@ import {
|
|||||||
import type { GitBranchList, GitLogEntry, GitLogFileChange } from "@/lib/types"
|
import type { GitBranchList, GitLogEntry, GitLogFileChange } from "@/lib/types"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { toErrorMessage } from "@/lib/app-error"
|
import { toErrorMessage } from "@/lib/app-error"
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
|
|
||||||
function formatRelativeTime(
|
function formatRelativeTime(
|
||||||
dateStr: string,
|
dateStr: string,
|
||||||
@@ -896,14 +896,15 @@ export function GitLogTab() {
|
|||||||
}
|
}
|
||||||
}, [folder, refreshBranches, fetchLog])
|
}, [folder, refreshBranches, fetchLog])
|
||||||
|
|
||||||
const handleScroll = useCallback((e: UIEvent<HTMLDivElement>) => {
|
const handleScroll = useCallback((e: Event) => {
|
||||||
const nextScrolled = e.currentTarget.scrollTop > 0
|
const target = e.target as HTMLElement
|
||||||
|
const nextScrolled = target.scrollTop > 0
|
||||||
setScrolled((prev) => (prev === nextScrolled ? prev : nextScrolled))
|
setScrolled((prev) => (prev === nextScrolled ? prev : nextScrolled))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full overflow-y-auto scrollbar-thin px-3 py-3">
|
<ScrollArea className="h-full px-3 py-3">
|
||||||
{hasBranches && (
|
{hasBranches && (
|
||||||
<BranchSelector
|
<BranchSelector
|
||||||
branchList={branchList}
|
branchList={branchList}
|
||||||
@@ -923,13 +924,13 @@ export function GitLogTab() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ScrollArea>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full overflow-y-auto scrollbar-thin px-3 py-3">
|
<ScrollArea className="h-full px-3 py-3">
|
||||||
{hasBranches && (
|
{hasBranches && (
|
||||||
<BranchSelector
|
<BranchSelector
|
||||||
branchList={branchList}
|
branchList={branchList}
|
||||||
@@ -953,29 +954,31 @@ export function GitLogTab() {
|
|||||||
{t("retry")}
|
{t("retry")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ScrollArea>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full overflow-y-auto scrollbar-thin px-3 py-3">
|
<ScrollArea className="h-full px-3 py-3">
|
||||||
{hasBranches && (
|
<div className="flex flex-col min-h-full">
|
||||||
<BranchSelector
|
{hasBranches && (
|
||||||
branchList={branchList}
|
<BranchSelector
|
||||||
currentBranch={currentBranch}
|
branchList={branchList}
|
||||||
selectedBranch={selectedBranch}
|
currentBranch={currentBranch}
|
||||||
onBranchChange={handleBranchChange}
|
selectedBranch={selectedBranch}
|
||||||
onRefresh={handleRefresh}
|
onBranchChange={handleBranchChange}
|
||||||
refreshing={loading || refreshing}
|
onRefresh={handleRefresh}
|
||||||
/>
|
refreshing={loading || refreshing}
|
||||||
)}
|
/>
|
||||||
<div className="flex items-center justify-center flex-1 p-4">
|
)}
|
||||||
<p className="text-xs text-muted-foreground text-center">
|
<div className="flex items-center justify-center flex-1 p-4">
|
||||||
{t("noCommitsFound")}
|
<p className="text-xs text-muted-foreground text-center">
|
||||||
</p>
|
{t("noCommitsFound")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ScrollArea>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -983,256 +986,258 @@ export function GitLogTab() {
|
|||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
<div
|
<ScrollArea
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
className="flex-1 min-h-0 overflow-y-auto scrollbar-thin px-3 py-3 space-y-3"
|
className="flex-1 min-h-0 px-3 py-3"
|
||||||
>
|
>
|
||||||
{hasBranches && (
|
<div className="space-y-3">
|
||||||
<div
|
{hasBranches && (
|
||||||
className={`sticky top-0 z-10 rounded-full bg-sidebar/85 supports-[backdrop-filter]:bg-sidebar/70 backdrop-blur ${scrolled ? "p-2 shadow-md" : "p-0"}`}
|
<div
|
||||||
>
|
className={`sticky top-0 z-10 rounded-full bg-sidebar/85 supports-[backdrop-filter]:bg-sidebar/70 backdrop-blur ${scrolled ? "p-2 shadow-md" : "p-0"}`}
|
||||||
<BranchSelector
|
>
|
||||||
branchList={branchList}
|
<BranchSelector
|
||||||
currentBranch={currentBranch}
|
branchList={branchList}
|
||||||
selectedBranch={selectedBranch}
|
currentBranch={currentBranch}
|
||||||
onBranchChange={handleBranchChange}
|
selectedBranch={selectedBranch}
|
||||||
onRefresh={handleRefresh}
|
onBranchChange={handleBranchChange}
|
||||||
refreshing={loading || refreshing}
|
onRefresh={handleRefresh}
|
||||||
/>
|
refreshing={loading || refreshing}
|
||||||
</div>
|
/>
|
||||||
)}
|
</div>
|
||||||
{entries.map((entry) => {
|
)}
|
||||||
const commitKey = entry.full_hash
|
{entries.map((entry) => {
|
||||||
const commitDate = parseDate(entry.date)
|
const commitKey = entry.full_hash
|
||||||
const pushStatus = getPushStatusMeta(
|
const commitDate = parseDate(entry.date)
|
||||||
entry.pushed,
|
const pushStatus = getPushStatusMeta(
|
||||||
pushStatusLabels
|
entry.pushed,
|
||||||
)
|
pushStatusLabels
|
||||||
const PushStatusIcon = pushStatus.icon
|
)
|
||||||
const commitBranches = branchesByCommit[commitKey]
|
const PushStatusIcon = pushStatus.icon
|
||||||
const isBranchLoading = !!branchesLoading[commitKey]
|
const commitBranches = branchesByCommit[commitKey]
|
||||||
const branchError = branchesError[commitKey]
|
const isBranchLoading = !!branchesLoading[commitKey]
|
||||||
const isOpen = !!openByCommit[commitKey]
|
const branchError = branchesError[commitKey]
|
||||||
|
const isOpen = !!openByCommit[commitKey]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextMenu key={entry.full_hash}>
|
<ContextMenu key={entry.full_hash}>
|
||||||
<ContextMenuTrigger asChild>
|
<ContextMenuTrigger asChild>
|
||||||
<div>
|
<div>
|
||||||
<Commit
|
<Commit
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
setOpenByCommit((prev) => ({
|
setOpenByCommit((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[commitKey]: open,
|
[commitKey]: open,
|
||||||
}))
|
}))
|
||||||
if (open) {
|
if (open) {
|
||||||
void fetchCommitBranches(commitKey)
|
void fetchCommitBranches(commitKey)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
>
|
>
|
||||||
<CommitHeader>
|
<CommitHeader>
|
||||||
<CommitInfo className="min-w-0">
|
<CommitInfo className="min-w-0">
|
||||||
<CommitMessage className="line-clamp-1 leading-snug">
|
<CommitMessage className="line-clamp-1 leading-snug">
|
||||||
{entry.message}
|
|
||||||
</CommitMessage>
|
|
||||||
<CommitMetadata className="mt-1 min-w-0 flex items-center gap-1.5">
|
|
||||||
<span
|
|
||||||
className="inline-flex shrink-0"
|
|
||||||
title={pushStatus.label}
|
|
||||||
aria-label={pushStatus.label}
|
|
||||||
>
|
|
||||||
<PushStatusIcon
|
|
||||||
className={pushStatus.className}
|
|
||||||
size={12}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span className="truncate">{entry.author}</span>
|
|
||||||
<CommitTimestamp
|
|
||||||
className="shrink-0"
|
|
||||||
date={commitDate ?? new Date()}
|
|
||||||
>
|
|
||||||
{formatRelativeTime(entry.date, t)}
|
|
||||||
</CommitTimestamp>
|
|
||||||
<CommitHash className="text-primary/70">
|
|
||||||
{entry.hash}
|
|
||||||
</CommitHash>
|
|
||||||
</CommitMetadata>
|
|
||||||
</CommitInfo>
|
|
||||||
<CommitActions className="shrink-0">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-6 w-6 text-muted-foreground hover:text-foreground"
|
|
||||||
onClick={() => {
|
|
||||||
void openCommitDiff(
|
|
||||||
entry.full_hash,
|
|
||||||
undefined,
|
|
||||||
entry.message
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
title={tCommon("viewDiff")}
|
|
||||||
aria-label={t("viewCommitDiffAria", {
|
|
||||||
hash: entry.hash,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<GitCompare size={14} />
|
|
||||||
</Button>
|
|
||||||
</CommitActions>
|
|
||||||
</CommitHeader>
|
|
||||||
<CommitContent>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="grid grid-cols-[4rem_minmax(0,1fr)] items-center gap-x-2 gap-y-1 text-xs">
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{t("hash")}
|
|
||||||
</span>
|
|
||||||
<span className="group/hash flex items-center gap-1 min-w-0">
|
|
||||||
<code
|
|
||||||
className="block min-w-0 flex-1 truncate font-mono"
|
|
||||||
title={entry.full_hash}
|
|
||||||
>
|
|
||||||
{entry.full_hash}
|
|
||||||
</code>
|
|
||||||
<CommitCopyButton
|
|
||||||
aria-label={t("copyFullCommitHashAria", {
|
|
||||||
hash: entry.full_hash,
|
|
||||||
})}
|
|
||||||
className="size-5 shrink-0 opacity-0 transition-opacity group-hover/hash:opacity-100 group-focus-within/hash:opacity-100"
|
|
||||||
hash={entry.full_hash}
|
|
||||||
title={t("copyHash")}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{t("author")}
|
|
||||||
</span>
|
|
||||||
<span className="min-w-0 flex items-center gap-1">
|
|
||||||
<span className="min-w-0 truncate">
|
|
||||||
{entry.author}
|
|
||||||
</span>
|
|
||||||
<span className="shrink-0 text-muted-foreground">
|
|
||||||
·
|
|
||||||
</span>
|
|
||||||
<time
|
|
||||||
className="shrink-0"
|
|
||||||
dateTime={commitDate?.toISOString()}
|
|
||||||
>
|
|
||||||
{commitDate
|
|
||||||
? commitDate.toLocaleString()
|
|
||||||
: entry.date}
|
|
||||||
</time>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="group/msg relative rounded-lg border border-border/60 bg-muted/20 p-2.5">
|
|
||||||
<p className="text-xs whitespace-pre-wrap break-words pr-6">
|
|
||||||
{entry.message}
|
{entry.message}
|
||||||
</p>
|
</CommitMessage>
|
||||||
<CommitCopyButton
|
<CommitMetadata className="mt-1 min-w-0 flex items-center gap-1.5">
|
||||||
className="absolute top-1.5 right-1.5 size-5 opacity-0 transition-opacity group-hover/msg:opacity-100 group-focus-within/msg:opacity-100"
|
<span
|
||||||
hash={entry.message}
|
className="inline-flex shrink-0"
|
||||||
title={t("copyMessage")}
|
title={pushStatus.label}
|
||||||
/>
|
aria-label={pushStatus.label}
|
||||||
</div>
|
>
|
||||||
{entry.files.length === 0 ? (
|
<PushStatusIcon
|
||||||
<div className="space-y-1">
|
className={pushStatus.className}
|
||||||
<p className="text-[11px] text-muted-foreground">
|
size={12}
|
||||||
{t("filesTitle")}
|
/>
|
||||||
</p>
|
</span>
|
||||||
<p className="text-xs text-muted-foreground">
|
<span className="truncate">{entry.author}</span>
|
||||||
{t("noFileChangeDetails")}
|
<CommitTimestamp
|
||||||
</p>
|
className="shrink-0"
|
||||||
|
date={commitDate ?? new Date()}
|
||||||
|
>
|
||||||
|
{formatRelativeTime(entry.date, t)}
|
||||||
|
</CommitTimestamp>
|
||||||
|
<CommitHash className="text-primary/70">
|
||||||
|
{entry.hash}
|
||||||
|
</CommitHash>
|
||||||
|
</CommitMetadata>
|
||||||
|
</CommitInfo>
|
||||||
|
<CommitActions className="shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={() => {
|
||||||
|
void openCommitDiff(
|
||||||
|
entry.full_hash,
|
||||||
|
undefined,
|
||||||
|
entry.message
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
title={tCommon("viewDiff")}
|
||||||
|
aria-label={t("viewCommitDiffAria", {
|
||||||
|
hash: entry.hash,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<GitCompare size={14} />
|
||||||
|
</Button>
|
||||||
|
</CommitActions>
|
||||||
|
</CommitHeader>
|
||||||
|
<CommitContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-[4rem_minmax(0,1fr)] items-center gap-x-2 gap-y-1 text-xs">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t("hash")}
|
||||||
|
</span>
|
||||||
|
<span className="group/hash flex items-center gap-1 min-w-0">
|
||||||
|
<code
|
||||||
|
className="block min-w-0 flex-1 truncate font-mono"
|
||||||
|
title={entry.full_hash}
|
||||||
|
>
|
||||||
|
{entry.full_hash}
|
||||||
|
</code>
|
||||||
|
<CommitCopyButton
|
||||||
|
aria-label={t("copyFullCommitHashAria", {
|
||||||
|
hash: entry.full_hash,
|
||||||
|
})}
|
||||||
|
className="size-5 shrink-0 opacity-0 transition-opacity group-hover/hash:opacity-100 group-focus-within/hash:opacity-100"
|
||||||
|
hash={entry.full_hash}
|
||||||
|
title={t("copyHash")}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t("author")}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex items-center gap-1">
|
||||||
|
<span className="min-w-0 truncate">
|
||||||
|
{entry.author}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-muted-foreground">
|
||||||
|
·
|
||||||
|
</span>
|
||||||
|
<time
|
||||||
|
className="shrink-0"
|
||||||
|
dateTime={commitDate?.toISOString()}
|
||||||
|
>
|
||||||
|
{commitDate
|
||||||
|
? commitDate.toLocaleString()
|
||||||
|
: entry.date}
|
||||||
|
</time>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<div className="group/msg relative rounded-lg border border-border/60 bg-muted/20 p-2.5">
|
||||||
<CommitFilesTree
|
<p className="text-xs whitespace-pre-wrap break-words pr-6">
|
||||||
commitHash={entry.full_hash}
|
{entry.message}
|
||||||
files={entry.files}
|
|
||||||
folderName={folderName}
|
|
||||||
onOpenCommitDiff={openCommitDiff}
|
|
||||||
onOpenFilePreview={openFilePreview}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="pt-3 space-y-1">
|
|
||||||
<p className="text-[11px] text-muted-foreground">
|
|
||||||
{t("branchesTitle")}
|
|
||||||
</p>
|
|
||||||
{isBranchLoading ? (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t("loadingBranches")}
|
|
||||||
</p>
|
</p>
|
||||||
) : branchError ? (
|
<CommitCopyButton
|
||||||
<p className="text-xs text-destructive">
|
className="absolute top-1.5 right-1.5 size-5 opacity-0 transition-opacity group-hover/msg:opacity-100 group-focus-within/msg:opacity-100"
|
||||||
{branchError}
|
hash={entry.message}
|
||||||
</p>
|
title={t("copyMessage")}
|
||||||
) : commitBranches &&
|
/>
|
||||||
commitBranches.length > 0 ? (
|
</div>
|
||||||
<div className="flex flex-wrap gap-1">
|
{entry.files.length === 0 ? (
|
||||||
{commitBranches.map((branch) => (
|
<div className="space-y-1">
|
||||||
<span
|
<p className="text-[11px] text-muted-foreground">
|
||||||
key={`${commitKey}-${branch}`}
|
{t("filesTitle")}
|
||||||
className="rounded-md border border-border px-1.5 py-0.5 text-[10px] text-muted-foreground"
|
</p>
|
||||||
title={branch}
|
<p className="text-xs text-muted-foreground">
|
||||||
>
|
{t("noFileChangeDetails")}
|
||||||
{branch}
|
</p>
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-muted-foreground">
|
<CommitFilesTree
|
||||||
{t("noContainingBranches")}
|
commitHash={entry.full_hash}
|
||||||
</p>
|
files={entry.files}
|
||||||
|
folderName={folderName}
|
||||||
|
onOpenCommitDiff={openCommitDiff}
|
||||||
|
onOpenFilePreview={openFilePreview}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
<div className="pt-3 space-y-1">
|
||||||
|
<p className="text-[11px] text-muted-foreground">
|
||||||
|
{t("branchesTitle")}
|
||||||
|
</p>
|
||||||
|
{isBranchLoading ? (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("loadingBranches")}
|
||||||
|
</p>
|
||||||
|
) : branchError ? (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
{branchError}
|
||||||
|
</p>
|
||||||
|
) : commitBranches &&
|
||||||
|
commitBranches.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{commitBranches.map((branch) => (
|
||||||
|
<span
|
||||||
|
key={`${commitKey}-${branch}`}
|
||||||
|
className="rounded-md border border-border px-1.5 py-0.5 text-[10px] text-muted-foreground"
|
||||||
|
title={branch}
|
||||||
|
>
|
||||||
|
{branch}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("noContainingBranches")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CommitContent>
|
||||||
</CommitContent>
|
</Commit>
|
||||||
</Commit>
|
</div>
|
||||||
</div>
|
</ContextMenuTrigger>
|
||||||
</ContextMenuTrigger>
|
<ContextMenuContent>
|
||||||
<ContextMenuContent>
|
<ContextMenuItem
|
||||||
<ContextMenuItem
|
onSelect={() => {
|
||||||
onSelect={() => {
|
handleOpenNewBranchDialog(entry)
|
||||||
handleOpenNewBranchDialog(entry)
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<GitBranchPlus className="h-3.5 w-3.5" />
|
||||||
<GitBranchPlus className="h-3.5 w-3.5" />
|
{t("newBranch")}
|
||||||
{t("newBranch")}
|
</ContextMenuItem>
|
||||||
</ContextMenuItem>
|
<ContextMenuItem
|
||||||
<ContextMenuItem
|
onSelect={() => {
|
||||||
onSelect={() => {
|
void openCommitDiff(
|
||||||
void openCommitDiff(
|
entry.full_hash,
|
||||||
entry.full_hash,
|
undefined,
|
||||||
undefined,
|
entry.message
|
||||||
entry.message
|
)
|
||||||
)
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<GitCompare className="h-3.5 w-3.5" />
|
||||||
<GitCompare className="h-3.5 w-3.5" />
|
{tCommon("viewDiff")}
|
||||||
{tCommon("viewDiff")}
|
</ContextMenuItem>
|
||||||
</ContextMenuItem>
|
<ContextMenuItem
|
||||||
<ContextMenuItem
|
onSelect={() => {
|
||||||
onSelect={() => {
|
void fetchLog()
|
||||||
void fetchLog()
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<RefreshCw className="size-3.5" />
|
||||||
<RefreshCw className="size-3.5" />
|
{tCommon("refresh")}
|
||||||
{tCommon("refresh")}
|
</ContextMenuItem>
|
||||||
</ContextMenuItem>
|
<ContextMenuItem
|
||||||
<ContextMenuItem
|
onSelect={() => {
|
||||||
onSelect={() => {
|
if (!folder) return
|
||||||
if (!folder) return
|
openPushWindow(folder.id).catch((err) => {
|
||||||
openPushWindow(folder.id).catch((err) => {
|
const msg = toErrorMessage(err)
|
||||||
const msg = toErrorMessage(err)
|
toast.error(t("toasts.openPushWindowFailed"), {
|
||||||
toast.error(t("toasts.openPushWindowFailed"), {
|
description: msg,
|
||||||
description: msg,
|
})
|
||||||
})
|
})
|
||||||
})
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<Upload className="size-3.5" />
|
||||||
<Upload className="size-3.5" />
|
{tCommon("push")}
|
||||||
{tCommon("push")}
|
</ContextMenuItem>
|
||||||
</ContextMenuItem>
|
</ContextMenuContent>
|
||||||
</ContextMenuContent>
|
</ContextMenu>
|
||||||
</ContextMenu>
|
)
|
||||||
)
|
})}
|
||||||
})}
|
</div>
|
||||||
</div>
|
</ScrollArea>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
<ContextMenuContent>
|
<ContextMenuContent>
|
||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from "@/components/ui/collapsible"
|
} from "@/components/ui/collapsible"
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
function isRemovedFileDiff(diff: string | null): boolean {
|
function isRemovedFileDiff(diff: string | null): boolean {
|
||||||
@@ -290,9 +291,9 @@ export function SessionFilesTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto scrollbar-thin px-2">
|
<ScrollArea className="flex-1 min-h-0 px-2">
|
||||||
<SessionFilesContent conversationId={conversationId} />
|
<SessionFilesContent conversationId={conversationId} />
|
||||||
</div>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
24
src/components/overlay-scrollbars-init.tsx
Normal file
24
src/components/overlay-scrollbars-init.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect } from "react"
|
||||||
|
import { useOverlayScrollbars } from "overlayscrollbars-react"
|
||||||
|
|
||||||
|
export function OverlayScrollbarsInit() {
|
||||||
|
const [init] = useOverlayScrollbars({
|
||||||
|
options: {
|
||||||
|
scrollbars: {
|
||||||
|
theme: "os-theme-codeg",
|
||||||
|
autoHide: "leave",
|
||||||
|
clickScroll: true,
|
||||||
|
},
|
||||||
|
overflow: { x: "hidden" },
|
||||||
|
},
|
||||||
|
defer: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
init(document.body)
|
||||||
|
}, [init])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -1,55 +1,59 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import { useMemo } from "react"
|
||||||
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
|
import {
|
||||||
|
OverlayScrollbarsComponent,
|
||||||
|
type OverlayScrollbarsComponentRef,
|
||||||
|
} from "overlayscrollbars-react"
|
||||||
|
import type { OverlayScrollbarsComponentProps } from "overlayscrollbars-react"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
type ScrollAreaProps = {
|
||||||
|
children: React.ReactNode
|
||||||
|
className?: string
|
||||||
|
x?: "scroll" | "hidden"
|
||||||
|
y?: "scroll" | "hidden"
|
||||||
|
onScroll?: (event: Event) => void
|
||||||
|
ref?: React.Ref<OverlayScrollbarsComponentRef>
|
||||||
|
}
|
||||||
|
|
||||||
function ScrollArea({
|
const BASE_OPTIONS: OverlayScrollbarsComponentProps["options"] = {
|
||||||
className,
|
scrollbars: {
|
||||||
|
theme: "os-theme-codeg",
|
||||||
|
autoHide: "leave",
|
||||||
|
clickScroll: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScrollArea({
|
||||||
children,
|
children,
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
|
||||||
return (
|
|
||||||
<ScrollAreaPrimitive.Root
|
|
||||||
data-slot="scroll-area"
|
|
||||||
className={cn("relative", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ScrollAreaPrimitive.Viewport
|
|
||||||
data-slot="scroll-area-viewport"
|
|
||||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</ScrollAreaPrimitive.Viewport>
|
|
||||||
<ScrollBar />
|
|
||||||
<ScrollAreaPrimitive.Corner />
|
|
||||||
</ScrollAreaPrimitive.Root>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ScrollBar({
|
|
||||||
className,
|
className,
|
||||||
orientation = "vertical",
|
x = "hidden",
|
||||||
...props
|
y = "scroll",
|
||||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
onScroll,
|
||||||
|
ref,
|
||||||
|
}: ScrollAreaProps) {
|
||||||
|
const options = useMemo<OverlayScrollbarsComponentProps["options"]>(
|
||||||
|
() => ({
|
||||||
|
...BASE_OPTIONS,
|
||||||
|
overflow: { x, y },
|
||||||
|
}),
|
||||||
|
[x, y]
|
||||||
|
)
|
||||||
|
|
||||||
|
const events = useMemo<OverlayScrollbarsComponentProps["events"]>(
|
||||||
|
() => (onScroll ? { scroll: (_instance, event) => onScroll(event) } : {}),
|
||||||
|
[onScroll]
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
<OverlayScrollbarsComponent
|
||||||
data-slot="scroll-area-scrollbar"
|
ref={ref}
|
||||||
data-orientation={orientation}
|
className={className}
|
||||||
orientation={orientation}
|
options={options}
|
||||||
className={cn(
|
events={events}
|
||||||
"data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent flex touch-none p-px transition-colors select-none",
|
defer
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
>
|
||||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
{children}
|
||||||
data-slot="scroll-area-thumb"
|
</OverlayScrollbarsComponent>
|
||||||
className="rounded-full bg-border relative flex-1"
|
|
||||||
/>
|
|
||||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export { ScrollArea, ScrollBar }
|
|
||||||
|
|||||||
Reference in New Issue
Block a user