mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-20 13:53:32 +08:00
fix: correct windows tab bar overflow alignment
This commit is contained in:
parent
d3c62a8bc5
commit
fb011588f9
136
desktop/src/components/layout/TabBar.test.tsx
Normal file
136
desktop/src/components/layout/TabBar.test.tsx
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import '@testing-library/jest-dom'
|
||||||
|
|
||||||
|
vi.mock('../../i18n', () => ({
|
||||||
|
useTranslation: () => (key: string) => {
|
||||||
|
const translations: Record<string, string> = {
|
||||||
|
'tabs.close': 'Close',
|
||||||
|
'tabs.closeOthers': 'Close Others',
|
||||||
|
'tabs.closeLeft': 'Close Left',
|
||||||
|
'tabs.closeRight': 'Close Right',
|
||||||
|
'tabs.closeAll': 'Close All',
|
||||||
|
'tabs.closeConfirmTitle': 'Session Running',
|
||||||
|
'tabs.closeConfirmMessage': 'Still running',
|
||||||
|
'tabs.closeConfirmKeep': 'Keep Running',
|
||||||
|
'tabs.closeConfirmStop': 'Stop & Close',
|
||||||
|
'common.cancel': 'Cancel',
|
||||||
|
}
|
||||||
|
|
||||||
|
return translations[key] ?? key
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('./WindowControls', () => ({
|
||||||
|
WindowControls: () => <div data-testid="window-controls" />,
|
||||||
|
showWindowControls: true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('TabBar', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
class ResizeObserverMock {
|
||||||
|
constructor(_callback: ResizeObserverCallback) {}
|
||||||
|
|
||||||
|
observe(_target: Element) {}
|
||||||
|
|
||||||
|
disconnect() {}
|
||||||
|
unobserve() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'ResizeObserver', {
|
||||||
|
configurable: true,
|
||||||
|
value: ResizeObserverMock,
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.resetModules()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
const { useTabStore } = await import('../../stores/tabStore')
|
||||||
|
const { useChatStore } = await import('../../stores/chatStore')
|
||||||
|
|
||||||
|
useTabStore.setState({ tabs: [], activeTabId: null })
|
||||||
|
useChatStore.setState({
|
||||||
|
sessions: {},
|
||||||
|
} as Partial<ReturnType<typeof useChatStore.getState>>)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the overflow button flush against window controls on Windows', async () => {
|
||||||
|
const { TabBar } = await import('./TabBar')
|
||||||
|
const { useTabStore } = await import('../../stores/tabStore')
|
||||||
|
const { useChatStore } = await import('../../stores/chatStore')
|
||||||
|
|
||||||
|
useTabStore.setState({
|
||||||
|
tabs: [
|
||||||
|
{ sessionId: 'tab-1', title: 'Untitled Session', type: 'session', status: 'idle' },
|
||||||
|
{ sessionId: 'tab-2', title: 'Settings', type: 'settings', status: 'idle' },
|
||||||
|
{ sessionId: 'tab-3', title: 'hello', type: 'session', status: 'idle' },
|
||||||
|
{ sessionId: 'tab-4', title: 'overflow', type: 'session', status: 'idle' },
|
||||||
|
],
|
||||||
|
activeTabId: 'tab-1',
|
||||||
|
})
|
||||||
|
useChatStore.setState({
|
||||||
|
sessions: {},
|
||||||
|
disconnectSession: vi.fn(),
|
||||||
|
} as Partial<ReturnType<typeof useChatStore.getState>>)
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
render(<TabBar />)
|
||||||
|
})
|
||||||
|
|
||||||
|
const scrollRegion = screen.getByTestId('tab-bar').querySelector('.overflow-x-hidden')
|
||||||
|
expect(scrollRegion).toBeInTheDocument()
|
||||||
|
|
||||||
|
Object.defineProperty(scrollRegion!, 'clientWidth', {
|
||||||
|
configurable: true,
|
||||||
|
get: () => 240,
|
||||||
|
})
|
||||||
|
Object.defineProperty(scrollRegion!, 'scrollWidth', {
|
||||||
|
configurable: true,
|
||||||
|
get: () => 720,
|
||||||
|
})
|
||||||
|
Object.defineProperty(scrollRegion!, 'scrollLeft', {
|
||||||
|
configurable: true,
|
||||||
|
get: () => 0,
|
||||||
|
})
|
||||||
|
Object.defineProperty(scrollRegion!, 'scrollBy', {
|
||||||
|
configurable: true,
|
||||||
|
value: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
fireEvent.scroll(scrollRegion!)
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('window-controls')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('chevron_right').closest('button')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
const rightButton = screen.getByText('chevron_right').closest('button')
|
||||||
|
expect(rightButton?.nextElementSibling).toBe(screen.getByTestId('window-controls'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks the tab bar as a native drag region', async () => {
|
||||||
|
const { TabBar } = await import('./TabBar')
|
||||||
|
const { useTabStore } = await import('../../stores/tabStore')
|
||||||
|
const { useChatStore } = await import('../../stores/chatStore')
|
||||||
|
|
||||||
|
useTabStore.setState({
|
||||||
|
tabs: [
|
||||||
|
{ sessionId: 'tab-1', title: 'Untitled Session', type: 'session', status: 'idle' },
|
||||||
|
],
|
||||||
|
activeTabId: 'tab-1',
|
||||||
|
})
|
||||||
|
useChatStore.setState({
|
||||||
|
sessions: {},
|
||||||
|
disconnectSession: vi.fn(),
|
||||||
|
} as Partial<ReturnType<typeof useChatStore.getState>>)
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
render(<TabBar />)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByTestId('tab-bar')).toHaveAttribute('data-tauri-drag-region')
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -5,7 +5,6 @@ import { useTranslation } from '../../i18n'
|
|||||||
import { WindowControls, showWindowControls } from './WindowControls'
|
import { WindowControls, showWindowControls } from './WindowControls'
|
||||||
|
|
||||||
const TAB_WIDTH = 180
|
const TAB_WIDTH = 180
|
||||||
const isTauri = typeof window !== 'undefined' && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window)
|
|
||||||
|
|
||||||
export function TabBar() {
|
export function TabBar() {
|
||||||
const tabs = useTabStore((s) => s.tabs)
|
const tabs = useTabStore((s) => s.tabs)
|
||||||
@ -15,8 +14,6 @@ export function TabBar() {
|
|||||||
const disconnectSession = useChatStore((s) => s.disconnectSession)
|
const disconnectSession = useChatStore((s) => s.disconnectSession)
|
||||||
|
|
||||||
const moveTab = useTabStore((s) => s.moveTab)
|
const moveTab = useTabStore((s) => s.moveTab)
|
||||||
const startDraggingRef = useRef<(() => Promise<void>) | null>(null)
|
|
||||||
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
const [canScrollLeft, setCanScrollLeft] = useState(false)
|
const [canScrollLeft, setCanScrollLeft] = useState(false)
|
||||||
const [canScrollRight, setCanScrollRight] = useState(false)
|
const [canScrollRight, setCanScrollRight] = useState(false)
|
||||||
@ -53,16 +50,6 @@ export function TabBar() {
|
|||||||
return () => document.removeEventListener('click', close)
|
return () => document.removeEventListener('click', close)
|
||||||
}, [contextMenu])
|
}, [contextMenu])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isTauri) return
|
|
||||||
import(/* @vite-ignore */ '@tauri-apps/api/window')
|
|
||||||
.then(({ getCurrentWindow }) => {
|
|
||||||
const win = getCurrentWindow()
|
|
||||||
startDraggingRef.current = () => win.startDragging()
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const scroll = (direction: 'left' | 'right') => {
|
const scroll = (direction: 'left' | 'right') => {
|
||||||
const el = scrollRef.current
|
const el = scrollRef.current
|
||||||
if (!el) return
|
if (!el) return
|
||||||
@ -158,19 +145,13 @@ export function TabBar() {
|
|||||||
setDragOverIndex(null)
|
setDragOverIndex(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTabBarDrag = useCallback((e: React.MouseEvent) => {
|
|
||||||
if ((e.target as HTMLElement).closest('button, input, textarea, select, a, [role="button"], [draggable="true"]')) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
startDraggingRef.current?.()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
if (tabs.length === 0 && !showWindowControls) return null
|
if (tabs.length === 0 && !showWindowControls) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
data-testid="tab-bar"
|
||||||
|
data-tauri-drag-region
|
||||||
className="flex items-stretch bg-[var(--color-surface-container)] min-h-[37px] select-none border-b border-[var(--color-border)]"
|
className="flex items-stretch bg-[var(--color-surface-container)] min-h-[37px] select-none border-b border-[var(--color-border)]"
|
||||||
onMouseDown={handleTabBarDrag}
|
|
||||||
>
|
>
|
||||||
|
|
||||||
{canScrollLeft && (
|
{canScrollLeft && (
|
||||||
@ -203,8 +184,6 @@ export function TabBar() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Windows: drag spacer fills remaining area + custom window controls */}
|
|
||||||
{showWindowControls && <div className="flex-1" />}
|
|
||||||
<WindowControls />
|
<WindowControls />
|
||||||
|
|
||||||
{contextMenu && (
|
{contextMenu && (
|
||||||
|
|||||||
@ -43,7 +43,7 @@ export function WindowControls() {
|
|||||||
if (!showWindowControls || !win) return null
|
if (!showWindowControls || !win) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-stretch flex-shrink-0 -my-px">
|
<div data-testid="window-controls" className="flex items-stretch flex-shrink-0 -my-px">
|
||||||
{/* Minimize */}
|
{/* Minimize */}
|
||||||
<button
|
<button
|
||||||
onClick={() => runWindowAction(() => win.minimize())}
|
onClick={() => runWindowAction(() => win.minimize())}
|
||||||
|
|||||||
@ -229,6 +229,9 @@ html, body, #root {
|
|||||||
button, input, textarea, select, a, [role="button"] {
|
button, input, textarea, select, a, [role="button"] {
|
||||||
-webkit-app-region: no-drag;
|
-webkit-app-region: no-drag;
|
||||||
}
|
}
|
||||||
|
[draggable="true"] {
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
/* Custom shadow from prototype */
|
/* Custom shadow from prototype */
|
||||||
.custom-shadow {
|
.custom-shadow {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user