Initial commit

This commit is contained in:
xggz
2026-03-06 22:56:13 +08:00
commit 54d1097b41
273 changed files with 92457 additions and 0 deletions

View File

@@ -0,0 +1,101 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import { Reorder } from "motion/react"
import { useTabContext } from "@/contexts/tab-context"
import { useWorkspaceContext } from "@/contexts/workspace-context"
import { useShortcutSettings } from "@/hooks/use-shortcut-settings"
import { matchShortcutEvent } from "@/lib/keyboard-shortcuts"
import { TabItem } from "./tab-item"
import { cn } from "@/lib/utils"
export function TabBar() {
const {
tabs,
activeTabId,
switchTab,
closeTab,
closeOtherTabs,
closeAllTabs,
pinTab,
reorderTabs,
} = useTabContext()
const { mode, activePane } = useWorkspaceContext()
const { shortcuts } = useShortcutSettings()
const scrollRef = useRef<HTMLDivElement>(null)
const [isHovered, setIsHovered] = useState(false)
const handleWheel = useCallback((e: React.WheelEvent<HTMLDivElement>) => {
if (e.deltaY !== 0 && scrollRef.current) {
e.preventDefault()
scrollRef.current.scrollLeft += e.deltaY
}
}, [])
useEffect(() => {
if (!activeTabId || !scrollRef.current) return
const el = scrollRef.current.querySelector(`[data-tab-id="${activeTabId}"]`)
el?.scrollIntoView({ block: "nearest", inline: "nearest" })
}, [activeTabId])
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
const shouldHandleShortcut =
mode === "conversation" ||
(mode === "fusion" && activePane === "conversation")
if (!shouldHandleShortcut) return
if (!matchShortcutEvent(event, shortcuts.close_current_tab)) return
if (!activeTabId) return
event.preventDefault()
closeTab(activeTabId)
}
window.addEventListener("keydown", onKeyDown)
return () => {
window.removeEventListener("keydown", onKeyDown)
}
}, [activePane, activeTabId, closeTab, mode, shortcuts.close_current_tab])
if (tabs.length === 0) return null
return (
<Reorder.Group
as="div"
ref={scrollRef}
role="tablist"
axis="x"
values={tabs}
onReorder={reorderTabs}
onWheel={handleWheel}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className={cn(
"h-10 pt-1.5 px-1.5 flex items-stretch gap-1.5 border-b border-border",
"overflow-x-scroll",
isHovered
? [
"pb-0.5",
"[&::-webkit-scrollbar]:h-1",
"[&::-webkit-scrollbar-track]:bg-transparent",
"[&::-webkit-scrollbar-thumb]:rounded-full",
"[&::-webkit-scrollbar-thumb]:bg-border",
]
: ["pb-1.5", "[&::-webkit-scrollbar]:h-0"]
)}
>
{tabs.map((tab) => (
<TabItem
key={tab.id}
tab={tab}
isActive={tab.id === activeTabId}
onSwitch={switchTab}
onClose={closeTab}
onCloseOthers={closeOtherTabs}
onCloseAll={closeAllTabs}
onPin={pinTab}
/>
))}
</Reorder.Group>
)
}

View File

@@ -0,0 +1,149 @@
"use client"
import { memo, useCallback, useRef } from "react"
import { Reorder } from "motion/react"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
import { STATUS_COLORS } from "@/lib/types"
import type { ConversationStatus } from "@/lib/types"
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import type { TabItem as TabItemData } from "@/contexts/tab-context"
interface TabItemProps {
tab: TabItemData
isActive: boolean
onSwitch: (tabId: string) => void
onClose: (tabId: string) => void
onCloseOthers: (tabId: string) => void
onCloseAll: () => void
onPin: (tabId: string) => void
}
export const TabItem = memo(function TabItem({
tab,
isActive,
onSwitch,
onClose,
onCloseOthers,
onCloseAll,
onPin,
}: TabItemProps) {
const isDragging = useRef(false)
const itemRef = useRef<HTMLDivElement>(null)
const clearResidualStyles = useCallback(() => {
const el = itemRef.current
if (!el) return
el.style.transform = ""
el.style.zIndex = ""
el.style.position = ""
el.style.userSelect = ""
}, [])
const handleClick = useCallback(() => {
if (isDragging.current) return
onSwitch(tab.id)
}, [onSwitch, tab.id])
const handleDoubleClick = useCallback(() => {
if (isDragging.current) return
if (!tab.isPinned) {
onPin(tab.id)
}
}, [onPin, tab.id, tab.isPinned])
const handleClose = useCallback(() => {
onClose(tab.id)
}, [onClose, tab.id])
const handleCloseOthers = useCallback(() => {
onCloseOthers(tab.id)
}, [onCloseOthers, tab.id])
return (
<Reorder.Item
ref={itemRef}
as="div"
value={tab}
data-tab-id={tab.id}
onDragStart={() => {
isDragging.current = true
}}
onDragEnd={() => {
setTimeout(() => {
isDragging.current = false
clearResidualStyles()
}, 200)
}}
onLayoutAnimationComplete={clearResidualStyles}
className="shrink-0 rounded-full cursor-grab active:cursor-grabbing active:opacity-90 active:shadow-md active:z-50"
>
<ContextMenu>
<ContextMenuTrigger asChild>
<div
role="tab"
aria-selected={isActive}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
className={cn(
"group/tab relative flex items-center h-full gap-1.5 px-3 text-xs rounded-full",
"cursor-pointer select-none shrink-0",
"hover:bg-primary/8 transition-colors",
isActive
? "bg-primary/10 text-foreground"
: "text-muted-foreground"
)}
>
<span
className={cn(
"w-2 h-2 rounded-full shrink-0",
tab.status
? STATUS_COLORS[tab.status as ConversationStatus]
: "bg-gray-400 dark:bg-gray-500"
)}
/>
<span
className={cn(
"truncate max-w-[140px]",
!tab.isPinned && "[font-style:oblique]"
)}
title={tab.title}
>
{tab.title}
</span>
<button
type="button"
className={cn(
"rounded-full p-0.5 hover:bg-muted",
isActive
? "opacity-100"
: "opacity-0 group-hover/tab:opacity-100"
)}
onClick={(event) => {
event.stopPropagation()
handleClose()
}}
aria-label="Close conversation tab"
>
<X className="h-3 w-3" />
</button>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={handleClose}></ContextMenuItem>
<ContextMenuItem onSelect={handleCloseOthers}>
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onSelect={onCloseAll}></ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</Reorder.Item>
)
})