Re-add scroll to open workspace control (#2796)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
36357dfcb7
commit
e27f3df397
|
|
@ -829,6 +829,56 @@
|
|||
background: color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
}
|
||||
|
||||
.scroll-to-current-workspace-reveal-highlight {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.scroll-to-current-workspace-reveal-highlight::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
z-index: 30;
|
||||
pointer-events: none;
|
||||
border: 1.5px solid color-mix(in srgb, var(--terminal-pane-locate) 68%, transparent);
|
||||
border-radius: calc(var(--radius-md) + 2px);
|
||||
box-shadow:
|
||||
0 0 0 2px color-mix(in srgb, var(--terminal-pane-locate) 34%, transparent),
|
||||
0 0 18px color-mix(in srgb, var(--terminal-pane-locate) 36%, transparent);
|
||||
animation: scroll-to-current-workspace-reveal-glow 1.5s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes scroll-to-current-workspace-reveal-glow {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: scale(0.992);
|
||||
}
|
||||
|
||||
24% {
|
||||
opacity: 0.72;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
60% {
|
||||
opacity: 0.28;
|
||||
transform: scale(1.006);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(1.006);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.scroll-to-current-workspace-reveal-highlight::before {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes settings-shell-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
import { Crosshair } from 'lucide-react'
|
||||
import React from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { requestScrollToCurrentWorkspaceReveal } from '@/lib/scroll-to-current-workspace-status'
|
||||
|
||||
export function ScrollToCurrentWorkspaceToolbarButton(): React.JSX.Element {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
type="button"
|
||||
aria-label="Reveal active workspace"
|
||||
onClick={requestScrollToCurrentWorkspaceReveal}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<Crosshair className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
Reveal active workspace
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
|
@ -33,6 +33,7 @@ import { cn } from '@/lib/utils'
|
|||
import { toast } from 'sonner'
|
||||
import type { GitHubViewer } from '../../../../shared/types'
|
||||
import { showOnboardingFromRenderer } from '../onboarding/show-onboarding-event'
|
||||
import { ScrollToCurrentWorkspaceToolbarButton } from './ScrollToCurrentWorkspaceToolbarButton'
|
||||
|
||||
const GITHUB_ISSUES_URL = 'https://github.com/stablyai/orca/issues/'
|
||||
const DISCORD_URL = 'https://discord.gg/fzjDKHxv8Q'
|
||||
|
|
@ -288,6 +289,7 @@ const SidebarToolbar = React.memo(function SidebarToolbar() {
|
|||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="flex items-center gap-1">
|
||||
<ScrollToCurrentWorkspaceToolbarButton />
|
||||
<DropdownMenu modal={false}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ import {
|
|||
} from '@/hooks/useVirtualizedScrollAnchor'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { getShortcutPlatform } from '@/lib/shortcut-platform'
|
||||
import { SCROLL_TO_CURRENT_WORKSPACE_REVEAL_REQUEST_EVENT } from '@/lib/scroll-to-current-workspace-status'
|
||||
import { useRepoHeaderDrag } from './repo-header-drag'
|
||||
import WorktreeContextMenu from './WorktreeContextMenu'
|
||||
import {
|
||||
|
|
@ -215,6 +216,55 @@ function getWorktreeOptionId(worktreeId: string): string {
|
|||
return `worktree-list-option-${encodeURIComponent(worktreeId)}`
|
||||
}
|
||||
|
||||
function getMountedWorktreeBounds(
|
||||
container: HTMLElement,
|
||||
worktreeId: string
|
||||
): VirtualItemBounds | null {
|
||||
const element = document.getElementById(getWorktreeOptionId(worktreeId))
|
||||
if (!element || !container.contains(element)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
const elementRect = element.getBoundingClientRect()
|
||||
return {
|
||||
index: -1,
|
||||
start: elementRect.top - containerRect.top + container.scrollTop,
|
||||
end: elementRect.bottom - containerRect.top + container.scrollTop
|
||||
}
|
||||
}
|
||||
|
||||
export function getScrollTopToRevealBounds(
|
||||
container: HTMLElement,
|
||||
bounds: Pick<VirtualItemBounds, 'start' | 'end'>
|
||||
): number | null {
|
||||
const viewportTop = container.scrollTop
|
||||
const viewportBottom = viewportTop + container.clientHeight
|
||||
if (bounds.start < viewportTop) {
|
||||
return bounds.start
|
||||
}
|
||||
if (bounds.end > viewportBottom) {
|
||||
return bounds.end - container.clientHeight
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function revealMountedWorktreeElement(
|
||||
container: HTMLElement,
|
||||
worktreeId: string,
|
||||
behavior: ScrollBehavior
|
||||
): boolean {
|
||||
const bounds = getMountedWorktreeBounds(container, worktreeId)
|
||||
if (!bounds) {
|
||||
return false
|
||||
}
|
||||
const nextScrollTop = getScrollTopToRevealBounds(container, bounds)
|
||||
if (nextScrollTop !== null) {
|
||||
container.scrollTo({ top: Math.max(0, nextScrollTop), behavior })
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function getWorktreeVisibilityMenuLabel(repo: Repo): string {
|
||||
const visibility = effectiveExternalWorktreeVisibility(
|
||||
repo,
|
||||
|
|
@ -317,6 +367,12 @@ type WorktreeDragSession = {
|
|||
rects: readonly WorktreeDragRect[]
|
||||
}
|
||||
|
||||
type VirtualItemBounds = {
|
||||
index: number
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
type WorktreePointerDrag = {
|
||||
pointerId: number
|
||||
sourceRow: HTMLElement
|
||||
|
|
@ -565,9 +621,30 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
|||
const [worktreeDragState, setWorktreeDragState] = useState<WorktreeRowDragState>(
|
||||
WORKTREE_ROW_DRAG_INITIAL_STATE
|
||||
)
|
||||
const [pendingRevealRetryTick, setPendingRevealRetryTick] = useState(0)
|
||||
const [documentVisibilityRevision, setDocumentVisibilityRevision] = useState(0)
|
||||
const [highlightedRevealWorktreeId, setHighlightedRevealWorktreeId] = useState<string | null>(
|
||||
null
|
||||
)
|
||||
const worktreeDragSessionRef = useRef<WorktreeDragSession | null>(null)
|
||||
const worktreePointerDragRef = useRef<WorktreePointerDrag | null>(null)
|
||||
const pendingRevealRetryRef = useRef<{ worktreeId: string; count: number } | null>(null)
|
||||
const revealHighlightTimeoutRef = useRef<number | null>(null)
|
||||
const flashRevealedWorktree = useCallback((worktreeId: string) => {
|
||||
if (revealHighlightTimeoutRef.current !== null) {
|
||||
window.clearTimeout(revealHighlightTimeoutRef.current)
|
||||
}
|
||||
// Why: remove before add restarts the CSS glow when the user repeatedly
|
||||
// asks to reveal the same active workspace.
|
||||
setHighlightedRevealWorktreeId(null)
|
||||
window.requestAnimationFrame(() => {
|
||||
setHighlightedRevealWorktreeId(worktreeId)
|
||||
revealHighlightTimeoutRef.current = window.setTimeout(() => {
|
||||
revealHighlightTimeoutRef.current = null
|
||||
setHighlightedRevealWorktreeId(null)
|
||||
}, 1500)
|
||||
})
|
||||
}, [])
|
||||
const suppressWorktreeClickUntilRef = useRef(0)
|
||||
const canReorderRepoHeaders = groupBy === 'repo' && repoGroupOrdering === 'manual'
|
||||
const lastVisibleRefreshKeyRef = useRef('')
|
||||
|
|
@ -929,20 +1006,65 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
|||
)
|
||||
const outcome = resolvePendingSidebarReveal({ targetIndex, targetWorktreeStillExists })
|
||||
if (outcome === 'scroll-and-clear') {
|
||||
// Why: `align: 'auto'` is a no-op when the card is already visible and
|
||||
// otherwise scrolls the minimum amount to bring it into view. Using
|
||||
// 'center' here made every worktree click re-center the sidebar, which
|
||||
// is visually jumpy even when nothing needed to move. `behavior: 'smooth'`
|
||||
// animates that minimum scroll so off-screen reveals slide into view
|
||||
// instead of snapping — matching the native scroll-into-view feel.
|
||||
const targetRow = renderRows[targetIndex]
|
||||
const container = scrollRef.current
|
||||
const retryExactRevealOnNextFrame = () => {
|
||||
const previousRetry = pendingRevealRetryRef.current
|
||||
const nextRetryCount =
|
||||
previousRetry?.worktreeId === pendingRevealWorktree.worktreeId
|
||||
? previousRetry.count + 1
|
||||
: 1
|
||||
pendingRevealRetryRef.current = {
|
||||
worktreeId: pendingRevealWorktree.worktreeId,
|
||||
count: nextRetryCount
|
||||
}
|
||||
if (nextRetryCount <= 8) {
|
||||
requestAnimationFrame(() => setPendingRevealRetryTick((tick) => tick + 1))
|
||||
} else {
|
||||
pendingRevealRetryRef.current = null
|
||||
clearPendingRevealWorktreeId()
|
||||
}
|
||||
}
|
||||
if (
|
||||
container &&
|
||||
revealMountedWorktreeElement(
|
||||
container,
|
||||
pendingRevealWorktree.worktreeId,
|
||||
pendingRevealWorktree.behavior
|
||||
)
|
||||
) {
|
||||
if (pendingRevealWorktree.highlight) {
|
||||
flashRevealedWorktree(pendingRevealWorktree.worktreeId)
|
||||
}
|
||||
pendingRevealRetryRef.current = null
|
||||
clearPendingRevealWorktreeId()
|
||||
return
|
||||
}
|
||||
|
||||
if (targetRow?.type !== 'lineage-group') {
|
||||
// Why: virtual row indexing can leave the card edge slightly clipped;
|
||||
// stage it into the mounted window, then retry the exact DOM reveal.
|
||||
virtualizer.scrollToIndex(targetIndex, {
|
||||
align: 'auto',
|
||||
behavior: 'auto'
|
||||
})
|
||||
retryExactRevealOnNextFrame()
|
||||
return
|
||||
}
|
||||
|
||||
// Why: for grouped lineage rows the virtual row is only a staging
|
||||
// target. Jump it into the mounted window first, then retry the exact
|
||||
// card reveal instead of clearing while a smooth virtual scroll is
|
||||
// still in flight.
|
||||
virtualizer.scrollToIndex(targetIndex, {
|
||||
align: 'auto',
|
||||
behavior: pendingRevealWorktree.behavior
|
||||
behavior: 'auto'
|
||||
})
|
||||
clearPendingRevealWorktreeId()
|
||||
retryExactRevealOnNextFrame()
|
||||
return
|
||||
}
|
||||
if (outcome === 'clear') {
|
||||
pendingRevealRetryRef.current = null
|
||||
clearPendingRevealWorktreeId()
|
||||
}
|
||||
})
|
||||
|
|
@ -960,7 +1082,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
|||
toggleGroup,
|
||||
collapsedGroups,
|
||||
workspaceStatuses,
|
||||
settings
|
||||
settings,
|
||||
pendingRevealRetryTick,
|
||||
flashRevealedWorktree
|
||||
])
|
||||
|
||||
const prCacheLen = useAppStore((s) => countRecordKeysByReference(s.prCache))
|
||||
|
|
@ -1185,6 +1309,14 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
|||
markScrollMovement()
|
||||
}, [markScrollMovement])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (revealHighlightTimeoutRef.current !== null) {
|
||||
window.clearTimeout(revealHighlightTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const cleanupWorktreePointerDrag = useCallback(() => {
|
||||
const drag = worktreePointerDragRef.current
|
||||
if (!drag) {
|
||||
|
|
@ -2112,9 +2244,14 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
|||
data-worktree-drag-group-index={worktreeDragGroupIndex}
|
||||
className={cn(
|
||||
'relative transition-[opacity,transform,filter] duration-150 ease-out',
|
||||
highlightedRevealWorktreeId === itemRow.worktree.id &&
|
||||
'scroll-to-current-workspace-reveal-highlight',
|
||||
worktreeDragState.draggingWorktreeId === itemRow.worktree.id &&
|
||||
'z-20 scale-[0.985] opacity-35 saturate-75'
|
||||
)}
|
||||
data-scroll-reveal-highlight={
|
||||
highlightedRevealWorktreeId === itemRow.worktree.id ? 'true' : undefined
|
||||
}
|
||||
// Why: nested child cards live inside the parent's clickable
|
||||
// card body; bubbling would activate/edit the parent too.
|
||||
onClick={nested ? stopNestedWorktreeCardBubble : undefined}
|
||||
|
|
@ -2197,11 +2334,16 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
|||
aria-selected={selectedWorktreeIds.has(child.worktree.id)}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-start gap-1.5 rounded-md border border-transparent px-2 py-1.5 transition-colors',
|
||||
'relative flex cursor-pointer items-start gap-1.5 rounded-md border border-transparent px-2 py-1.5 transition-colors',
|
||||
highlightedRevealWorktreeId === child.worktree.id &&
|
||||
'scroll-to-current-workspace-reveal-highlight',
|
||||
isActive
|
||||
? 'border-black/[0.015] bg-black/[0.08] shadow-[0_1px_2px_rgba(0,0,0,0.04)] dark:border-border/40 dark:bg-white/[0.10] dark:shadow-[0_1px_2px_rgba(0,0,0,0.03)]'
|
||||
: 'worktree-sidebar-card-hover'
|
||||
)}
|
||||
data-scroll-reveal-highlight={
|
||||
highlightedRevealWorktreeId === child.worktree.id ? 'true' : undefined
|
||||
}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
|
|
@ -2416,6 +2558,7 @@ const WorktreeList = React.memo(function WorktreeList({
|
|||
const activeView = useAppStore((s) => s.activeView)
|
||||
const activeModal = useAppStore((s) => s.activeModal)
|
||||
const pendingRevealWorktree = useAppStore((s) => s.pendingRevealWorktree)
|
||||
const revealWorktreeInSidebar = useAppStore((s) => s.revealWorktreeInSidebar)
|
||||
const clearPendingRevealWorktreeId = useAppStore((s) => s.clearPendingRevealWorktreeId)
|
||||
|
||||
// Read tabsByWorktree when needed for filtering or sorting
|
||||
|
|
@ -2705,7 +2848,6 @@ const WorktreeList = React.memo(function WorktreeList({
|
|||
])
|
||||
|
||||
const worktrees = visibleWorktrees
|
||||
|
||||
const collapsedGroups = useAppStore((s) => s.collapsedGroups)
|
||||
const toggleGroup = useAppStore((s) => s.toggleCollapsedGroup)
|
||||
|
||||
|
|
@ -3018,6 +3160,35 @@ const WorktreeList = React.memo(function WorktreeList({
|
|||
}
|
||||
}, [setShowSleepingWorkspaces, setFilterRepoIds, setHideDefaultBranchWorkspace, filterState])
|
||||
|
||||
const handleRevealCurrentWorkspaceRequest = useCallback(() => {
|
||||
if (!activeWorktreeId) {
|
||||
return
|
||||
}
|
||||
const activeWorktree = worktreeMap.get(activeWorktreeId)
|
||||
if (!activeWorktree || activeWorktree.isArchived) {
|
||||
return
|
||||
}
|
||||
if (!worktrees.some((worktree) => worktree.id === activeWorktreeId)) {
|
||||
// Why: the toolbar action promises to reveal the current workspace; when
|
||||
// sidebar filters hide it, relax those filters before queuing the reveal.
|
||||
clearFilters()
|
||||
}
|
||||
revealWorktreeInSidebar(activeWorktreeId, { behavior: 'smooth', highlight: true })
|
||||
}, [activeWorktreeId, clearFilters, revealWorktreeInSidebar, worktreeMap, worktrees])
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener(
|
||||
SCROLL_TO_CURRENT_WORKSPACE_REVEAL_REQUEST_EVENT,
|
||||
handleRevealCurrentWorkspaceRequest
|
||||
)
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
SCROLL_TO_CURRENT_WORKSPACE_REVEAL_REQUEST_EVENT,
|
||||
handleRevealCurrentWorkspaceRequest
|
||||
)
|
||||
}
|
||||
}, [handleRevealCurrentWorkspaceRequest])
|
||||
|
||||
if (worktrees.length === 0) {
|
||||
return (
|
||||
<div data-worktree-sidebar-container className="relative min-h-0 flex-1">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { getScrollTopToRevealBounds } from './WorktreeList'
|
||||
|
||||
describe('getScrollTopToRevealBounds', () => {
|
||||
const makeContainer = (scrollTop: number, clientHeight: number) =>
|
||||
({
|
||||
scrollTop,
|
||||
clientHeight
|
||||
}) as HTMLElement
|
||||
|
||||
it('scrolls upward to reveal a mounted current workspace card above the viewport', () => {
|
||||
expect(getScrollTopToRevealBounds(makeContainer(100, 200), { start: 60, end: 120 })).toBe(60)
|
||||
})
|
||||
|
||||
it('scrolls downward to reveal a mounted current workspace card below the viewport', () => {
|
||||
expect(getScrollTopToRevealBounds(makeContainer(100, 200), { start: 250, end: 340 })).toBe(140)
|
||||
})
|
||||
|
||||
it('does not scroll when the current workspace card is already fully visible', () => {
|
||||
expect(getScrollTopToRevealBounds(makeContainer(100, 200), { start: 125, end: 260 })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
export const SCROLL_TO_CURRENT_WORKSPACE_REVEAL_REQUEST_EVENT =
|
||||
'orca-scroll-to-current-workspace-reveal-request'
|
||||
|
||||
export function requestScrollToCurrentWorkspaceReveal(): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
window.dispatchEvent(new Event(SCROLL_TO_CURRENT_WORKSPACE_REVEAL_REQUEST_EVENT))
|
||||
}
|
||||
|
|
@ -58,6 +58,7 @@ import type { WorkspacePortScanResult } from '../../../../shared/workspace-ports
|
|||
export type PendingSidebarWorktreeReveal = {
|
||||
worktreeId: string
|
||||
behavior: 'auto' | 'smooth'
|
||||
highlight?: boolean
|
||||
}
|
||||
|
||||
function clampPetSize(size: number): number {
|
||||
|
|
@ -530,7 +531,10 @@ export type UISlice = {
|
|||
pendingRevealWorktree: PendingSidebarWorktreeReveal | null
|
||||
revealWorktreeInSidebar: (
|
||||
worktreeId: string,
|
||||
options?: { behavior?: PendingSidebarWorktreeReveal['behavior'] }
|
||||
options?: {
|
||||
behavior?: PendingSidebarWorktreeReveal['behavior']
|
||||
highlight?: boolean
|
||||
}
|
||||
) => void
|
||||
clearPendingRevealWorktreeId: () => void
|
||||
// Why: lets the SourceControl sidebar request that the diff editor scroll
|
||||
|
|
@ -1121,7 +1125,8 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
|
|||
set({
|
||||
pendingRevealWorktree: {
|
||||
worktreeId,
|
||||
behavior: options?.behavior ?? 'smooth'
|
||||
behavior: options?.behavior ?? 'smooth',
|
||||
...(options?.highlight ? { highlight: true } : {})
|
||||
}
|
||||
}),
|
||||
clearPendingRevealWorktreeId: () => set({ pendingRevealWorktree: null }),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,209 @@
|
|||
import type { Page } from '@stablyai/playwright-test'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
|
||||
const WORKTREE_OPTION_PREFIX = 'worktree-list-option-'
|
||||
|
||||
function worktreeOption(page: Page, worktreeId: string) {
|
||||
return page.locator(`[id="${WORKTREE_OPTION_PREFIX}${encodeURIComponent(worktreeId)}"]`)
|
||||
}
|
||||
|
||||
async function prepareSidebarForScrollTest(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('window.__store is not available')
|
||||
}
|
||||
|
||||
const state = store.getState()
|
||||
state.setActiveView('terminal')
|
||||
state.setSidebarOpen(true)
|
||||
state.setGroupBy('none')
|
||||
state.setSortBy('recent')
|
||||
state.setShowActiveOnly(false)
|
||||
state.setShowSleepingWorkspaces(true)
|
||||
state.setHideDefaultBranchWorkspace(false)
|
||||
state.setFilterRepoIds([])
|
||||
})
|
||||
}
|
||||
|
||||
async function forceCurrentWorkspaceClipped(page: Page, targetId: string): Promise<void> {
|
||||
await page.locator('[data-worktree-sidebar]').evaluate((element, targetId) => {
|
||||
const scroller = element as HTMLElement
|
||||
const target = document.getElementById(`worktree-list-option-${encodeURIComponent(targetId)}`)
|
||||
if (!target) {
|
||||
throw new Error('Target workspace row is not mounted')
|
||||
}
|
||||
|
||||
scroller.style.height = '72px'
|
||||
scroller.style.maxHeight = '72px'
|
||||
scroller.style.overflowY = 'auto'
|
||||
|
||||
const scrollerBounds = scroller.getBoundingClientRect()
|
||||
const targetBounds = target.getBoundingClientRect()
|
||||
const desiredTargetTop = scrollerBounds.bottom - 24
|
||||
scroller.scrollTop += targetBounds.top - desiredTargetTop
|
||||
scroller.dispatchEvent(new Event('scroll', { bubbles: true }))
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
}, targetId)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate((targetId) => {
|
||||
const scroller = document.querySelector<HTMLElement>('[data-worktree-sidebar]')
|
||||
const target = document.getElementById(
|
||||
`worktree-list-option-${encodeURIComponent(targetId)}`
|
||||
)
|
||||
if (!scroller || !target) {
|
||||
return false
|
||||
}
|
||||
|
||||
const scrollerBounds = scroller.getBoundingClientRect()
|
||||
const targetBounds = target.getBoundingClientRect()
|
||||
return (
|
||||
targetBounds.top < scrollerBounds.bottom && targetBounds.bottom > scrollerBounds.bottom
|
||||
)
|
||||
}, targetId),
|
||||
{
|
||||
timeout: 10_000,
|
||||
message: 'Target workspace should be clipped before using the reveal button'
|
||||
}
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
async function expectNoRevealHighlightDuring(
|
||||
page: Page,
|
||||
targetId: string,
|
||||
durationMs: number
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + durationMs
|
||||
while (Date.now() < deadline) {
|
||||
const isHighlighted = await page.evaluate((targetId) => {
|
||||
const target = document.getElementById(`worktree-list-option-${encodeURIComponent(targetId)}`)
|
||||
return target?.getAttribute('data-scroll-reveal-highlight') === 'true'
|
||||
}, targetId)
|
||||
expect(isHighlighted).toBe(false)
|
||||
await page.waitForTimeout(50)
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Reveal active workspace button', () => {
|
||||
test.beforeEach(async ({ orcaPage }) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
})
|
||||
|
||||
test('reveals the current workspace when it is clipped in the production sidebar', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
await prepareSidebarForScrollTest(orcaPage)
|
||||
|
||||
const renderedOptions = orcaPage.locator('[data-worktree-sidebar] [role="option"]')
|
||||
await expect(renderedOptions).toHaveCount(2)
|
||||
|
||||
const targetIdAttribute = await renderedOptions.last().getAttribute('id')
|
||||
if (!targetIdAttribute?.startsWith(WORKTREE_OPTION_PREFIX)) {
|
||||
throw new Error('Bottom workspace row did not expose the expected option id')
|
||||
}
|
||||
|
||||
const targetId = decodeURIComponent(targetIdAttribute.slice(WORKTREE_OPTION_PREFIX.length))
|
||||
const targetRow = worktreeOption(orcaPage, targetId)
|
||||
const revealButton = orcaPage.getByRole('button', { name: 'Reveal active workspace' })
|
||||
|
||||
await renderedOptions.last().click()
|
||||
await expect(targetRow).toHaveAttribute('aria-current', 'page')
|
||||
await expectNoRevealHighlightDuring(orcaPage, targetId, 400)
|
||||
await expect(revealButton).toBeVisible()
|
||||
await expect(revealButton).toBeEnabled()
|
||||
await forceCurrentWorkspaceClipped(orcaPage, targetId)
|
||||
|
||||
await expect(revealButton).toBeVisible()
|
||||
await expect(revealButton).toBeEnabled()
|
||||
|
||||
await revealButton.click()
|
||||
await expect(targetRow).toHaveAttribute('data-scroll-reveal-highlight', 'true')
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
orcaPage.evaluate((targetId) => {
|
||||
const scroller = document.querySelector<HTMLElement>('[data-worktree-sidebar]')
|
||||
const target = document.getElementById(
|
||||
`worktree-list-option-${encodeURIComponent(targetId)}`
|
||||
)
|
||||
if (!scroller || !target) {
|
||||
return false
|
||||
}
|
||||
|
||||
const scrollerBounds = scroller.getBoundingClientRect()
|
||||
const targetBounds = target.getBoundingClientRect()
|
||||
return (
|
||||
targetBounds.top >= scrollerBounds.top - 1 &&
|
||||
targetBounds.bottom <= scrollerBounds.bottom + 1
|
||||
)
|
||||
}, targetId),
|
||||
{
|
||||
timeout: 10_000,
|
||||
message: 'Reveal button did not scroll the current workspace fully into view'
|
||||
}
|
||||
)
|
||||
.toBe(true)
|
||||
await expect(revealButton).toBeVisible()
|
||||
await expect(revealButton).toBeEnabled()
|
||||
})
|
||||
|
||||
test('clears sidebar filters before revealing a hidden current workspace', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
await prepareSidebarForScrollTest(orcaPage)
|
||||
|
||||
const renderedOptions = orcaPage.locator('[data-worktree-sidebar] [role="option"]')
|
||||
await expect(renderedOptions).toHaveCount(2)
|
||||
|
||||
const targetIdAttribute = await renderedOptions.last().getAttribute('id')
|
||||
if (!targetIdAttribute?.startsWith(WORKTREE_OPTION_PREFIX)) {
|
||||
throw new Error('Bottom workspace row did not expose the expected option id')
|
||||
}
|
||||
|
||||
const targetId = decodeURIComponent(targetIdAttribute.slice(WORKTREE_OPTION_PREFIX.length))
|
||||
const targetRow = worktreeOption(orcaPage, targetId)
|
||||
const revealButton = orcaPage.getByRole('button', { name: 'Reveal active workspace' })
|
||||
|
||||
await renderedOptions.last().click()
|
||||
await expect(targetRow).toHaveAttribute('aria-current', 'page')
|
||||
|
||||
await orcaPage.evaluate(() => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('window.__store is not available')
|
||||
}
|
||||
store.getState().setFilterRepoIds(['__filtered_repo__'])
|
||||
})
|
||||
|
||||
await expect(renderedOptions).toHaveCount(0)
|
||||
await expect(orcaPage.getByText('No workspaces found')).toBeVisible()
|
||||
|
||||
await revealButton.click()
|
||||
|
||||
await expect(targetRow).toBeVisible()
|
||||
await expect(targetRow).toHaveAttribute('data-scroll-reveal-highlight', 'true')
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
orcaPage.evaluate(() => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('window.__store is not available')
|
||||
}
|
||||
return store.getState().filterRepoIds
|
||||
}),
|
||||
{
|
||||
timeout: 10_000,
|
||||
message: 'Reveal button should clear repo filters that hide the current workspace'
|
||||
}
|
||||
)
|
||||
.toEqual([])
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue