mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
Fix tab switching after terminal tab reorder (#6395)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -36,13 +36,6 @@ vi.mock('@dnd-kit/sortable', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./tab-strip-pointer-activation', () => ({
|
||||
useTabStripPointerActivation: () => ({
|
||||
isPressed: false,
|
||||
onPointerDown: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('lucide-react', () => ({
|
||||
Columns2: function Columns2(props: Record<string, unknown>) {
|
||||
return { type: 'Columns2', props }
|
||||
|
||||
@@ -20,13 +20,11 @@ import {
|
||||
getDropIndicatorClasses,
|
||||
getTabRootStateClasses,
|
||||
getTabStripBorderClasses,
|
||||
showsTabSelectionChrome,
|
||||
type DropIndicator
|
||||
} from './drop-indicator'
|
||||
import { preventMiddleButtonDefault } from './middle-button-default-guard'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules'
|
||||
import { useTabStripPointerActivation } from './tab-strip-pointer-activation'
|
||||
import { TabWorkspaceLayoutMenuSection } from './TabWorkspaceLayoutMenuSection'
|
||||
|
||||
function formatBrowserTabUrlLabel(url: string): string {
|
||||
@@ -171,11 +169,6 @@ export default function BrowserTab({
|
||||
return () => window.removeEventListener('blur', dismiss)
|
||||
}, [menuOpen])
|
||||
|
||||
const { isPressed, onPointerDown: onTabPointerDown } = useTabStripPointerActivation({
|
||||
onActivate
|
||||
})
|
||||
const showsSelectionChrome = showsTabSelectionChrome(isActive, isPressed)
|
||||
|
||||
const tabRoot = (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
@@ -183,12 +176,13 @@ export default function BrowserTab({
|
||||
data-pinned={isPinned ? 'true' : 'false'}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className={`group relative flex items-center h-full px-1.5 text-xs cursor-pointer select-none outline-none focus:outline-none focus-visible:outline-none ${getTabStripBorderClasses(hasTabsToRight, { includeTopBorder: includeTopTabBorder })} ${getDropIndicatorClasses(dropIndicator ?? null)} ${getTabRootStateClasses(isActive, isPressed)}`}
|
||||
className={`group relative flex items-center h-full px-1.5 text-xs cursor-pointer select-none outline-none focus:outline-none focus-visible:outline-none ${getTabStripBorderClasses(hasTabsToRight, { includeTopBorder: includeTopTabBorder })} ${getDropIndicatorClasses(dropIndicator ?? null)} ${getTabRootStateClasses(isActive)}`}
|
||||
onPointerDown={(e) => {
|
||||
onTabPointerDown(
|
||||
e,
|
||||
listeners?.onPointerDown as ((event: React.PointerEvent<Element>) => void) | undefined
|
||||
)
|
||||
if (e.button !== 0) {
|
||||
return
|
||||
}
|
||||
onActivate()
|
||||
listeners?.onPointerDown?.(e)
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
if (e.button === 1) {
|
||||
@@ -207,7 +201,7 @@ export default function BrowserTab({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{showsSelectionChrome && <span className={ACTIVE_TAB_INDICATOR_CLASSES} aria-hidden />}
|
||||
{isActive && <span className={ACTIVE_TAB_INDICATOR_CLASSES} aria-hidden />}
|
||||
{/* Why: the browser tab icon is the only non-terminal, non-editor
|
||||
surface in the tab strip. Coloring the Globe blue (matching the
|
||||
in-app browser's identity and the default tab insertion bar)
|
||||
@@ -224,7 +218,7 @@ export default function BrowserTab({
|
||||
{!isPinned && (
|
||||
<button
|
||||
className={`flex items-center justify-center w-4 h-4 rounded-sm shrink-0 ${
|
||||
showsSelectionChrome
|
||||
isActive
|
||||
? 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
||||
: 'text-transparent group-hover:text-muted-foreground hover:!text-foreground hover:!bg-muted'
|
||||
}`}
|
||||
|
||||
@@ -55,13 +55,6 @@ vi.mock('@dnd-kit/sortable', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./tab-strip-pointer-activation', () => ({
|
||||
useTabStripPointerActivation: () => ({
|
||||
isPressed: false,
|
||||
onPointerDown: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('lucide-react', () => ({
|
||||
Columns2: function Columns2(props: Record<string, unknown>) {
|
||||
return { type: 'Columns2', props }
|
||||
@@ -189,8 +182,7 @@ vi.mock('./drop-indicator', () => ({
|
||||
ACTIVE_TAB_INDICATOR_CLASSES: 'active-tab-indicator',
|
||||
getDropIndicatorClasses: () => '',
|
||||
getTabStripBorderClasses: () => '',
|
||||
getTabRootStateClasses: () => '',
|
||||
showsTabSelectionChrome: () => true
|
||||
getTabRootStateClasses: () => ''
|
||||
}))
|
||||
|
||||
vi.mock('@/components/editor/markdown-preview-controls', () => ({
|
||||
|
||||
@@ -22,14 +22,12 @@ import {
|
||||
getDropIndicatorClasses,
|
||||
getTabRootStateClasses,
|
||||
getTabStripBorderClasses,
|
||||
showsTabSelectionChrome,
|
||||
type DropIndicator
|
||||
} from './drop-indicator'
|
||||
import { canOpenMarkdownPreview } from '@/components/editor/markdown-preview-controls'
|
||||
import { EditorFileTabContextMenu } from './EditorFileTabContextMenu'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules'
|
||||
import { useTabStripPointerActivation } from './tab-strip-pointer-activation'
|
||||
import { EditorFileTabCloseButton } from './EditorFileTabCloseButton'
|
||||
|
||||
export default function EditorFileTab({
|
||||
@@ -201,11 +199,6 @@ export default function EditorFileTab({
|
||||
return () => window.removeEventListener('blur', dismiss)
|
||||
}, [menuOpen])
|
||||
|
||||
const { isPressed, onPointerDown: onTabPointerDown } = useTabStripPointerActivation({
|
||||
onActivate,
|
||||
disabled: isRenaming
|
||||
})
|
||||
const showsSelectionChrome = showsTabSelectionChrome(isActive, isPressed)
|
||||
const dragListeners = isRenaming ? undefined : listeners
|
||||
|
||||
const tabRoot = (
|
||||
@@ -215,12 +208,13 @@ export default function EditorFileTab({
|
||||
data-pinned={isPinned ? 'true' : 'false'}
|
||||
{...attributes}
|
||||
{...dragListeners}
|
||||
className={`group relative flex items-center h-full px-1.5 text-xs cursor-pointer select-none outline-none focus:outline-none focus-visible:outline-none ${getTabStripBorderClasses(hasTabsToRight, { includeTopBorder: includeTopTabBorder })} ${getDropIndicatorClasses(dropIndicator ?? null)} ${getTabRootStateClasses(isActive, isPressed)}`}
|
||||
className={`group relative flex items-center h-full px-1.5 text-xs cursor-pointer select-none outline-none focus:outline-none focus-visible:outline-none ${getTabStripBorderClasses(hasTabsToRight, { includeTopBorder: includeTopTabBorder })} ${getDropIndicatorClasses(dropIndicator ?? null)} ${getTabRootStateClasses(isActive)}`}
|
||||
onPointerDown={(e) => {
|
||||
onTabPointerDown(
|
||||
e,
|
||||
dragListeners?.onPointerDown as ((event: React.PointerEvent<Element>) => void) | undefined
|
||||
)
|
||||
if (isRenaming || e.button !== 0) {
|
||||
return
|
||||
}
|
||||
onActivate()
|
||||
dragListeners?.onPointerDown?.(e)
|
||||
}}
|
||||
onDoubleClick={() => {
|
||||
if (file.isPreview && onMakePermanent) {
|
||||
@@ -244,26 +238,26 @@ export default function EditorFileTab({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{showsSelectionChrome && <span className={ACTIVE_TAB_INDICATOR_CLASSES} aria-hidden />}
|
||||
{isActive && <span className={ACTIVE_TAB_INDICATOR_CLASSES} aria-hidden />}
|
||||
{isConflictReview ? (
|
||||
<ShieldAlert
|
||||
className={`w-3 h-3 mr-1 shrink-0 ${showsSelectionChrome ? 'text-orange-400' : 'text-orange-400/70'}`}
|
||||
className={`w-3 h-3 mr-1 shrink-0 ${isActive ? 'text-orange-400' : 'text-orange-400/70'}`}
|
||||
/>
|
||||
) : isCheckDetails ? (
|
||||
<ListChecks
|
||||
className={`w-3 h-3 mr-1 shrink-0 ${showsSelectionChrome ? 'text-foreground' : 'text-muted-foreground'}`}
|
||||
className={`w-3 h-3 mr-1 shrink-0 ${isActive ? 'text-foreground' : 'text-muted-foreground'}`}
|
||||
/>
|
||||
) : isDiff ? (
|
||||
<GitCompareArrows
|
||||
className={`w-3 h-3 mr-1 shrink-0 ${showsSelectionChrome ? 'text-foreground' : 'text-muted-foreground'}`}
|
||||
className={`w-3 h-3 mr-1 shrink-0 ${isActive ? 'text-foreground' : 'text-muted-foreground'}`}
|
||||
/>
|
||||
) : isMarkdownPreviewTab ? (
|
||||
<Eye
|
||||
className={`w-3.5 h-3.5 mr-1.5 shrink-0 ${showsSelectionChrome ? 'text-foreground' : 'text-muted-foreground'}`}
|
||||
className={`w-3.5 h-3.5 mr-1.5 shrink-0 ${isActive ? 'text-foreground' : 'text-muted-foreground'}`}
|
||||
/>
|
||||
) : (
|
||||
<FileIcon
|
||||
className={`w-3 h-3 mr-1 shrink-0 ${showsSelectionChrome ? 'text-foreground' : 'text-muted-foreground'}`}
|
||||
className={`w-3 h-3 mr-1 shrink-0 ${isActive ? 'text-foreground' : 'text-muted-foreground'}`}
|
||||
/>
|
||||
)}
|
||||
{isPinned && <Pin className="mr-1 size-3 shrink-0 text-muted-foreground" aria-hidden />}
|
||||
@@ -347,7 +341,7 @@ export default function EditorFileTab({
|
||||
{!isPinned && (
|
||||
<EditorFileTabCloseButton
|
||||
fileIsDirty={file.isDirty}
|
||||
showsSelectionChrome={showsSelectionChrome}
|
||||
showsSelectionChrome={isActive}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -71,13 +71,6 @@ vi.mock('@dnd-kit/sortable', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('./tab-strip-pointer-activation', () => ({
|
||||
useTabStripPointerActivation: () => ({
|
||||
isPressed: false,
|
||||
onPointerDown: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('lucide-react', () => ({
|
||||
Columns2: function Columns2(props: Record<string, unknown>) {
|
||||
return { type: 'Columns2', props }
|
||||
@@ -178,8 +171,7 @@ vi.mock('./drop-indicator', () => ({
|
||||
ACTIVE_TAB_INDICATOR_CLASSES: 'active-tab-indicator',
|
||||
getDropIndicatorClasses: () => '',
|
||||
getTabStripBorderClasses: () => '',
|
||||
getTabRootStateClasses: () => '',
|
||||
showsTabSelectionChrome: () => true
|
||||
getTabRootStateClasses: () => ''
|
||||
}))
|
||||
|
||||
vi.mock('./middle-button-default-guard', () => ({
|
||||
|
||||
@@ -17,14 +17,12 @@ import {
|
||||
getDropIndicatorClasses,
|
||||
getTabRootStateClasses,
|
||||
getTabStripBorderClasses,
|
||||
showsTabSelectionChrome,
|
||||
type DropIndicator
|
||||
} from './drop-indicator'
|
||||
import { preventMiddleButtonDefault } from './middle-button-default-guard'
|
||||
import { SortableTabContextMenu } from './SortableTabContextMenu'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules'
|
||||
import { useTabStripPointerActivation } from './tab-strip-pointer-activation'
|
||||
import { useShortcutKeyDetails } from '@/hooks/useShortcutLabel'
|
||||
|
||||
type SortableTabProps = {
|
||||
@@ -201,14 +199,6 @@ export default function SortableTab({
|
||||
// so dnd-kit's a11y attributes (aria-roledescription, etc.) remain on the element — only
|
||||
// the pointer listeners are gated so a drag can't start while typing.
|
||||
const dragListeners = isEditing ? undefined : listeners
|
||||
const handleActivate = useCallback(() => {
|
||||
onActivate(tab.id)
|
||||
}, [onActivate, tab.id])
|
||||
const { isPressed, onPointerDown: onTabPointerDown } = useTabStripPointerActivation({
|
||||
onActivate: handleActivate,
|
||||
disabled: isEditing
|
||||
})
|
||||
const showsSelectionChrome = showsTabSelectionChrome(isActive, isPressed)
|
||||
const closeShortcut = useShortcutKeyDetails('tab.close')
|
||||
const tabTitle = tab.customTitle ?? tab.title
|
||||
const tabRoot = (
|
||||
@@ -224,7 +214,6 @@ export default function SortableTab({
|
||||
// pass even if the tab-bar render path had silently broken (the same
|
||||
// tautology that let PR #1186's render crash ship past E2E in #1193).
|
||||
data-active={isActive ? 'true' : 'false'}
|
||||
data-pressed={isPressed ? 'true' : 'false'}
|
||||
{...attributes}
|
||||
{...dragListeners}
|
||||
// Why: on unread activity, tint the whole tab with a subtle amber
|
||||
@@ -234,7 +223,7 @@ export default function SortableTab({
|
||||
// tab still reads as "selected + has activity". The wash is
|
||||
// rendered as an absolutely-positioned child below so the ::after
|
||||
// pseudo-element stays free for the drop indicator.
|
||||
className={`group relative flex items-center h-full px-1.5 text-xs cursor-pointer select-none outline-none focus:outline-none focus-visible:outline-none ${getTabStripBorderClasses(hasTabsToRight, { includeTopBorder: includeTopTabBorder })} ${getDropIndicatorClasses(dropIndicator ?? null)} ${getTabRootStateClasses(isActive, isPressed)}`}
|
||||
className={`group relative flex items-center h-full px-1.5 text-xs cursor-pointer select-none outline-none focus:outline-none focus-visible:outline-none ${getTabStripBorderClasses(hasTabsToRight, { includeTopBorder: includeTopTabBorder })} ${getDropIndicatorClasses(dropIndicator ?? null)} ${getTabRootStateClasses(isActive)}`}
|
||||
onDoubleClick={(e) => {
|
||||
if (isEditing) {
|
||||
return
|
||||
@@ -243,10 +232,11 @@ export default function SortableTab({
|
||||
handleRenameOpen()
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
onTabPointerDown(
|
||||
e,
|
||||
dragListeners?.onPointerDown as ((event: React.PointerEvent<Element>) => void) | undefined
|
||||
)
|
||||
if (isEditing || e.button !== 0) {
|
||||
return
|
||||
}
|
||||
onActivate(tab.id)
|
||||
dragListeners?.onPointerDown?.(e)
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
// Why: prevent default browser middle-click behavior (auto-scroll)
|
||||
@@ -272,7 +262,7 @@ export default function SortableTab({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{showsSelectionChrome && <span className={ACTIVE_TAB_INDICATOR_CLASSES} aria-hidden />}
|
||||
{isActive && <span className={ACTIVE_TAB_INDICATOR_CLASSES} aria-hidden />}
|
||||
{showActivityAffordance && (
|
||||
// Why: amber wash for unread tabs. Rendered as a real DOM child so
|
||||
// both drop indicators (::before left / ::after right in
|
||||
@@ -294,7 +284,7 @@ export default function SortableTab({
|
||||
// Why: coding-agent tabs should read as Claude/Codex/etc. while the
|
||||
// harness is running; plain shells keep the generic terminal tile.
|
||||
<span
|
||||
className={`mr-1 inline-flex shrink-0 ${showsSelectionChrome ? '' : 'opacity-70'}`}
|
||||
className={`mr-1 inline-flex shrink-0 ${isActive ? '' : 'opacity-70'}`}
|
||||
data-agent-icon={tabAgent}
|
||||
aria-hidden
|
||||
>
|
||||
@@ -310,7 +300,7 @@ export default function SortableTab({
|
||||
// on inactive tabs to match the existing text treatment without
|
||||
// desaturating the brand colors beyond recognition.
|
||||
<span
|
||||
className={`mr-1 inline-flex shrink-0 ${showsSelectionChrome ? '' : 'opacity-70'}`}
|
||||
className={`mr-1 inline-flex shrink-0 ${isActive ? '' : 'opacity-70'}`}
|
||||
data-shell-icon={shellForIcon ?? 'generic'}
|
||||
aria-hidden
|
||||
>
|
||||
@@ -390,7 +380,7 @@ export default function SortableTab({
|
||||
{isExpanded && !isEditing && (
|
||||
<button
|
||||
className={`mr-1 flex items-center justify-center w-4 h-4 rounded-sm shrink-0 ${
|
||||
showsSelectionChrome
|
||||
isActive
|
||||
? 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
||||
: 'text-transparent group-hover:text-muted-foreground hover:!text-foreground hover:!bg-muted'
|
||||
}`}
|
||||
@@ -410,7 +400,7 @@ export default function SortableTab({
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className={`relative z-10 flex items-center justify-center w-4 h-4 rounded-sm shrink-0 ${
|
||||
showsSelectionChrome
|
||||
isActive
|
||||
? 'text-muted-foreground hover:text-foreground hover:bg-muted focus-visible:text-foreground focus-visible:bg-muted'
|
||||
: 'text-transparent group-hover:text-muted-foreground hover:!text-foreground hover:!bg-muted focus-visible:!text-foreground focus-visible:!bg-muted'
|
||||
}`}
|
||||
|
||||
@@ -84,11 +84,4 @@ describe('getTabRootStateClasses', () => {
|
||||
expect(classes).toContain('text-muted-foreground')
|
||||
expect(classes).toContain('hover:text-foreground')
|
||||
})
|
||||
|
||||
it('returns the selected-tab surface treatment while pressed before activation', () => {
|
||||
const classes = getTabRootStateClasses(false, true)
|
||||
expect(classes).toContain('bg-[color-mix(in_srgb,var(--foreground)_6%,var(--card))]')
|
||||
expect(classes).toContain('text-foreground')
|
||||
expect(classes).not.toContain('hover:text-foreground')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28,12 +28,8 @@ export function getDropIndicatorClasses(dropIndicator: DropIndicator): string {
|
||||
export const ACTIVE_TAB_INDICATOR_CLASSES =
|
||||
'pointer-events-none absolute inset-x-0 bottom-0 h-[2px] bg-[color-mix(in_srgb,var(--foreground)_60%,var(--card))] z-10'
|
||||
|
||||
export function showsTabSelectionChrome(isActive: boolean, isPressed = false): boolean {
|
||||
return isActive || isPressed
|
||||
}
|
||||
|
||||
export function getTabRootStateClasses(isActive: boolean, isPressed = false): string {
|
||||
return showsTabSelectionChrome(isActive, isPressed)
|
||||
export function getTabRootStateClasses(isActive: boolean): string {
|
||||
return isActive
|
||||
? 'bg-[color-mix(in_srgb,var(--foreground)_6%,var(--card))] text-foreground'
|
||||
: 'bg-card text-muted-foreground hover:text-foreground'
|
||||
}
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { act, useRef, useState } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TabDragProvider } from '../tab-group/tab-drag-context'
|
||||
import { useTabStripPointerActivation } from './tab-strip-pointer-activation'
|
||||
|
||||
function Probe({ onActivate }: { onActivate: () => void }): React.JSX.Element {
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
const dragActiveRef = useRef(false)
|
||||
dragActiveRef.current = dragActive
|
||||
|
||||
return (
|
||||
<TabDragProvider isTabDragActive={dragActive} isTabDragActiveRef={dragActiveRef}>
|
||||
<ProbeButton onActivate={onActivate} onDragActiveChange={setDragActive} />
|
||||
</TabDragProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function ProbeButton({
|
||||
onActivate,
|
||||
onDragActiveChange
|
||||
}: {
|
||||
onActivate: () => void
|
||||
onDragActiveChange: (active: boolean) => void
|
||||
}): React.JSX.Element {
|
||||
const { isPressed, onPointerDown } = useTabStripPointerActivation({ onActivate })
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
data-pressed={isPressed ? 'true' : 'false'}
|
||||
onPointerDown={(event) => onPointerDown(event)}
|
||||
>
|
||||
Tab
|
||||
</button>
|
||||
<button type="button" onClick={() => onDragActiveChange(true)}>
|
||||
Start drag
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
let root: Root | null = null
|
||||
let container: HTMLDivElement | null = null
|
||||
|
||||
function renderProbe(onActivate = vi.fn()): {
|
||||
onActivate: ReturnType<typeof vi.fn>
|
||||
tabButton: HTMLButtonElement
|
||||
dragButton: HTMLButtonElement
|
||||
} {
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
act(() => {
|
||||
root?.render(<Probe onActivate={onActivate} />)
|
||||
})
|
||||
const buttons = container.querySelectorAll('button')
|
||||
return {
|
||||
onActivate,
|
||||
tabButton: buttons[0] as HTMLButtonElement,
|
||||
dragButton: buttons[1] as HTMLButtonElement
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchPointer(target: EventTarget, type: string, button = 0): void {
|
||||
target.dispatchEvent(new MouseEvent(type, { bubbles: true, button }))
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount())
|
||||
container?.remove()
|
||||
root = null
|
||||
container = null
|
||||
})
|
||||
|
||||
describe('useTabStripPointerActivation', () => {
|
||||
it('defers activation until pointerup', () => {
|
||||
const { onActivate, tabButton } = renderProbe()
|
||||
|
||||
act(() => dispatchPointer(tabButton, 'pointerdown'))
|
||||
expect(tabButton.dataset.pressed).toBe('true')
|
||||
expect(onActivate).not.toHaveBeenCalled()
|
||||
|
||||
act(() => dispatchPointer(window, 'pointerup'))
|
||||
expect(tabButton.dataset.pressed).toBe('false')
|
||||
expect(onActivate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('activates when the release event reports no changed button', () => {
|
||||
const { onActivate, tabButton } = renderProbe()
|
||||
|
||||
act(() => dispatchPointer(tabButton, 'pointerdown'))
|
||||
act(() => dispatchPointer(window, 'pointerup', -1))
|
||||
|
||||
expect(tabButton.dataset.pressed).toBe('false')
|
||||
expect(onActivate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('cancels pending activation on pointercancel', () => {
|
||||
const { onActivate, tabButton } = renderProbe()
|
||||
|
||||
act(() => dispatchPointer(tabButton, 'pointerdown'))
|
||||
act(() => dispatchPointer(window, 'pointercancel'))
|
||||
|
||||
expect(tabButton.dataset.pressed).toBe('false')
|
||||
expect(onActivate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears pending activation when a drag starts', () => {
|
||||
const { onActivate, tabButton, dragButton } = renderProbe()
|
||||
|
||||
act(() => dispatchPointer(tabButton, 'pointerdown'))
|
||||
act(() => dragButton.click())
|
||||
act(() => dispatchPointer(window, 'pointerup'))
|
||||
|
||||
expect(tabButton.dataset.pressed).toBe('false')
|
||||
expect(onActivate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,71 +0,0 @@
|
||||
import { useCallback, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { useTabDragActive, useTabDragActiveRef } from '../tab-group/tab-drag-context'
|
||||
|
||||
export function useTabStripPointerActivation({
|
||||
onActivate,
|
||||
disabled = false
|
||||
}: {
|
||||
onActivate: () => void
|
||||
disabled?: boolean
|
||||
}): {
|
||||
isPressed: boolean
|
||||
onPointerDown: (
|
||||
event: React.PointerEvent,
|
||||
dragListener?: (event: React.PointerEvent<Element>) => void
|
||||
) => void
|
||||
} {
|
||||
const isTabDragActive = useTabDragActive()
|
||||
const isTabDragActiveRef = useTabDragActiveRef()
|
||||
const [isPressed, setIsPressed] = useState(false)
|
||||
const pendingActivationRef = useRef(false)
|
||||
const onActivateRef = useRef(onActivate)
|
||||
onActivateRef.current = onActivate
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isTabDragActive) {
|
||||
return
|
||||
}
|
||||
pendingActivationRef.current = false
|
||||
setIsPressed(false)
|
||||
}, [isTabDragActive])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isPressed) {
|
||||
return
|
||||
}
|
||||
const finishPointerPress = (): void => {
|
||||
// Why: pointerup often reports button -1/no changed button; the left-button
|
||||
// gate is on pointerdown, so release must always clear the pending click.
|
||||
const shouldActivate = pendingActivationRef.current && !isTabDragActiveRef.current
|
||||
pendingActivationRef.current = false
|
||||
setIsPressed(false)
|
||||
if (shouldActivate) {
|
||||
onActivateRef.current()
|
||||
}
|
||||
}
|
||||
const cancelPointerPress = (): void => {
|
||||
pendingActivationRef.current = false
|
||||
setIsPressed(false)
|
||||
}
|
||||
window.addEventListener('pointerup', finishPointerPress)
|
||||
window.addEventListener('pointercancel', cancelPointerPress)
|
||||
return () => {
|
||||
window.removeEventListener('pointerup', finishPointerPress)
|
||||
window.removeEventListener('pointercancel', cancelPointerPress)
|
||||
}
|
||||
}, [isPressed, isTabDragActiveRef])
|
||||
|
||||
const onPointerDown = useCallback(
|
||||
(event: React.PointerEvent, dragListener?: (event: React.PointerEvent<Element>) => void) => {
|
||||
if (disabled || event.button !== 0) {
|
||||
return
|
||||
}
|
||||
pendingActivationRef.current = true
|
||||
setIsPressed(true)
|
||||
dragListener?.(event)
|
||||
},
|
||||
[disabled]
|
||||
)
|
||||
|
||||
return { isPressed, onPointerDown }
|
||||
}
|
||||
@@ -172,7 +172,7 @@ describe('resolveTabInsertion', () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('resolveTabIndicatorEdges', () => {
|
||||
it('marks both tabs around a left-edge insertion slot', () => {
|
||||
it('marks one edge for a left-edge insertion slot', () => {
|
||||
const hovered: HoveredTabInsertion = {
|
||||
groupId: 'group-1',
|
||||
visibleTabId: 'tab-2',
|
||||
@@ -180,12 +180,11 @@ describe('resolveTabIndicatorEdges', () => {
|
||||
}
|
||||
|
||||
expect(resolveTabIndicatorEdges(['tab-1', 'tab-2', 'tab-3'], hovered)).toEqual([
|
||||
{ visibleTabId: 'tab-1', side: 'right' },
|
||||
{ visibleTabId: 'tab-2', side: 'left' }
|
||||
])
|
||||
})
|
||||
|
||||
it('marks both tabs around a right-edge insertion slot', () => {
|
||||
it('marks one edge for a right-edge insertion slot', () => {
|
||||
const hovered: HoveredTabInsertion = {
|
||||
groupId: 'group-1',
|
||||
visibleTabId: 'tab-2',
|
||||
@@ -193,7 +192,6 @@ describe('resolveTabIndicatorEdges', () => {
|
||||
}
|
||||
|
||||
expect(resolveTabIndicatorEdges(['tab-1', 'tab-2', 'tab-3'], hovered)).toEqual([
|
||||
{ visibleTabId: 'tab-2', side: 'right' },
|
||||
{ visibleTabId: 'tab-3', side: 'left' }
|
||||
])
|
||||
})
|
||||
|
||||
@@ -62,18 +62,10 @@ export function resolveTabIndicatorEdges(
|
||||
}
|
||||
|
||||
const insertionIndex = hoveredIndex + (hoveredTabInsertion.side === 'right' ? 1 : 0)
|
||||
const edges: TabIndicatorEdge[] = []
|
||||
|
||||
// Why: VS Code draws the insertion cue by marking both tabs adjacent to the
|
||||
// slot so the two 1px edges read as one continuous bar between them.
|
||||
if (insertionIndex > 0) {
|
||||
edges.push({ visibleTabId: orderedVisibleTabIds[insertionIndex - 1]!, side: 'right' })
|
||||
}
|
||||
if (insertionIndex < orderedVisibleTabIds.length) {
|
||||
edges.push({ visibleTabId: orderedVisibleTabIds[insertionIndex]!, side: 'left' })
|
||||
return [{ visibleTabId: orderedVisibleTabIds[insertionIndex]!, side: 'left' }]
|
||||
}
|
||||
|
||||
return edges
|
||||
return [{ visibleTabId: orderedVisibleTabIds[insertionIndex - 1]!, side: 'right' }]
|
||||
}
|
||||
|
||||
function equal(a: HoveredTabInsertion | null, b: HoveredTabInsertion | null): boolean {
|
||||
|
||||
+26
-16
@@ -279,7 +279,9 @@ test.describe('Tabs', () => {
|
||||
.toEqual([domOrderBefore[1], domOrderBefore[0], ...domOrderBefore.slice(2)])
|
||||
})
|
||||
|
||||
test('clicking tabs still switches after a tab drag gesture releases', async ({ orcaPage }) => {
|
||||
test('clicking tabs still switches after dragging a terminal tab to reorder', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
const worktreeId = (await getActiveWorktreeId(orcaPage))!
|
||||
|
||||
await orcaPage.evaluate((targetWorktreeId) => {
|
||||
@@ -297,41 +299,49 @@ test.describe('Tabs', () => {
|
||||
.poll(() => countRenderedTabs(orcaPage), { timeout: 5_000 })
|
||||
.toBeGreaterThanOrEqual(2)
|
||||
|
||||
const domOrder = await orcaPage.$$eval(SORTABLE_TAB, (nodes) =>
|
||||
const domOrderBefore = await orcaPage.$$eval(SORTABLE_TAB, (nodes) =>
|
||||
nodes.map((n) => (n as HTMLElement).dataset.tabId ?? '')
|
||||
)
|
||||
const [firstTabId, secondTabId] = domOrder
|
||||
const [firstTabId, secondTabId] = domOrderBefore
|
||||
expect(firstTabId).toBeTruthy()
|
||||
expect(secondTabId).toBeTruthy()
|
||||
|
||||
await orcaPage.evaluate((tabId) => {
|
||||
window.__store?.getState().setActiveTab(tabId)
|
||||
}, firstTabId)
|
||||
await tabLocator(orcaPage, firstTabId).click({ force: true })
|
||||
await expect.poll(() => getDomActiveTabId(orcaPage), { timeout: 3_000 }).toBe(firstTabId)
|
||||
|
||||
const firstTabBox = await tabLocator(orcaPage, firstTabId).boundingBox()
|
||||
const secondTabBox = await tabLocator(orcaPage, secondTabId).boundingBox()
|
||||
expect(firstTabBox).not.toBeNull()
|
||||
expect(secondTabBox).not.toBeNull()
|
||||
const startX = firstTabBox!.x + firstTabBox!.width / 2
|
||||
const startY = firstTabBox!.y + firstTabBox!.height / 2
|
||||
const endX = secondTabBox!.x + secondTabBox!.width * 0.75
|
||||
const endY = secondTabBox!.y + secondTabBox!.height / 2
|
||||
await orcaPage.mouse.move(startX, startY)
|
||||
await orcaPage.mouse.down()
|
||||
// Why: exceed dnd-kit's 12px tab-drag threshold so this exercises the
|
||||
// drag/click handshake, not just an ordinary tab press.
|
||||
await orcaPage.mouse.move(startX + 24, startY, { steps: 4 })
|
||||
// Why: this mirrors the release repro: drag a terminal tab across another
|
||||
// tab far enough for dnd-kit to commit a reorder, then release on the tab
|
||||
// strip before clicking tabs again.
|
||||
await orcaPage.mouse.move(endX, endY, { steps: 8 })
|
||||
await orcaPage.mouse.up()
|
||||
|
||||
// Reset selection through setup state, then prove the user-visible click
|
||||
// path still activates another tab after the drag release.
|
||||
await orcaPage.evaluate((tabId) => {
|
||||
window.__store?.getState().setActiveTab(tabId)
|
||||
}, firstTabId)
|
||||
await expect.poll(() => getDomActiveTabId(orcaPage), { timeout: 3_000 }).toBe(firstTabId)
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
orcaPage.$$eval(SORTABLE_TAB, (nodes) =>
|
||||
nodes.map((n) => (n as HTMLElement).dataset.tabId ?? '')
|
||||
),
|
||||
{ timeout: 5_000, message: 'Terminal tab drag did not reorder the tab strip' }
|
||||
)
|
||||
.toEqual([secondTabId, firstTabId, ...domOrderBefore.slice(2)])
|
||||
|
||||
await tabLocator(orcaPage, firstTabId).click({ force: true })
|
||||
await expect.poll(() => getDomActiveTabId(orcaPage), { timeout: 3_000 }).toBe(firstTabId)
|
||||
await tabLocator(orcaPage, secondTabId).click({ force: true })
|
||||
await expect
|
||||
.poll(() => getDomActiveTabId(orcaPage), {
|
||||
timeout: 5_000,
|
||||
message: 'Tab click did not activate after a completed tab drag gesture'
|
||||
message: 'Tab click did not activate after a terminal tab reorder drag'
|
||||
})
|
||||
.toBe(secondTabId)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user