feat: add explicit drag handles to sidebar lists and refactor workspace tab model

This commit is contained in:
Mohamed Boudra
2026-03-04 11:57:22 +07:00
parent 952ff0dd72
commit b5b4e60916
11 changed files with 984 additions and 797 deletions
@@ -1,9 +1,6 @@
import { RefreshControl } from "react-native";
import { useCallback, useState } from "react";
import DraggableFlatList, {
NestableDraggableFlatList,
type RenderItemParams,
} from "react-native-draggable-flatlist";
import DraggableFlatList, { type RenderItemParams } from "react-native-draggable-flatlist";
import { useUnistyles } from "react-native-unistyles";
import type {
DraggableListProps,
@@ -33,57 +30,63 @@ export function DraggableList<T>({
simultaneousGestureRef,
waitFor,
onDragBegin: onDragBeginProp,
nestable = false,
onDragIntent: onDragIntentProp,
onDragRelease: onDragReleaseProp,
nestable: _nestable = false,
}: DraggableListProps<T>) {
const { theme } = useUnistyles();
const [isDragging, setIsDragging] = useState(false);
// Pass the ref directly to DraggableFlatList - it handles the gesture coordination
// The ref may not have .current set yet, but that's okay - DraggableFlatList will
// read it when the gesture is being recognized
const simultaneousHandlers = simultaneousGestureRef ? [simultaneousGestureRef] : undefined;
const handleRenderItem = useCallback(
({ item, drag, isActive, getIndex }: RenderItemParams<T>) => {
const index = getIndex() ?? 0;
const itemKey = keyExtractor(item, index);
const dragWithLog = () => {
console.log("[sidebar-dnd-debug] row drag() called", { testID, itemKey, index });
onDragIntentProp?.();
drag();
};
const info: DraggableRenderItemInfo<T> = {
item,
index,
drag,
drag: dragWithLog,
isActive,
};
return renderItem(info);
},
[renderItem]
[keyExtractor, onDragIntentProp, renderItem, testID]
);
const handleDragEnd = useCallback(
({ data: newData }: { data: T[] }) => {
console.log("[sidebar-dnd-debug] list onDragEnd", { testID, count: newData.length });
setIsDragging(false);
onDragEnd(newData);
},
[onDragEnd]
[onDragEnd, testID]
);
const handleDragBegin = useCallback(() => {
console.log("[sidebar-dnd-debug] list onDragBegin", { testID });
setIsDragging(true);
onDragBeginProp?.();
}, [onDragBeginProp]);
}, [onDragBeginProp, testID]);
const handleRelease = useCallback(() => {
console.log("[sidebar-dnd-debug] list onRelease", { testID });
setIsDragging(false);
}, []);
onDragReleaseProp?.();
}, [onDragReleaseProp, testID]);
const showRefreshControl = Boolean(onRefresh) && (!isDragging || Boolean(refreshing));
const resolvedContainerStyle =
containerStyle ?? (scrollEnabled ? { flex: 1 } : undefined);
const ListComponent: typeof DraggableFlatList = (nestable
? (NestableDraggableFlatList as any)
: DraggableFlatList) as any;
const shouldShowRefreshControl = showRefreshControl && !nestable;
const shouldShowRefreshControl = showRefreshControl;
return (
<ListComponent
<DraggableFlatList
testID={testID}
data={data}
keyExtractor={keyExtractor}
@@ -98,11 +101,10 @@ export function DraggableList<T>({
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
scrollEnabled={scrollEnabled}
simultaneousHandlers={simultaneousHandlers}
// Higher activationDistance prevents drag from interfering with nested onLongPress handlers
activationDistance={20}
activationDistance={6}
onDragBegin={handleDragBegin}
onRelease={handleRelease}
// @ts-expect-error - waitFor is supported by RNGH FlatList but not typed in DraggableFlatList
// @ts-ignore - waitFor is supported by RNGH FlatList but missing from DraggableFlatList types
waitFor={waitFor}
refreshControl={
shouldShowRefreshControl ? (
@@ -52,6 +52,10 @@ export interface DraggableListProps<T> {
waitFor?: MutableRefObject<GestureType | undefined> | MutableRefObject<GestureType | undefined>[];
/** Called when a drag gesture begins (before items are reordered) */
onDragBegin?: () => void;
/** Called immediately before invoking row `drag()` to lock outer owners. */
onDragIntent?: () => void;
/** Called when drag interaction ends (finger released). */
onDragRelease?: () => void;
/**
* Native-only: use the nestable draggable-flatlist variant for nested drag
* lists coordinated by a shared NestableScrollContainer.
@@ -6,10 +6,10 @@ import {
Platform,
ActivityIndicator,
Alert,
StatusBar,
ScrollView,
type GestureResponderEvent,
} from 'react-native'
import * as Haptics from 'expo-haptics'
import { useQueries } from '@tanstack/react-query'
import {
useCallback,
@@ -22,9 +22,8 @@ import {
} from 'react'
import { router, usePathname, useSegments } from 'expo-router'
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
import { Gesture, GestureDetector, type GestureType } from 'react-native-gesture-handler'
import { ChevronDown, ChevronRight } from 'lucide-react-native'
import { NestableScrollContainer } from 'react-native-draggable-flatlist'
import { type GestureType } from 'react-native-gesture-handler'
import { ChevronDown, ChevronRight, GripVertical } from 'lucide-react-native'
import { DraggableList, type DraggableRenderItemInfo } from './draggable-list'
import type { DraggableListDragHandleProps } from './draggable-list.types'
import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime'
@@ -53,9 +52,6 @@ import { useToast } from '@/contexts/toast-context'
import { useCheckoutGitActionsStore } from '@/stores/checkout-git-actions-store'
import { buildSidebarShortcutModel } from '@/utils/sidebar-shortcuts'
import { hasVisibleOrderChanged, mergeWithRemainder } from '@/utils/sidebar-reorder'
import {
shouldOpenContextMenuOnPressOut,
} from '@/utils/sidebar-gesture-arbitration'
const PASEO_WORKTREE_PATH_MARKER = '/.paseo/worktrees'
@@ -78,11 +74,6 @@ interface SidebarWorkspaceListProps {
parentGestureRef?: MutableRefObject<GestureType | undefined>
}
interface ContextMenuController {
setAnchorRect: (rect: { x: number; y: number; width: number; height: number } | null) => void
setOpen: (open: boolean) => void
}
interface ProjectHeaderRowProps {
project: SidebarProjectEntry
displayName: string
@@ -90,6 +81,7 @@ interface ProjectHeaderRowProps {
collapsed: boolean
onToggle: () => void
drag: () => void
isDragging: boolean
dragHandleProps?: DraggableListDragHandleProps
}
@@ -100,9 +92,10 @@ interface WorkspaceRowInnerProps {
showShortcutBadge: boolean
onPress: () => void
drag: () => void
isDragging: boolean
isArchiving: boolean
dragHandleProps?: DraggableListDragHandleProps
menuController: ContextMenuController | null
menuController: ReturnType<typeof useContextMenu> | null
}
function resolveWorkspaceCreatedAtLabel(workspace: SidebarWorkspaceEntry): string | null {
@@ -161,159 +154,34 @@ function WorkspaceStatusIndicator({
function useLongPressDragInteraction(input: {
drag: () => void
menuController: ContextMenuController | null
}) {
const didLongPressRef = useRef(false)
const didLongPressCleanupTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const longPressArmedRef = useRef(false)
const longPressCancelledRef = useRef(false)
const didStartDragRef = useRef(false)
const touchStartRef = useRef<{ x: number; y: number } | null>(null)
useEffect(() => {
return () => {
if (!didLongPressCleanupTimerRef.current) {
return
}
clearTimeout(didLongPressCleanupTimerRef.current)
didLongPressCleanupTimerRef.current = null
}
}, [])
const openContextMenuAtTouchStart = useCallback(() => {
if (!input.menuController) {
return
}
const point = touchStartRef.current
if (!point) {
return
}
const statusBarHeight = Platform.OS === 'android' ? (StatusBar.currentHeight ?? 0) : 0
input.menuController.setAnchorRect({
x: point.x,
y: point.y + statusBarHeight,
width: 0,
height: 0,
})
input.menuController.setOpen(true)
}, [input.menuController])
const handleLongPress = useCallback(() => {
if (Platform.OS === 'web') {
return
}
if (longPressCancelledRef.current) {
return
}
console.log('[sidebar-dnd-debug] long-press armed')
void Haptics.selectionAsync().catch(() => {})
didLongPressRef.current = true
longPressArmedRef.current = true
}, [])
const handlePressIn = useCallback((event: GestureResponderEvent) => {
if (didLongPressCleanupTimerRef.current) {
clearTimeout(didLongPressCleanupTimerRef.current)
didLongPressCleanupTimerRef.current = null
}
longPressCancelledRef.current = false
longPressArmedRef.current = false
didStartDragRef.current = false
touchStartRef.current = {
didLongPressRef.current = false
console.log('[sidebar-dnd-debug] press-in', {
x: event.nativeEvent.pageX,
y: event.nativeEvent.pageY,
}
})
}, [])
const handlePressOut = useCallback(() => {
if (Platform.OS === 'web') {
return
}
if (
!shouldOpenContextMenuOnPressOut({
longPressArmed: longPressArmedRef.current,
didStartDrag: didStartDragRef.current,
})
) {
longPressCancelledRef.current = false
longPressArmedRef.current = false
didStartDragRef.current = false
touchStartRef.current = null
return
}
openContextMenuAtTouchStart()
didLongPressCleanupTimerRef.current = setTimeout(() => {
didLongPressRef.current = false
didLongPressCleanupTimerRef.current = null
}, 0)
longPressCancelledRef.current = false
longPressArmedRef.current = false
didStartDragRef.current = false
touchStartRef.current = null
}, [openContextMenuAtTouchStart])
const moveMonitorGesture = useMemo(() => {
if (Platform.OS === 'web') {
return null
}
const CANCEL_SLOP_PX = 10
const DRAG_SLOP_PX = 8
return Gesture.Pan()
.manualActivation(true)
.runOnJS(true)
.onTouchesDown((event) => {
const touch = event.changedTouches[0]
if (!touch) {
return
}
touchStartRef.current = { x: touch.absoluteX, y: touch.absoluteY }
})
.onTouchesMove((event) => {
const touch = event.changedTouches[0]
if (!touch || event.numberOfTouches !== 1) {
return
}
const start = touchStartRef.current
if (!start) {
touchStartRef.current = { x: touch.absoluteX, y: touch.absoluteY }
return
}
if (didStartDragRef.current) {
return
}
const dx = touch.absoluteX - start.x
const dy = touch.absoluteY - start.y
const distance = Math.sqrt(dx * dx + dy * dy)
if (!longPressArmedRef.current) {
if (distance > CANCEL_SLOP_PX) {
longPressCancelledRef.current = true
}
return
}
if (distance > DRAG_SLOP_PX) {
didStartDragRef.current = true
input.drag()
}
})
}, [input.drag])
console.log('[sidebar-dnd-debug] press-out no context-menu', {
didLongPress: didLongPressRef.current,
})
}, [])
return {
didLongPressRef,
handleLongPress,
handlePressIn,
handlePressOut,
moveMonitorGesture,
}
}
@@ -324,11 +192,11 @@ function ProjectHeaderRow({
collapsed,
onToggle,
drag,
isDragging,
dragHandleProps,
}: ProjectHeaderRowProps) {
const interaction = useLongPressDragInteraction({
drag,
menuController: useContextMenu(),
})
const handlePress = useCallback(() => {
@@ -339,11 +207,18 @@ function ProjectHeaderRow({
onToggle()
}, [interaction.didLongPressRef, onToggle])
const handleDragHandlePressIn = useCallback(() => {
console.log('[sidebar-dnd-debug] project drag-handle press-in')
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {})
drag()
}, [drag])
const trigger = (
<ContextMenuTrigger
enabledOnMobile={false}
enabledOnMobile
style={({ pressed, hovered = false }) => [
styles.projectRow,
isDragging && styles.projectRowDragging,
hovered && styles.projectRowHovered,
pressed && styles.projectRowPressed,
]}
@@ -351,15 +226,10 @@ function ProjectHeaderRow({
onPressOut={interaction.handlePressOut}
onPress={handlePress}
onLongPress={interaction.handleLongPress}
delayLongPress={200}
delayLongPress={350}
testID={`sidebar-project-row-${project.projectKey}`}
>
<View
{...(dragHandleProps?.attributes as any)}
{...(dragHandleProps?.listeners as any)}
ref={dragHandleProps?.setActivatorNodeRef as any}
style={styles.projectRowLeft}
>
<View style={styles.projectRowLeft}>
{collapsed ? (
<ChevronRight size={14} color="#9ca3af" />
) : (
@@ -378,14 +248,24 @@ function ProjectHeaderRow({
{displayName}
</Text>
</View>
<Pressable
{...(dragHandleProps?.attributes as any)}
{...(dragHandleProps?.listeners as any)}
ref={dragHandleProps?.setActivatorNodeRef as any}
hitSlop={8}
onPressIn={handleDragHandlePressIn}
onLongPress={handleDragHandlePressIn}
delayLongPress={120}
onPress={(event) => (event as any).stopPropagation?.()}
style={({ pressed }) => [styles.dragHandle, pressed && styles.dragHandlePressed]}
testID={`sidebar-project-drag-handle-${project.projectKey}`}
>
<GripVertical size={14} color="#9ca3af" />
</Pressable>
</ContextMenuTrigger>
)
return interaction.moveMonitorGesture ? (
<GestureDetector gesture={interaction.moveMonitorGesture}>{trigger}</GestureDetector>
) : (
trigger
)
return trigger
}
function ProjectHeaderRowWithMenu(props: ProjectHeaderRowProps) {
@@ -411,6 +291,7 @@ function WorkspaceRowInner({
showShortcutBadge,
onPress,
drag,
isDragging,
isArchiving,
dragHandleProps,
menuController,
@@ -419,7 +300,6 @@ function WorkspaceRowInner({
const createdAtLabel = resolveWorkspaceCreatedAtLabel(workspace)
const interaction = useLongPressDragInteraction({
drag,
menuController,
})
const handlePress = useCallback(() => {
@@ -430,12 +310,17 @@ function WorkspaceRowInner({
onPress()
}, [interaction.didLongPressRef, onPress])
const handleDragHandlePressIn = useCallback(() => {
console.log('[sidebar-dnd-debug] workspace drag-handle press-in', {
workspaceKey: workspace.workspaceKey,
})
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {})
drag()
}, [drag, workspace.workspaceKey])
const rowChildren = (
<>
<View
{...(dragHandleProps?.attributes as any)}
{...(dragHandleProps?.listeners as any)}
ref={dragHandleProps?.setActivatorNodeRef as any}
style={styles.workspaceRowLeft}
>
<WorkspaceStatusIndicator bucket={workspace.statusBucket} loading={isArchiving} />
@@ -454,16 +339,31 @@ function WorkspaceRowInner({
<Text style={styles.shortcutBadgeText}>{shortcutNumber}</Text>
</View>
) : null}
<Pressable
{...(dragHandleProps?.attributes as any)}
{...(dragHandleProps?.listeners as any)}
ref={dragHandleProps?.setActivatorNodeRef as any}
hitSlop={8}
onPressIn={handleDragHandlePressIn}
onLongPress={handleDragHandlePressIn}
delayLongPress={120}
onPress={(event) => (event as any).stopPropagation?.()}
style={({ pressed }) => [styles.dragHandle, pressed && styles.dragHandlePressed]}
testID={`sidebar-workspace-drag-handle-${workspace.workspaceKey}`}
>
<GripVertical size={14} color="#9ca3af" />
</Pressable>
</View>
</>
)
const trigger = menuController ? (
<ContextMenuTrigger
enabledOnMobile={false}
enabledOnMobile
disabled={isArchiving}
style={({ pressed, hovered = false }) => [
styles.workspaceRow,
isDragging && styles.workspaceRowDragging,
selected && styles.workspaceRowSelected,
hovered && styles.workspaceRowHovered,
pressed && styles.workspaceRowPressed,
@@ -472,7 +372,7 @@ function WorkspaceRowInner({
onPressOut={interaction.handlePressOut}
onPress={handlePress}
onLongPress={interaction.handleLongPress}
delayLongPress={200}
delayLongPress={350}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
{rowChildren}
@@ -482,6 +382,7 @@ function WorkspaceRowInner({
disabled={isArchiving}
style={({ pressed, hovered = false }) => [
styles.workspaceRow,
isDragging && styles.workspaceRowDragging,
selected && styles.workspaceRowSelected,
hovered && styles.workspaceRowHovered,
pressed && styles.workspaceRowPressed,
@@ -490,18 +391,14 @@ function WorkspaceRowInner({
onPressOut={interaction.handlePressOut}
onPress={handlePress}
onLongPress={interaction.handleLongPress}
delayLongPress={200}
delayLongPress={350}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
{rowChildren}
</Pressable>
)
const content = interaction.moveMonitorGesture ? (
<GestureDetector gesture={interaction.moveMonitorGesture}>{trigger}</GestureDetector>
) : (
trigger
)
const content = trigger
return (
<View style={styles.workspaceRowContainer}>
@@ -523,6 +420,7 @@ function WorkspaceRowWithMenu({
showShortcutBadge,
onPress,
drag,
isDragging,
dragHandleProps,
}: {
workspace: SidebarWorkspaceEntry
@@ -531,6 +429,7 @@ function WorkspaceRowWithMenu({
showShortcutBadge: boolean
onPress: () => void
drag: () => void
isDragging: boolean
dragHandleProps?: DraggableListDragHandleProps
}) {
const toast = useToast()
@@ -586,6 +485,7 @@ function WorkspaceRowWithMenu({
showShortcutBadge={showShortcutBadge}
onPress={onPress}
drag={drag}
isDragging={isDragging}
isArchiving={isArchiving}
dragHandleProps={dragHandleProps}
menuController={contextMenu}
@@ -612,6 +512,7 @@ function WorkspaceRowPlain({
showShortcutBadge,
onPress,
drag,
isDragging,
dragHandleProps,
}: {
workspace: SidebarWorkspaceEntry
@@ -620,6 +521,7 @@ function WorkspaceRowPlain({
showShortcutBadge: boolean
onPress: () => void
drag: () => void
isDragging: boolean
dragHandleProps?: DraggableListDragHandleProps
}) {
return (
@@ -630,6 +532,7 @@ function WorkspaceRowPlain({
showShortcutBadge={showShortcutBadge}
onPress={onPress}
drag={drag}
isDragging={isDragging}
isArchiving={false}
dragHandleProps={dragHandleProps}
menuController={null}
@@ -644,6 +547,7 @@ function WorkspaceRow({
showShortcutBadge,
onPress,
drag,
isDragging,
dragHandleProps,
}: {
workspace: SidebarWorkspaceEntry
@@ -652,6 +556,7 @@ function WorkspaceRow({
showShortcutBadge: boolean
onPress: () => void
drag: () => void
isDragging: boolean
dragHandleProps?: DraggableListDragHandleProps
}) {
if (!isPaseoOwnedWorktreePath(workspace.workspaceId)) {
@@ -663,6 +568,7 @@ function WorkspaceRow({
showShortcutBadge={showShortcutBadge}
onPress={onPress}
drag={drag}
isDragging={isDragging}
dragHandleProps={dragHandleProps}
/>
)
@@ -677,6 +583,7 @@ function WorkspaceRow({
showShortcutBadge={showShortcutBadge}
onPress={onPress}
drag={drag}
isDragging={isDragging}
dragHandleProps={dragHandleProps}
/>
</ContextMenu>
@@ -697,7 +604,10 @@ function ProjectBlock({
onToggleCollapsed,
onWorkspacePress,
onWorkspaceReorder,
onAnyDragBegin,
onAnyDragEnd,
drag,
isDragging,
dragHandleProps,
useNestable,
}: {
@@ -714,12 +624,20 @@ function ProjectBlock({
onToggleCollapsed: () => void
onWorkspacePress?: () => void
onWorkspaceReorder: (projectKey: string, workspaces: SidebarWorkspaceEntry[]) => void
onAnyDragBegin: () => void
onAnyDragEnd: () => void
drag: () => void
isDragging: boolean
dragHandleProps?: DraggableListDragHandleProps
useNestable: boolean
}) {
const renderWorkspace = useCallback(
({ item, drag: workspaceDrag, dragHandleProps: workspaceDragHandleProps }: DraggableRenderItemInfo<SidebarWorkspaceEntry>) => {
({
item,
drag: workspaceDrag,
isActive,
dragHandleProps: workspaceDragHandleProps,
}: DraggableRenderItemInfo<SidebarWorkspaceEntry>) => {
const workspaceRoute = buildHostWorkspaceRoute(serverId ?? '', item.workspaceId)
const navigate = shouldReplaceWorkspaceNavigation ? router.replace : router.push
const isSelected =
@@ -741,6 +659,7 @@ function ProjectBlock({
navigate(workspaceRoute as any)
}}
drag={workspaceDrag}
isDragging={isActive}
dragHandleProps={workspaceDragHandleProps}
/>
)
@@ -764,15 +683,23 @@ function ProjectBlock({
collapsed={collapsed}
onToggle={onToggleCollapsed}
drag={drag}
isDragging={isDragging}
dragHandleProps={dragHandleProps}
/>
{!collapsed ? (
<DraggableList
testID={`sidebar-workspace-list-${project.projectKey}`}
data={project.workspaces}
keyExtractor={(workspace) => workspace.workspaceKey}
renderItem={renderWorkspace}
onDragEnd={(workspaces) => onWorkspaceReorder(project.projectKey, workspaces)}
onDragIntent={onAnyDragBegin}
onDragBegin={onAnyDragBegin}
onDragRelease={onAnyDragEnd}
onDragEnd={(workspaces) => {
onWorkspaceReorder(project.projectKey, workspaces)
onAnyDragEnd()
}}
scrollEnabled={false}
useDragHandle
nestable={useNestable}
@@ -795,11 +722,11 @@ export function SidebarWorkspaceList({
parentGestureRef,
}: SidebarWorkspaceListProps) {
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
const isNative = Platform.OS !== 'web'
const segments = useSegments()
const pathname = usePathname()
const shouldReplaceWorkspaceNavigation = segments[0] === 'h'
const [collapsedProjectKeys, setCollapsedProjectKeys] = useState<Set<string>>(new Set())
const [outerScrollEnabled, setOuterScrollEnabled] = useState(true)
const isTauri = getIsTauri()
const altDown = useKeyboardShortcutsStore((state) => state.altDown)
const cmdOrCtrlDown = useKeyboardShortcutsStore((state) => state.cmdOrCtrlDown)
@@ -938,6 +865,16 @@ export function SidebarWorkspaceList({
})
}, [])
const lockOuterScrollForDrag = useCallback((source: string) => {
console.log('[sidebar-dnd-debug] outer scroll locked', { source })
setOuterScrollEnabled(false)
}, [])
const unlockOuterScrollForDrag = useCallback((source: string) => {
console.log('[sidebar-dnd-debug] outer scroll unlocked', { source })
setOuterScrollEnabled(true)
}, [])
const handleProjectDragEnd = useCallback(
(reorderedProjects: SidebarProjectEntry[]) => {
if (!serverId) {
@@ -996,7 +933,7 @@ export function SidebarWorkspaceList({
)
const renderProject = useCallback(
({ item, drag, dragHandleProps }: DraggableRenderItemInfo<SidebarProjectEntry>) => {
({ item, drag, isActive, dragHandleProps }: DraggableRenderItemInfo<SidebarProjectEntry>) => {
return (
<ProjectBlock
project={item}
@@ -1012,9 +949,12 @@ export function SidebarWorkspaceList({
onToggleCollapsed={() => toggleProjectCollapsed(item.projectKey)}
onWorkspacePress={onWorkspacePress}
onWorkspaceReorder={handleWorkspaceReorder}
onAnyDragBegin={() => lockOuterScrollForDrag(`workspace:${item.projectKey}`)}
onAnyDragEnd={() => unlockOuterScrollForDrag(`workspace:${item.projectKey}`)}
drag={drag}
isDragging={isActive}
dragHandleProps={dragHandleProps}
useNestable={isNative}
useNestable={false}
/>
)
},
@@ -1022,7 +962,6 @@ export function SidebarWorkspaceList({
activeWorkspaceSelection,
collapsedProjectKeys,
handleWorkspaceReorder,
isNative,
onWorkspacePress,
parentGestureRef,
projectIconByProjectKey,
@@ -1031,6 +970,8 @@ export function SidebarWorkspaceList({
shouldReplaceWorkspaceNavigation,
showShortcutBadges,
toggleProjectCollapsed,
lockOuterScrollForDrag,
unlockOuterScrollForDrag,
]
)
@@ -1040,13 +981,20 @@ export function SidebarWorkspaceList({
<Text style={styles.emptyText}>No projects yet</Text>
) : (
<DraggableList
testID="sidebar-project-list"
data={projects}
keyExtractor={(project) => project.projectKey}
renderItem={renderProject}
onDragEnd={handleProjectDragEnd}
onDragIntent={() => lockOuterScrollForDrag('project-list-intent')}
onDragBegin={() => lockOuterScrollForDrag('project-list-begin')}
onDragRelease={() => unlockOuterScrollForDrag('project-list-release')}
onDragEnd={(reorderedProjects) => {
handleProjectDragEnd(reorderedProjects)
unlockOuterScrollForDrag('project-list-end')
}}
scrollEnabled={false}
useDragHandle
nestable={isNative}
nestable={false}
simultaneousGestureRef={parentGestureRef}
containerStyle={styles.projectListContainer}
/>
@@ -1057,25 +1005,18 @@ export function SidebarWorkspaceList({
return (
<View style={styles.container}>
{isNative ? (
<NestableScrollContainer
style={styles.list}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
testID="sidebar-project-workspace-list-scroll"
>
{content}
</NestableScrollContainer>
) : (
<ScrollView
style={styles.list}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
testID="sidebar-project-workspace-list-scroll"
>
{content}
</ScrollView>
)}
<ScrollView
style={styles.list}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
scrollEnabled={outerScrollEnabled}
onScrollBeginDrag={() =>
console.log('[sidebar-dnd-debug] outer scroll begin', { outerScrollEnabled })
}
testID="sidebar-project-workspace-list-scroll"
>
{content}
</ScrollView>
</View>
)
}
@@ -1124,6 +1065,18 @@ const styles = StyleSheet.create((theme) => ({
projectRowPressed: {
backgroundColor: theme.colors.surface2,
},
projectRowDragging: {
backgroundColor: theme.colors.surface2,
borderWidth: 1,
borderColor: theme.colors.border,
transform: [{ scale: 1.02 }],
zIndex: 3,
elevation: 4,
shadowColor: '#000',
shadowOpacity: 0.2,
shadowRadius: 8,
shadowOffset: { width: 0, height: 4 },
},
projectRowLeft: {
flexDirection: 'row',
alignItems: 'center',
@@ -1185,6 +1138,18 @@ const styles = StyleSheet.create((theme) => ({
workspaceRowPressed: {
backgroundColor: theme.colors.surface2,
},
workspaceRowDragging: {
backgroundColor: theme.colors.surface2,
borderWidth: 1,
borderColor: theme.colors.border,
transform: [{ scale: 1.02 }],
zIndex: 3,
elevation: 4,
shadowColor: '#000',
shadowOpacity: 0.2,
shadowRadius: 8,
shadowOffset: { width: 0, height: 4 },
},
workspaceRowSelected: {
backgroundColor: theme.colors.surface2,
},
@@ -1226,6 +1191,22 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
minWidth: 0,
},
dragHandle: {
width: 24,
height: 24,
alignItems: 'center',
justifyContent: 'center',
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface0,
flexShrink: 0,
},
dragHandlePressed: {
backgroundColor: theme.colors.surface2,
borderColor: theme.colors.foregroundMuted,
transform: [{ scale: 0.96 }],
},
workspaceCreatedAtText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,