refactor: extract workspace terminal sessions, improve sidebar drag-and-drop with nestable lists, and consolidate tab presentation logic
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
import { RefreshControl } from "react-native";
|
||||
import { useCallback, useState } from "react";
|
||||
import DraggableFlatList, { type RenderItemParams } from "react-native-draggable-flatlist";
|
||||
import DraggableFlatList, {
|
||||
NestableDraggableFlatList,
|
||||
type RenderItemParams,
|
||||
} from "react-native-draggable-flatlist";
|
||||
import { useUnistyles } from "react-native-unistyles";
|
||||
import type {
|
||||
DraggableListProps,
|
||||
@@ -30,63 +33,56 @@ export function DraggableList<T>({
|
||||
simultaneousGestureRef,
|
||||
waitFor,
|
||||
onDragBegin: onDragBeginProp,
|
||||
onDragIntent: onDragIntentProp,
|
||||
onDragRelease: onDragReleaseProp,
|
||||
nestable: _nestable = false,
|
||||
nestable = false,
|
||||
}: DraggableListProps<T>) {
|
||||
const { theme } = useUnistyles();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
// Pass the ref directly to DraggableFlatList - it handles gesture
|
||||
// coordination internally for nestable lists.
|
||||
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: dragWithLog,
|
||||
drag,
|
||||
isActive,
|
||||
};
|
||||
return renderItem(info);
|
||||
},
|
||||
[keyExtractor, onDragIntentProp, renderItem, testID]
|
||||
[renderItem]
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
({ data: newData }: { data: T[] }) => {
|
||||
console.log("[sidebar-dnd-debug] list onDragEnd", { testID, count: newData.length });
|
||||
setIsDragging(false);
|
||||
onDragEnd(newData);
|
||||
},
|
||||
[onDragEnd, testID]
|
||||
[onDragEnd]
|
||||
);
|
||||
|
||||
const handleDragBegin = useCallback(() => {
|
||||
console.log("[sidebar-dnd-debug] list onDragBegin", { testID });
|
||||
setIsDragging(true);
|
||||
onDragBeginProp?.();
|
||||
}, [onDragBeginProp, testID]);
|
||||
}, [onDragBeginProp]);
|
||||
|
||||
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 shouldShowRefreshControl = showRefreshControl;
|
||||
const shouldShowRefreshControl = showRefreshControl && !nestable;
|
||||
const ListComponent: typeof DraggableFlatList = (nestable
|
||||
? (NestableDraggableFlatList as any)
|
||||
: DraggableFlatList) as any;
|
||||
|
||||
return (
|
||||
<DraggableFlatList
|
||||
<ListComponent
|
||||
testID={testID}
|
||||
data={data}
|
||||
keyExtractor={keyExtractor}
|
||||
@@ -101,7 +97,9 @@ export function DraggableList<T>({
|
||||
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
|
||||
scrollEnabled={scrollEnabled}
|
||||
simultaneousHandlers={simultaneousHandlers}
|
||||
activationDistance={6}
|
||||
// Higher activation distance reduces accidental drag capture while nested
|
||||
// lists are inside a scroll container.
|
||||
activationDistance={20}
|
||||
onDragBegin={handleDragBegin}
|
||||
onRelease={handleRelease}
|
||||
// @ts-ignore - waitFor is supported by RNGH FlatList but missing from DraggableFlatList types
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { StyleSheet, UnistylesRuntime } from "react-native-unistyles";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
HEADER_INNER_HEIGHT,
|
||||
HEADER_INNER_HEIGHT_MOBILE,
|
||||
HEADER_TOP_PADDING_MOBILE,
|
||||
getIsTauriMac,
|
||||
} from "@/constants/layout";
|
||||
import { useTauriDragHandlers } from "@/utils/tauri-window";
|
||||
import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-window";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
|
||||
interface ScreenHeaderProps {
|
||||
left?: ReactNode;
|
||||
@@ -26,19 +28,29 @@ export function ScreenHeader({
|
||||
leftStyle,
|
||||
rightStyle,
|
||||
}: ScreenHeaderProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
|
||||
const trafficLightPadding = useTrafficLightPadding();
|
||||
// Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets
|
||||
const topPadding = isMobile ? HEADER_TOP_PADDING_MOBILE : 0;
|
||||
const baseHorizontalPadding = theme.spacing[2];
|
||||
const collapsedSidebarTrafficLightInset =
|
||||
!isMobile && !desktopAgentListOpen && getIsTauriMac()
|
||||
? trafficLightPadding.left
|
||||
: 0;
|
||||
|
||||
// On Tauri macOS, enable window dragging and double-click to maximize
|
||||
// Left padding for traffic lights is handled by _layout.tsx when sidebar is collapsed
|
||||
const dragHandlers = useTauriDragHandlers();
|
||||
|
||||
return (
|
||||
<View style={styles.header}>
|
||||
<View style={[styles.inner, { paddingTop: insets.top + topPadding }]}>
|
||||
<View style={styles.row} {...dragHandlers}>
|
||||
<View
|
||||
style={[styles.row, { paddingLeft: baseHorizontalPadding + collapsedSidebarTrafficLightInset }]}
|
||||
{...dragHandlers}
|
||||
>
|
||||
<View style={[styles.left, leftStyle]}>{left}</View>
|
||||
<View style={[styles.right, rightStyle]}>{right}</View>
|
||||
</View>
|
||||
|
||||
@@ -32,6 +32,14 @@ import {
|
||||
} from '@/utils/host-routes'
|
||||
|
||||
const DESKTOP_SIDEBAR_WIDTH = 320
|
||||
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__)
|
||||
|
||||
function logLeftSidebarCloseGesture(event: string, details: Record<string, unknown>): void {
|
||||
if (!IS_DEV) {
|
||||
return
|
||||
}
|
||||
console.log(`[LeftSidebarCloseGesture] ${event}`, details)
|
||||
}
|
||||
|
||||
interface LeftSidebarProps {
|
||||
selectedAgentId?: string
|
||||
@@ -253,6 +261,7 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
})
|
||||
.onStart(() => {
|
||||
isGesturing.value = true
|
||||
runOnJS(logLeftSidebarCloseGesture)('start', { isOpen, isMobile })
|
||||
})
|
||||
.onUpdate((event) => {
|
||||
if (!isMobile) return
|
||||
@@ -270,6 +279,11 @@ export function LeftSidebar({ selectedAgentId: _selectedAgentId }: LeftSidebarPr
|
||||
isGesturing.value = false
|
||||
if (!isMobile) return
|
||||
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500
|
||||
runOnJS(logLeftSidebarCloseGesture)('end', {
|
||||
translationX: event.translationX,
|
||||
velocityX: event.velocityX,
|
||||
shouldClose,
|
||||
})
|
||||
if (shouldClose) {
|
||||
animateToClose()
|
||||
runOnJS(handleClose)()
|
||||
|
||||
@@ -24,6 +24,7 @@ import { router, usePathname } from 'expo-router'
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
|
||||
import { type GestureType } from 'react-native-gesture-handler'
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react-native'
|
||||
import { NestableScrollContainer } from 'react-native-draggable-flatlist'
|
||||
import { DraggableList, type DraggableRenderItemInfo } from './draggable-list'
|
||||
import type { DraggableListDragHandleProps } from './draggable-list.types'
|
||||
import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime'
|
||||
@@ -52,6 +53,7 @@ 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 { decideLongPressMove } from '@/utils/sidebar-gesture-arbitration'
|
||||
import { confirmDialog } from '@/utils/confirm-dialog'
|
||||
|
||||
const PASEO_WORKTREE_PATH_MARKER = '/.paseo/worktrees'
|
||||
@@ -160,6 +162,7 @@ function useLongPressDragInteraction(input: {
|
||||
}) {
|
||||
const didLongPressRef = useRef(false)
|
||||
const dragArmedRef = useRef(false)
|
||||
const dragActivatedRef = useRef(false)
|
||||
const didStartDragRef = useRef(false)
|
||||
const scrollIntentRef = useRef(false)
|
||||
const menuOpenedRef = useRef(false)
|
||||
@@ -212,7 +215,8 @@ function useLongPressDragInteraction(input: {
|
||||
const armTimers = useCallback(() => {
|
||||
clearTimers()
|
||||
|
||||
const DRAG_ARM_DELAY_MS = 140
|
||||
const DRAG_ARM_DELAY_MS = 180
|
||||
const DRAG_ARM_STATIONARY_SLOP_PX = 4
|
||||
const CONTEXT_MENU_DELAY_MS = 450
|
||||
const CONTEXT_MENU_STATIONARY_SLOP_PX = 6
|
||||
|
||||
@@ -220,9 +224,27 @@ function useLongPressDragInteraction(input: {
|
||||
if (scrollIntentRef.current || didStartDragRef.current || menuOpenedRef.current) {
|
||||
return
|
||||
}
|
||||
const start = touchStartRef.current
|
||||
const current = touchCurrentRef.current ?? start
|
||||
if (!start || !current) {
|
||||
return
|
||||
}
|
||||
const dx = current.x - start.x
|
||||
const dy = current.y - start.y
|
||||
const distance = Math.sqrt(dx * dx + dy * dy)
|
||||
if (distance > DRAG_ARM_STATIONARY_SLOP_PX) {
|
||||
console.log('[sidebar-dnd-debug] drag arm cancelled (movement)', {
|
||||
id: input.debugId,
|
||||
distance,
|
||||
})
|
||||
return
|
||||
}
|
||||
dragArmedRef.current = true
|
||||
dragActivatedRef.current = true
|
||||
didLongPressRef.current = true
|
||||
console.log('[sidebar-dnd-debug] drag armed', { id: input.debugId })
|
||||
void Haptics.selectionAsync().catch(() => {})
|
||||
input.drag()
|
||||
}, DRAG_ARM_DELAY_MS)
|
||||
|
||||
if (!input.menuController || Platform.OS === 'web') {
|
||||
@@ -256,14 +278,16 @@ function useLongPressDragInteraction(input: {
|
||||
|
||||
const handleDragIntent = useCallback(
|
||||
(details: { dx: number; dy: number; distance: number }) => {
|
||||
if (!dragActivatedRef.current) {
|
||||
return
|
||||
}
|
||||
didStartDragRef.current = true
|
||||
didLongPressRef.current = true
|
||||
clearTimers()
|
||||
console.log('[sidebar-dnd-debug] drag intent detected', { id: input.debugId, ...details })
|
||||
console.log('[sidebar-dnd-debug] drag movement detected', { id: input.debugId, ...details })
|
||||
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {})
|
||||
input.drag()
|
||||
},
|
||||
[clearTimers, input]
|
||||
[clearTimers, input.debugId]
|
||||
)
|
||||
|
||||
const handleScrollIntent = useCallback(
|
||||
@@ -276,9 +300,19 @@ function useLongPressDragInteraction(input: {
|
||||
[clearTimers, input.debugId]
|
||||
)
|
||||
|
||||
const handleSwipeIntent = useCallback(
|
||||
(details: { dx: number; dy: number; distance: number }) => {
|
||||
didLongPressRef.current = true
|
||||
clearTimers()
|
||||
console.log('[sidebar-dnd-debug] swipe intent detected', { id: input.debugId, ...details })
|
||||
},
|
||||
[clearTimers, input.debugId]
|
||||
)
|
||||
|
||||
const handlePressIn = useCallback((event: GestureResponderEvent) => {
|
||||
didLongPressRef.current = false
|
||||
dragArmedRef.current = false
|
||||
dragActivatedRef.current = false
|
||||
didStartDragRef.current = false
|
||||
scrollIntentRef.current = false
|
||||
menuOpenedRef.current = false
|
||||
@@ -301,7 +335,7 @@ function useLongPressDragInteraction(input: {
|
||||
const handleTouchMove = useCallback(
|
||||
(event: any) => {
|
||||
const start = touchStartRef.current
|
||||
if (!start || didStartDragRef.current) {
|
||||
if (!start || didStartDragRef.current || menuOpenedRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -311,30 +345,34 @@ function useLongPressDragInteraction(input: {
|
||||
if (typeof x !== 'number' || typeof y !== 'number') {
|
||||
return
|
||||
}
|
||||
touchCurrentRef.current = { x, y }
|
||||
const dx = x - start.x
|
||||
const dy = y - start.y
|
||||
const absDx = Math.abs(dx)
|
||||
const absDy = Math.abs(dy)
|
||||
|
||||
const current = { x, y }
|
||||
touchCurrentRef.current = current
|
||||
const dx = current.x - start.x
|
||||
const dy = current.y - start.y
|
||||
const distance = Math.sqrt(dx * dx + dy * dy)
|
||||
const decision = decideLongPressMove({
|
||||
dragArmed: dragArmedRef.current,
|
||||
didStartDrag: didStartDragRef.current,
|
||||
startPoint: start,
|
||||
currentPoint: current,
|
||||
})
|
||||
|
||||
const SCROLL_INTENT_SLOP_PX = 8
|
||||
const DRAG_START_SLOP_PX = 6
|
||||
|
||||
if (!scrollIntentRef.current && absDy > absDx && absDy > SCROLL_INTENT_SLOP_PX) {
|
||||
if (decision === 'vertical_scroll') {
|
||||
handleScrollIntent({ dx, dy, distance })
|
||||
return
|
||||
}
|
||||
|
||||
if (scrollIntentRef.current) {
|
||||
if (decision === 'horizontal_swipe' || decision === 'cancel_long_press') {
|
||||
handleSwipeIntent({ dx, dy, distance })
|
||||
return
|
||||
}
|
||||
|
||||
if (dragArmedRef.current && distance >= DRAG_START_SLOP_PX) {
|
||||
if (decision === 'start_drag') {
|
||||
handleDragIntent({ dx, dy, distance })
|
||||
}
|
||||
},
|
||||
[handleDragIntent, handleScrollIntent]
|
||||
[handleDragIntent, handleScrollIntent, handleSwipeIntent]
|
||||
)
|
||||
|
||||
const handlePressOut = useCallback(() => {
|
||||
@@ -343,11 +381,13 @@ function useLongPressDragInteraction(input: {
|
||||
id: input.debugId,
|
||||
didLongPress: didLongPressRef.current,
|
||||
didStartDrag: didStartDragRef.current,
|
||||
dragActivated: dragActivatedRef.current,
|
||||
scrollIntent: scrollIntentRef.current,
|
||||
dragArmed: dragArmedRef.current,
|
||||
menuOpened: menuOpenedRef.current,
|
||||
})
|
||||
dragArmedRef.current = false
|
||||
dragActivatedRef.current = false
|
||||
touchStartRef.current = null
|
||||
touchCurrentRef.current = null
|
||||
}, [clearTimers, input.debugId])
|
||||
@@ -371,10 +411,9 @@ function ProjectHeaderRow({
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
}: ProjectHeaderRowProps) {
|
||||
const menuController = useContextMenu()
|
||||
const interaction = useLongPressDragInteraction({
|
||||
drag,
|
||||
menuController,
|
||||
menuController: null,
|
||||
debugId: `project:${project.projectKey}`,
|
||||
})
|
||||
|
||||
@@ -387,8 +426,7 @@ function ProjectHeaderRow({
|
||||
}, [interaction.didLongPressRef, onToggle])
|
||||
|
||||
const trigger = (
|
||||
<ContextMenuTrigger
|
||||
enabledOnMobile={false}
|
||||
<Pressable
|
||||
style={({ pressed, hovered = false }) => [
|
||||
styles.projectRow,
|
||||
isDragging && styles.projectRowDragging,
|
||||
@@ -407,12 +445,6 @@ function ProjectHeaderRow({
|
||||
ref={dragHandleProps?.setActivatorNodeRef as any}
|
||||
style={styles.projectRowLeft}
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight size={14} color="#9ca3af" />
|
||||
) : (
|
||||
<ChevronDown size={14} color="#9ca3af" />
|
||||
)}
|
||||
|
||||
{iconDataUri ? (
|
||||
<Image source={{ uri: iconDataUri }} style={styles.projectIcon} />
|
||||
) : (
|
||||
@@ -424,29 +456,19 @@ function ProjectHeaderRow({
|
||||
<Text style={styles.projectTitle} numberOfLines={1}>
|
||||
{displayName}
|
||||
</Text>
|
||||
|
||||
{collapsed ? (
|
||||
<ChevronRight size={14} color="#9ca3af" />
|
||||
) : (
|
||||
<ChevronDown size={14} color="#9ca3af" />
|
||||
)}
|
||||
</View>
|
||||
</ContextMenuTrigger>
|
||||
</Pressable>
|
||||
)
|
||||
|
||||
return trigger
|
||||
}
|
||||
|
||||
function ProjectHeaderRowWithMenu(props: ProjectHeaderRowProps) {
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ProjectHeaderRow {...props} />
|
||||
<ContextMenuContent align="start" width={220} testID={`sidebar-project-context-${props.project.projectKey}`}>
|
||||
<ContextMenuItem
|
||||
testID={`sidebar-project-context-${props.project.projectKey}-toggle`}
|
||||
onSelect={props.onToggle}
|
||||
>
|
||||
{props.collapsed ? 'Expand project' : 'Collapse project'}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkspaceRowInner({
|
||||
workspace,
|
||||
selected,
|
||||
@@ -550,7 +572,7 @@ function WorkspaceRowInner({
|
||||
)
|
||||
}
|
||||
|
||||
function WorkspaceRowWithMenu({
|
||||
function WorkspaceRowWithMenuContent({
|
||||
workspace,
|
||||
selected,
|
||||
shortcutNumber,
|
||||
@@ -644,6 +666,41 @@ function WorkspaceRowWithMenu({
|
||||
)
|
||||
}
|
||||
|
||||
function WorkspaceRowWithMenu({
|
||||
workspace,
|
||||
selected,
|
||||
shortcutNumber,
|
||||
showShortcutBadge,
|
||||
onPress,
|
||||
drag,
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry
|
||||
selected: boolean
|
||||
shortcutNumber: number | null
|
||||
showShortcutBadge: boolean
|
||||
onPress: () => void
|
||||
drag: () => void
|
||||
isDragging: boolean
|
||||
dragHandleProps?: DraggableListDragHandleProps
|
||||
}) {
|
||||
return (
|
||||
<ContextMenu>
|
||||
<WorkspaceRowWithMenuContent
|
||||
workspace={workspace}
|
||||
selected={selected}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
onPress={onPress}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkspaceRowPlain({
|
||||
workspace,
|
||||
selected,
|
||||
@@ -714,18 +771,16 @@ function WorkspaceRow({
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<WorkspaceRowWithMenu
|
||||
workspace={workspace}
|
||||
selected={selected}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
onPress={onPress}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
</ContextMenu>
|
||||
<WorkspaceRowWithMenu
|
||||
workspace={workspace}
|
||||
selected={selected}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
onPress={onPress}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -742,8 +797,6 @@ function ProjectBlock({
|
||||
onToggleCollapsed,
|
||||
onWorkspacePress,
|
||||
onWorkspaceReorder,
|
||||
onAnyDragBegin,
|
||||
onAnyDragEnd,
|
||||
drag,
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
@@ -761,8 +814,6 @@ function ProjectBlock({
|
||||
onToggleCollapsed: () => void
|
||||
onWorkspacePress?: () => void
|
||||
onWorkspaceReorder: (projectKey: string, workspaces: SidebarWorkspaceEntry[]) => void
|
||||
onAnyDragBegin: () => void
|
||||
onAnyDragEnd: () => void
|
||||
drag: () => void
|
||||
isDragging: boolean
|
||||
dragHandleProps?: DraggableListDragHandleProps
|
||||
@@ -811,7 +862,7 @@ function ProjectBlock({
|
||||
|
||||
return (
|
||||
<View style={styles.projectBlock}>
|
||||
<ProjectHeaderRowWithMenu
|
||||
<ProjectHeaderRow
|
||||
project={project}
|
||||
displayName={displayName}
|
||||
iconDataUri={iconDataUri}
|
||||
@@ -828,13 +879,7 @@ function ProjectBlock({
|
||||
data={project.workspaces}
|
||||
keyExtractor={(workspace) => workspace.workspaceKey}
|
||||
renderItem={renderWorkspace}
|
||||
onDragIntent={onAnyDragBegin}
|
||||
onDragBegin={onAnyDragBegin}
|
||||
onDragRelease={onAnyDragEnd}
|
||||
onDragEnd={(workspaces) => {
|
||||
onWorkspaceReorder(project.projectKey, workspaces)
|
||||
onAnyDragEnd()
|
||||
}}
|
||||
onDragEnd={(workspaces) => onWorkspaceReorder(project.projectKey, workspaces)}
|
||||
scrollEnabled={false}
|
||||
useDragHandle
|
||||
nestable={useNestable}
|
||||
@@ -857,9 +902,9 @@ export function SidebarWorkspaceList({
|
||||
parentGestureRef,
|
||||
}: SidebarWorkspaceListProps) {
|
||||
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
|
||||
const isNative = Platform.OS !== 'web'
|
||||
const pathname = usePathname()
|
||||
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)
|
||||
@@ -1008,16 +1053,6 @@ 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) {
|
||||
@@ -1091,12 +1126,10 @@ 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={false}
|
||||
useNestable={isNative}
|
||||
/>
|
||||
)
|
||||
},
|
||||
@@ -1111,8 +1144,7 @@ export function SidebarWorkspaceList({
|
||||
shortcutModel.shortcutIndexByWorkspaceKey,
|
||||
showShortcutBadges,
|
||||
toggleProjectCollapsed,
|
||||
lockOuterScrollForDrag,
|
||||
unlockOuterScrollForDrag,
|
||||
isNative,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -1126,16 +1158,10 @@ export function SidebarWorkspaceList({
|
||||
data={projects}
|
||||
keyExtractor={(project) => project.projectKey}
|
||||
renderItem={renderProject}
|
||||
onDragIntent={() => lockOuterScrollForDrag('project-list-intent')}
|
||||
onDragBegin={() => lockOuterScrollForDrag('project-list-begin')}
|
||||
onDragRelease={() => unlockOuterScrollForDrag('project-list-release')}
|
||||
onDragEnd={(reorderedProjects) => {
|
||||
handleProjectDragEnd(reorderedProjects)
|
||||
unlockOuterScrollForDrag('project-list-end')
|
||||
}}
|
||||
onDragEnd={handleProjectDragEnd}
|
||||
scrollEnabled={false}
|
||||
useDragHandle
|
||||
nestable={false}
|
||||
nestable={isNative}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
containerStyle={styles.projectListContainer}
|
||||
/>
|
||||
@@ -1146,18 +1172,31 @@ export function SidebarWorkspaceList({
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<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>
|
||||
{isNative ? (
|
||||
<NestableScrollContainer
|
||||
style={styles.list}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
onScrollBeginDrag={() =>
|
||||
console.log('[sidebar-dnd-debug] outer scroll begin')
|
||||
}
|
||||
testID="sidebar-project-workspace-list-scroll"
|
||||
>
|
||||
{content}
|
||||
</NestableScrollContainer>
|
||||
) : (
|
||||
<ScrollView
|
||||
style={styles.list}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
onScrollBeginDrag={() =>
|
||||
console.log('[sidebar-dnd-debug] outer scroll begin')
|
||||
}
|
||||
testID="sidebar-project-workspace-list-scroll"
|
||||
>
|
||||
{content}
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -29,10 +29,8 @@ import {
|
||||
import { upsertTerminalListEntry } from "@/utils/terminal-list";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import {
|
||||
getTerminalOutputSession,
|
||||
releaseTerminalOutputSession,
|
||||
retainTerminalOutputSession,
|
||||
} from "@/terminal/runtime/terminal-output-session";
|
||||
getWorkspaceTerminalSession,
|
||||
} from "@/terminal/runtime/workspace-terminal-session";
|
||||
import {
|
||||
TerminalStreamController,
|
||||
type TerminalStreamControllerStatus,
|
||||
@@ -162,14 +160,15 @@ export function TerminalPane({
|
||||
const terminalsQueryKey = useMemo(() => ["terminals", serverId, cwd] as const, [cwd, serverId]);
|
||||
const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null);
|
||||
const streamControllerRef = useRef<TerminalStreamController | null>(null);
|
||||
const outputSession = useMemo(
|
||||
const workspaceTerminalSession = useMemo(
|
||||
() =>
|
||||
getTerminalOutputSession({
|
||||
getWorkspaceTerminalSession({
|
||||
scopeKey,
|
||||
maxOutputChars: MAX_OUTPUT_CHARS,
|
||||
}),
|
||||
[scopeKey]
|
||||
);
|
||||
const outputSession = workspaceTerminalSession.outputSession;
|
||||
const subscribeOutputSession = useCallback(
|
||||
(listener: () => void) => outputSession.subscribe(listener),
|
||||
[outputSession]
|
||||
@@ -178,17 +177,26 @@ export function TerminalPane({
|
||||
() => outputSession.getState(),
|
||||
[outputSession]
|
||||
);
|
||||
useEffect(() => {
|
||||
retainTerminalOutputSession({ scopeKey });
|
||||
return () => {
|
||||
releaseTerminalOutputSession({ scopeKey });
|
||||
};
|
||||
}, [scopeKey]);
|
||||
const outputState = useSyncExternalStore(
|
||||
subscribeOutputSession,
|
||||
getOutputSessionState,
|
||||
getOutputSessionState
|
||||
);
|
||||
const selectedOutputState = useMemo(() => {
|
||||
if (outputState.selectedTerminalId === selectedTerminalId) {
|
||||
return outputState;
|
||||
}
|
||||
|
||||
return {
|
||||
...outputState,
|
||||
selectedTerminalId,
|
||||
snapshotText: outputSession.readSnapshot({ terminalId: selectedTerminalId }),
|
||||
snapshotSequence: 0,
|
||||
chunkText: "",
|
||||
chunkSequence: 0,
|
||||
chunkReplay: false,
|
||||
};
|
||||
}, [outputSession, outputState, selectedTerminalId]);
|
||||
const [activeStream, setActiveStream] = useState<{
|
||||
terminalId: string;
|
||||
streamId: number;
|
||||
@@ -503,6 +511,7 @@ export function TerminalPane({
|
||||
const controller = new TerminalStreamController({
|
||||
client,
|
||||
getPreferredSize: () => lastReportedSizeRef.current,
|
||||
resumeOffsets: workspaceTerminalSession.resumeOffsets,
|
||||
onChunk: ({ terminalId, text, replay }) => {
|
||||
outputSession.append({ terminalId, text, replay });
|
||||
},
|
||||
@@ -523,7 +532,14 @@ export function TerminalPane({
|
||||
streamControllerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [client, handleStreamControllerStatus, isConnected, isScreenFocused, outputSession]);
|
||||
}, [
|
||||
client,
|
||||
handleStreamControllerStatus,
|
||||
isConnected,
|
||||
isScreenFocused,
|
||||
outputSession,
|
||||
workspaceTerminalSession.resumeOffsets,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
pendingTerminalInputRef.current = [];
|
||||
@@ -1022,11 +1038,11 @@ export function TerminalPane({
|
||||
contentInsetAdjustmentBehavior: "never",
|
||||
}}
|
||||
streamKey={`${scopeKey}:${selectedTerminal.id}`}
|
||||
initialOutputText={outputState.snapshotText}
|
||||
initialOutputChunkSequence={outputState.snapshotSequence}
|
||||
outputChunkText={outputState.chunkText}
|
||||
outputChunkSequence={outputState.chunkSequence}
|
||||
outputChunkReplay={outputState.chunkReplay}
|
||||
initialOutputText={selectedOutputState.snapshotText}
|
||||
initialOutputChunkSequence={selectedOutputState.snapshotSequence}
|
||||
outputChunkText={selectedOutputState.chunkText}
|
||||
outputChunkSequence={selectedOutputState.chunkSequence}
|
||||
outputChunkReplay={selectedOutputState.chunkReplay}
|
||||
testId="terminal-surface"
|
||||
xtermTheme={xtermTheme}
|
||||
swipeGesturesEnabled={swipeGesturesEnabled}
|
||||
|
||||
@@ -45,6 +45,12 @@ export interface ComboboxProps {
|
||||
options: ComboboxOption[]
|
||||
value: string
|
||||
onSelect: (id: string) => void
|
||||
renderOption?: (input: {
|
||||
option: ComboboxOption
|
||||
selected: boolean
|
||||
active: boolean
|
||||
onPress: () => void
|
||||
}) => ReactElement
|
||||
onSearchQueryChange?: (query: string) => void
|
||||
searchable?: boolean
|
||||
placeholder?: string
|
||||
@@ -204,6 +210,7 @@ export function Combobox({
|
||||
options,
|
||||
value,
|
||||
onSelect,
|
||||
renderOption,
|
||||
onSearchQueryChange,
|
||||
searchable = true,
|
||||
placeholder = 'Search...',
|
||||
@@ -568,15 +575,26 @@ export function Combobox({
|
||||
<>
|
||||
{orderedVisibleOptions.length > 0 ? (
|
||||
orderedVisibleOptions.map((opt, index) => (
|
||||
<ComboboxItem
|
||||
key={opt.id}
|
||||
label={opt.label}
|
||||
description={opt.description}
|
||||
kind={opt.kind}
|
||||
selected={opt.id === value}
|
||||
active={index === activeIndex}
|
||||
onPress={() => handleSelect(opt.id)}
|
||||
/>
|
||||
renderOption ? (
|
||||
<View key={opt.id}>
|
||||
{renderOption({
|
||||
option: opt,
|
||||
selected: opt.id === value,
|
||||
active: index === activeIndex,
|
||||
onPress: () => handleSelect(opt.id),
|
||||
})}
|
||||
</View>
|
||||
) : (
|
||||
<ComboboxItem
|
||||
key={opt.id}
|
||||
label={opt.label}
|
||||
description={opt.description}
|
||||
kind={opt.kind}
|
||||
selected={opt.id === value}
|
||||
active={index === activeIndex}
|
||||
onPress={() => handleSelect(opt.id)}
|
||||
/>
|
||||
)
|
||||
))
|
||||
) : (
|
||||
<ComboboxEmpty>{emptyText}</ComboboxEmpty>
|
||||
|
||||
Reference in New Issue
Block a user