Compare commits

...
Author SHA1 Message Date
Mohamed Boudra 4683ddb3d1 fix(app): save the order a native drop shows and keep the grab point
onDragEnd now orders rows from sortables' indexToKey, the order on screen, instead of its keyToIndex-derived data, which can trail a swap by a frame. The lifted row keeps the point the finger grabbed, and a nested list's lifted row stays inside its parent row.
2026-11-14 13:01:17 +01:00
Mohamed Boudra 17079f4a56 fix(app): keep sidebar rows still when a project expands or collapses
Sortables animates every row whose position changes. Expanding a project
slid the projects below over its rows for 300 ms. Animate positions only
while a drag reorders the list, and drop the enter and exit animations.
2026-11-14 12:37:56 +01:00
Mohamed Boudra c701a1d936 fix(app): drop a sidebar row without a wrong frame on native
Move the native sidebar lists (projects, manual-sort workspaces, pinned
chats) from patched react-native-draggable-flatlist to
react-native-sortables. draggable-flatlist mounts the reorder commit with
the new layout and the old held drag translate, so every native drop
painted one frame with rows missing or in the wrong slot. Sortables
positions rows with UI-thread offsets, so the commit never moves a row.

The draggable-flatlist patch and the long-press drag hook go away; web
keeps dnd-kit unchanged.
2026-11-14 12:17:44 +01:00
22 changed files with 668 additions and 1522 deletions
-1
View File
@@ -418,7 +418,6 @@
"packages/app/src/components/context-window-meter.tsx",
"packages/app/src/components/dictation-controls.tsx",
"packages/app/src/components/download-toast.tsx",
"packages/app/src/components/draggable-list.native.tsx",
"packages/app/src/components/headers/back-header.tsx",
"packages/app/src/components/headers/screen-header.tsx",
"packages/app/src/components/host-status-dot.tsx",
+32 -15
View File
@@ -31853,20 +31853,6 @@
}
}
},
"node_modules/react-native-draggable-flatlist": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/react-native-draggable-flatlist/-/react-native-draggable-flatlist-4.0.3.tgz",
"integrity": "sha512-2F4x5BFieWdGq9SetD2nSAR7s7oQCSgNllYgERRXXtNfSOuAGAVbDb/3H3lP0y5f7rEyNwabKorZAD/SyyNbDw==",
"license": "MIT",
"dependencies": {
"@babel/preset-typescript": "^7.17.12"
},
"peerDependencies": {
"react-native": ">=0.64.0",
"react-native-gesture-handler": ">=2.0.0",
"react-native-reanimated": ">=2.8.0"
}
},
"node_modules/react-native-drawer-layout": {
"version": "4.2.12",
"resolved": "https://registry.npmjs.org/react-native-drawer-layout/-/react-native-drawer-layout-4.2.12.tgz",
@@ -31909,6 +31895,22 @@
"react-native": "*"
}
},
"node_modules/react-native-haptic-feedback": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/react-native-haptic-feedback/-/react-native-haptic-feedback-3.0.0.tgz",
"integrity": "sha512-yxBQFHhXU8S0xupGtzDRToGw5yMWezUdLzxtlx2Oi9YpjPwprWZWCw8foPt0S2t84B0ZikzZYyOHiMSXIelUxg==",
"license": "MIT",
"optional": true,
"peerDependencies": {
"react": ">=18.0.0",
"react-native": ">=0.71.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
}
}
},
"node_modules/react-native-is-edge-to-edge": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.3.1.tgz",
@@ -31992,6 +31994,21 @@
"integrity": "sha512-b+4vILXHPu++t8H41PHLBVsTab2LPqwXNdzgdScyl4+Cu8Ta34aUQW3T469cB0ogAMPdu//KV00w4YVpDZoRUQ==",
"license": "MIT"
},
"node_modules/react-native-sortables": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/react-native-sortables/-/react-native-sortables-1.10.1.tgz",
"integrity": "sha512-3M+OnzE2zgN2meECvd5cGQrm5oFQSQ34HPm3DSLAo8Xaif9+Zvtv5V4uX8u/HmBtI+v1gEuw7rjuYWqvUhZB9Q==",
"license": "MIT",
"optionalDependencies": {
"react-native-haptic-feedback": ">=2.0.0"
},
"peerDependencies": {
"react": "*",
"react-native": "*",
"react-native-gesture-handler": ">=2.0.0",
"react-native-reanimated": ">=3.0.0"
}
},
"node_modules/react-native-unistyles": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/react-native-unistyles/-/react-native-unistyles-3.2.4.tgz",
@@ -37549,7 +37566,6 @@
"react-freeze": "1.0.4",
"react-i18next": "^17.0.8",
"react-native": "0.86.3",
"react-native-draggable-flatlist": "^4.0.3",
"react-native-edge-to-edge": "^1.7.0",
"react-native-enriched-markdown": "1.2.0-nightly-20261014-766fc0e67",
"react-native-gesture-handler": "~2.32.0",
@@ -37558,6 +37574,7 @@
"react-native-reanimated": "4.5.5",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0",
"react-native-sortables": "^1.10.1",
"react-native-svg": "15.15.4",
"react-native-unistyles": "^3.2.4",
"react-native-web": "~0.21.0",
+3 -3
View File
@@ -18,9 +18,9 @@ maestro test packages/app/maestro/ios-sidebar-close-regression.yaml --udid <simu
`test-sidebar-drag-cancellation-ios.sh` creates three temporary projects and
uses real native slow swipes to cover successful reorder, a second touch during
the drop spring, horizontal close cancellation, outer-list scroll recovery, and
a subsequent reorder. The harness removes its daemon projects and local fixture
directories on exit.
the drop spring, a horizontal swipe after the lift that keeps the panel open
and the order unchanged, outer-list scroll recovery, and a subsequent reorder.
The harness removes its daemon projects and local fixture directories on exit.
Start the debug dev client against the intended Metro server, then run:
@@ -49,29 +49,22 @@ appId: ${PASEO_MAESTRO_APP_ID}
above:
text: ".*${PASEO_MAESTRO_DRAG_A_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_A_NAME}, Project actions"
# Horizontal intent after the lift belongs to panel close. It must cancel the
# drag without committing another reorder or leaving the outer list locked.
# Horizontal movement after the lift stays with the lifted row: the panel does
# not close, the drop commits no reorder, and the outer list stays scrollable.
- swipe:
from:
text: ".*${PASEO_MAESTRO_DRAG_B_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_B_NAME}, Project actions"
direction: LEFT
duration: 20000
- assertNotVisible:
- assertVisible:
id: "sidebar-project-list"
- tapOn:
id: "menu-button"
- extendedWaitUntil:
visible:
id: "sidebar-project-list"
timeout: 10000
- assertVisible:
text: ".*${PASEO_MAESTRO_DRAG_B_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_B_NAME}, Project actions"
above:
text: ".*${PASEO_MAESTRO_DRAG_A_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_A_NAME}, Project actions"
# Reaching a fixture at the bottom proves the nestable outer scroll was
# unlocked by cancellation.
# Reaching a fixture at the bottom proves the outer scroll still works after
# the horizontal drag.
- scrollUntilVisible:
element:
text: ".*${PASEO_MAESTRO_DRAG_Z_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_Z_NAME}, Project actions"
@@ -92,7 +85,7 @@ appId: ${PASEO_MAESTRO_APP_ID}
visibilityPercentage: 50
centerElement: true
# A real reorder after cancellation proves the list is not merely scrollable;
# A real reorder after the horizontal drag proves the list is not merely scrollable;
# its drag recognizer and semantic order update both recovered.
- swipe:
from:
+1 -1
View File
@@ -122,7 +122,6 @@
"react-freeze": "1.0.4",
"react-i18next": "^17.0.8",
"react-native": "0.86.3",
"react-native-draggable-flatlist": "^4.0.3",
"react-native-edge-to-edge": "^1.7.0",
"react-native-enriched-markdown": "1.2.0-nightly-20261014-766fc0e67",
"react-native-gesture-handler": "~2.32.0",
@@ -131,6 +130,7 @@
"react-native-reanimated": "4.5.5",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "~4.26.0",
"react-native-sortables": "^1.10.1",
"react-native-svg": "15.15.4",
"react-native-unistyles": "^3.2.4",
"react-native-web": "~0.21.0",
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { orderDroppedItems } from "./draggable-list-drop-order";
interface Row {
key: string;
}
const rows = (...keys: string[]): Row[] => keys.map((key) => ({ key }));
const keyExtractor = (row: Row) => row.key;
describe("orderDroppedItems", () => {
it("returns the rows in the order the list shows at the drop", () => {
const result = orderDroppedItems({
data: rows("a", "b", "c"),
keyExtractor,
indexToKey: ["c", "a", "b"],
});
expect(result.map(keyExtractor)).toEqual(["c", "a", "b"]);
});
it("leaves out a row that disappeared while it was dropped", () => {
const result = orderDroppedItems({
data: rows("a", "c"),
keyExtractor,
indexToKey: ["c", "b", "a"],
});
expect(result).toEqual(rows("c", "a"));
});
it("leaves out a row that arrived while it was dropped, so its stored place is kept", () => {
const result = orderDroppedItems({
data: rows("a", "b", "new"),
keyExtractor,
indexToKey: ["b", "a"],
});
expect(result).toEqual(rows("b", "a"));
});
});
@@ -0,0 +1,24 @@
/**
* The rows in the order a native list shows when a drag ends.
*
* Sortables' own `data` for `onDragEnd` indexes rows through `keyToIndex`, a derived value that can
* trail `indexToKey` by a frame. A drop right after a swap would then save the order from before the
* swap, and a row added or removed during the drop leaves a hole. `indexToKey` is the order on
* screen. Keys without a row are skipped, and rows without a key are left out, so callers keep
* their stored place.
*/
export function orderDroppedItems<T>(input: {
data: readonly T[];
keyExtractor: (item: T) => string;
indexToKey: readonly string[];
}): T[] {
const itemsByKey = new Map(input.data.map((item) => [input.keyExtractor(item), item]));
const ordered: T[] = [];
for (const key of input.indexToKey) {
const item = itemsByKey.get(key);
if (item !== undefined) {
ordered.push(item);
}
}
return ordered;
}
@@ -1,133 +1,169 @@
import { RefreshControl } from "react-native";
import { useCallback, useMemo, useState } from "react";
import DraggableFlatList, {
NestableDraggableFlatList,
type RenderItemParams,
} from "react-native-draggable-flatlist";
import { useUnistyles } from "react-native-unistyles";
import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types";
import { createContext, useCallback, useContext, useState, type ReactElement } from "react";
import { View } from "react-native";
import { ScrollView as GestureScrollView } from "react-native-gesture-handler";
import {
createAnimatedComponent,
useAnimatedReaction,
useAnimatedRef,
type AnimatedRef,
} from "react-native-reanimated";
import Sortable, {
useItemContext,
type SortableGridDragEndParams,
type SortableGridRenderItem,
} from "react-native-sortables";
import { scheduleOnRN } from "react-native-worklets";
import type {
DraggableListDragHandleProps,
DraggableListHandleProps,
DraggableListProps,
DraggableListScrollViewProps,
DraggableRenderItemInfo,
} from "./draggable-list.types";
import { orderDroppedItems } from "./draggable-list-drop-order";
export type { DraggableListProps, DraggableRenderItemInfo };
const SCROLL_ENABLED_FLEX_STYLE = { flex: 1 };
// Sortables places every row with UI-thread offsets, so the reorder commit never moves a row by
// itself. Lists that position rows by React child order plus a held drag translate paint one
// frame of new order + old translate when the reorder commits.
//
// Render lists inside `DraggableListScrollView` so a dragged row autoscrolls it.
//
// A drag interrupted by the system (app backgrounded, incoming call) commits the order it
// previewed: sortables ends a cancelled drag through the same `onDragEnd` as a release.
// A row lifts after a still hold. Moving first fails the drag, so the touch scrolls the list or
// swipes the panel instead.
const DRAG_ACTIVATION_DELAY_MS = 180;
const DRAG_ACTIVATION_FAIL_OFFSET_PX = 4;
// Native handles carry no props; their presence marks the row as the handle.
const NATIVE_DRAG_HANDLE_PROPS: DraggableListDragHandleProps = {};
const AnimatedGestureScrollView = createAnimatedComponent(GestureScrollView);
const ScrollViewRefContext = createContext<AnimatedRef | null>(null);
// True inside a row of another DraggableList, e.g. the workspaces inside a project block.
const NestedListContext = createContext(false);
export function DraggableListScrollView({
children,
simultaneousGestureRef,
...scrollViewProps
}: DraggableListScrollViewProps) {
const scrollViewRef = useAnimatedRef();
return (
<AnimatedGestureScrollView
{...scrollViewProps}
ref={scrollViewRef}
simultaneousHandlers={simultaneousGestureRef}
>
<ScrollViewRefContext.Provider value={scrollViewRef}>
{children}
</ScrollViewRefContext.Provider>
</AnimatedGestureScrollView>
);
}
export function DraggableListHandle({ children, dragHandleProps }: DraggableListHandleProps) {
if (!dragHandleProps) {
return children;
}
return <Sortable.Handle>{children}</Sortable.Handle>;
}
export function DraggableList<T>({
data,
keyExtractor,
renderItem,
onDragEnd,
style,
containerStyle,
contentContainerStyle,
testID,
ListFooterComponent,
ListHeaderComponent,
ListEmptyComponent,
showsVerticalScrollIndicator = true,
scrollEnabled = true,
useDragHandle: _useDragHandle = false,
refreshing,
onRefresh,
extraData,
simultaneousGestureRef,
gestureHostPresented,
waitFor,
onDragBegin: onDragBeginProp,
nestable = false,
useDragHandle = false,
}: DraggableListProps<T>) {
const { theme } = useUnistyles();
const [isDragging, setIsDragging] = useState(false);
const scrollViewRef = useContext(ScrollViewRefContext);
const nested = useContext(NestedListContext);
const dragHandleProps = useDragHandle ? NATIVE_DRAG_HANDLE_PROPS : undefined;
// Pass the ref directly to DraggableFlatList - it handles gesture
// coordination internally for nestable lists.
const simultaneousHandlers = useMemo(
() => (simultaneousGestureRef ? [simultaneousGestureRef] : undefined),
[simultaneousGestureRef],
);
const refreshColors = useMemo(
() => [theme.colors.foregroundMuted],
[theme.colors.foregroundMuted],
);
const handleRenderItem = useCallback(
({ item, drag, isActive, getIndex }: RenderItemParams<T>) => {
const index = getIndex() ?? 0;
const info: DraggableRenderItemInfo<T> = {
item,
index,
drag,
isActive,
};
return renderItem(info);
},
[renderItem],
const renderGridItem = useCallback<SortableGridRenderItem<T>>(
({ item, index }) => (
<DraggableListItem
item={item}
index={index}
renderItem={renderItem}
dragHandleProps={dragHandleProps}
/>
),
[dragHandleProps, renderItem],
);
const handleDragEnd = useCallback(
({ data: newData }: { data: T[] }) => {
setIsDragging(false);
onDragEnd(newData);
({ indexToKey }: SortableGridDragEndParams<T>) => {
onDragEnd(orderDroppedItems({ data, keyExtractor, indexToKey }));
},
[onDragEnd],
);
const handleDragBegin = useCallback(() => {
setIsDragging(true);
onDragBeginProp?.();
}, [onDragBeginProp]);
const handleRelease = useCallback(() => {
setIsDragging(false);
}, []);
const showRefreshControl = Boolean(onRefresh) && (!isDragging || Boolean(refreshing));
const resolvedContainerStyle =
containerStyle ?? (scrollEnabled ? SCROLL_ENABLED_FLEX_STYLE : undefined);
const shouldShowRefreshControl = showRefreshControl && !nestable;
const ListComponent: typeof DraggableFlatList = (
nestable ? (NestableDraggableFlatList as unknown) : DraggableFlatList
) as typeof DraggableFlatList;
const refreshControl = useMemo(
() =>
shouldShowRefreshControl ? (
<RefreshControl
refreshing={refreshing ?? false}
onRefresh={onRefresh}
tintColor={theme.colors.foregroundMuted}
colors={refreshColors}
/>
) : undefined,
[shouldShowRefreshControl, refreshing, onRefresh, theme.colors.foregroundMuted, refreshColors],
[data, keyExtractor, onDragEnd],
);
return (
<ListComponent
testID={testID}
data={data}
keyExtractor={keyExtractor}
renderItem={handleRenderItem}
onDragEnd={handleDragEnd}
style={style}
containerStyle={resolvedContainerStyle}
contentContainerStyle={contentContainerStyle}
ListFooterComponent={ListFooterComponent}
ListHeaderComponent={ListHeaderComponent}
ListEmptyComponent={ListEmptyComponent}
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
scrollEnabled={scrollEnabled}
extraData={extraData}
simultaneousHandlers={simultaneousHandlers}
dragGestureHostPresented={gestureHostPresented}
// 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
waitFor={waitFor}
refreshControl={refreshControl}
/>
<View testID={testID} style={containerStyle}>
<Sortable.Grid
columns={1}
data={data}
keyExtractor={keyExtractor}
renderItem={renderGridItem}
onDragEnd={handleDragEnd}
customHandle={useDragHandle}
dragActivationDelay={DRAG_ACTIVATION_DELAY_MS}
dragActivationFailOffset={DRAG_ACTIVATION_FAIL_OFFSET_PX}
scrollableRef={scrollViewRef ?? undefined}
autoScrollEnabled={scrollViewRef !== null}
// Clamped to the single column's width, so a dragged row moves only vertically. A nested
// list also stays inside its row: its lifted row would draw under the rows that follow.
overDrag={nested ? "none" : "vertical"}
// The lifted row keeps the point where the finger grabbed it.
enableActiveItemSnap={false}
// Rows animate only while a drag reorders them. Otherwise a project expanding or
// collapsing would slide the rows below over its rows, and added rows would fade in.
itemsLayoutTransitionMode="reorder"
itemEntering={null}
itemExiting={null}
// Rows style themselves from `isActive`; the other rows stay as they are.
activeItemScale={1}
activeItemShadowOpacity={0}
inactiveItemOpacity={1}
inactiveItemScale={1}
hapticsEnabled
/>
</View>
);
}
function DraggableListItem<T>({
item,
index,
renderItem,
dragHandleProps,
}: {
item: T;
index: number;
renderItem: (info: DraggableRenderItemInfo<T>) => ReactElement;
dragHandleProps: DraggableListDragHandleProps | undefined;
}) {
const { isActive } = useItemContext();
const [active, setActive] = useState(false);
useAnimatedReaction(
() => isActive.value,
(next, previous) => {
if (previous !== null && next !== previous) {
scheduleOnRN(setActive, next);
}
},
);
return (
<NestedListContext.Provider value={true}>
{renderItem({ item, index, isActive: active, dragHandleProps })}
</NestedListContext.Provider>
);
}
@@ -1,12 +1,13 @@
import type { ReactElement, MutableRefObject } from "react";
import type { MutableRefObject, PropsWithChildren, ReactElement } from "react";
import type { StyleProp, ViewStyle } from "react-native";
import type { GestureType } from "react-native-gesture-handler";
/**
* Marks the row a drag starts from when the list uses a drag handle. On web, spread
* `attributes` and `listeners` and assign `setActivatorNodeRef` on the handle element. On
* every platform, wrap the handle in `DraggableListHandle` with these props.
*/
export interface DraggableListDragHandleProps {
/**
* Web-only drag handle props (from dnd-kit). Spread these onto the element
* that should initiate the drag. Native uses the `drag()` callback instead.
*/
attributes?: Record<string, unknown>;
listeners?: Record<string, unknown>;
setActivatorNodeRef?: (node: unknown) => void;
@@ -15,53 +16,34 @@ export interface DraggableListDragHandleProps {
export interface DraggableRenderItemInfo<T> {
item: T;
index: number;
drag: () => void;
isActive: boolean;
dragHandleProps?: DraggableListDragHandleProps;
}
export interface DraggableListProps<T> {
data: T[];
keyExtractor: (item: T, index: number) => string;
keyExtractor: (item: T) => string;
renderItem: (info: DraggableRenderItemInfo<T>) => ReactElement;
onDragEnd: (data: T[]) => void;
style?: StyleProp<ViewStyle>;
/** Outer container style (useful for nested, non-scrolling lists). */
containerStyle?: StyleProp<ViewStyle>;
contentContainerStyle?: StyleProp<ViewStyle>;
testID?: string;
ListFooterComponent?: ReactElement | null;
ListHeaderComponent?: ReactElement | null;
ListEmptyComponent?: ReactElement | null;
showsVerticalScrollIndicator?: boolean;
/** When false, disables internal scrolling (use outer list to scroll). */
scrollEnabled?: boolean;
/**
* Web-only: when true, the drag can only be initiated from the handle props
* passed to `renderItem` (prevents nested lists from fighting).
* When true, a drag starts only from the `DraggableListHandle` that receives the row's
* `dragHandleProps`, so a list nested inside a row keeps its own drags.
*/
useDragHandle?: boolean;
refreshing?: boolean;
onRefresh?: () => void;
/** Fill remaining space when content is smaller than container */
contentContainerFlexGrow?: boolean;
/** External row state that should invalidate virtualized native cells. */
extraData?: unknown;
/** Gesture ref for simultaneous handling with parent gestures (e.g., sidebar close) */
simultaneousGestureRef?: MutableRefObject<GestureType | undefined>;
/** Whether the retained native gesture host is currently presented. */
gestureHostPresented?: boolean;
/** Gesture ref(s) that the list should wait for before handling scroll */
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.
*/
nestable?: boolean;
}
export type DraggableListHandleProps = PropsWithChildren<{
dragHandleProps: DraggableListDragHandleProps | undefined;
}>;
/** The vertical scroll view that hosts draggable lists and scrolls while a row is dragged. */
export type DraggableListScrollViewProps = PropsWithChildren<{
style?: StyleProp<ViewStyle>;
contentContainerStyle?: StyleProp<ViewStyle>;
showsVerticalScrollIndicator?: boolean;
testID?: string;
/** Native-only: a gesture that keeps recognizing while the list scrolls, e.g. a panel close. */
simultaneousGestureRef?: MutableRefObject<GestureType | undefined>;
}>;
@@ -1,4 +1,4 @@
import { memo, useCallback, useMemo, useRef, type ReactElement } from "react";
import { memo, useMemo, useRef, type ReactElement } from "react";
import { ScrollView, View } from "react-native";
import {
DndContext,
@@ -17,7 +17,12 @@ import {
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types";
import type {
DraggableListHandleProps,
DraggableListProps,
DraggableListScrollViewProps,
DraggableRenderItemInfo,
} from "./draggable-list.types";
import { getDragActivationConstraints, useDragReorderState } from "./drag-reorder";
export type { DraggableListProps, DraggableRenderItemInfo };
@@ -116,18 +121,6 @@ function SortableItemInner<T>({
isDragging,
} = useSortable({ id });
const dragRef = useRef<(() => void) | null>(null);
const drag = useCallback(() => {
// dnd-kit handles drag initiation via listeners
// This is a no-op but matches the mobile API
}, []);
// Store listeners in ref so drag handle can access them
dragRef.current = () => {
// Trigger drag - handled by dnd-kit's listeners
};
// dnd-kit can set `scaleX/scaleY` on the active item when dragging over a
// differently-sized droppable. For variable-height rows this can look like
// the "ghost" stretches. Keep the dragged item's size stable by zeroing
@@ -166,7 +159,6 @@ function SortableItemInner<T>({
const info: DraggableRenderItemInfo<T> = {
item,
index,
drag,
isActive: activeId === id,
dragHandleProps,
};
@@ -184,31 +176,44 @@ function SortableItemInner<T>({
const SortableItem = memo(SortableItemInner) as typeof SortableItemInner;
export function DraggableListScrollView({
children,
style,
contentContainerStyle,
showsVerticalScrollIndicator,
testID,
}: DraggableListScrollViewProps) {
return (
<ScrollView
style={style}
contentContainerStyle={contentContainerStyle}
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
testID={testID}
>
{children}
</ScrollView>
);
}
// The handle element itself spreads `dragHandleProps` on web.
export function DraggableListHandle({ children }: DraggableListHandleProps) {
return children;
}
export function DraggableList<T>({
data,
keyExtractor,
renderItem,
onDragEnd,
style,
containerStyle,
contentContainerStyle,
testID,
ListFooterComponent,
ListHeaderComponent,
ListEmptyComponent,
showsVerticalScrollIndicator = true,
scrollEnabled = true,
extraData: _extraData,
// Native-only, as before.
testID: _testID,
useDragHandle = false,
// simultaneousGestureRef is native-only, ignored on web
onDragBegin,
nestable: _nestable = false,
}: DraggableListProps<T>) {
const { activeId, items, handlers } = useDragReorderState({
data,
keyExtractor,
onDragEnd,
onDragBegin,
});
const activationConstraints = getDragActivationConstraints(useDragHandle, DRAG_ACTIVATION_CONFIG);
@@ -224,89 +229,39 @@ export function DraggableList<T>({
}),
);
const ids = useMemo(
() => items.map((item, index) => keyExtractor(item, index)),
[items, keyExtractor],
);
const ids = useMemo(() => items.map((item) => keyExtractor(item)), [items, keyExtractor]);
const wrapperStyle = useMemo(
() => [
{ position: "relative" as const },
scrollEnabled ? { flex: 1, minHeight: 0 } : null,
containerStyle,
],
[scrollEnabled, containerStyle],
() => [{ position: "relative" as const }, containerStyle],
[containerStyle],
);
return (
<View style={wrapperStyle}>
{scrollEnabled ? (
<ScrollView
testID={testID}
style={style}
contentContainerStyle={contentContainerStyle}
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
>
{ListHeaderComponent}
{items.length === 0 && ListEmptyComponent}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={DND_MODIFIERS}
onDragStart={handlers.onDragStart}
onDragCancel={handlers.onDragCancel}
onDragEnd={handlers.onDragEnd}
>
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
{items.map((item, index) => {
const id = keyExtractor(item, index);
return (
<SortableItem
key={id}
id={id}
item={item}
index={index}
renderItem={renderItem}
activeId={activeId}
useDragHandle={useDragHandle}
/>
);
})}
</SortableContext>
</DndContext>
{ListFooterComponent}
</ScrollView>
) : (
<>
{ListHeaderComponent}
{items.length === 0 && ListEmptyComponent}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={DND_MODIFIERS}
onDragStart={handlers.onDragStart}
onDragCancel={handlers.onDragCancel}
onDragEnd={handlers.onDragEnd}
>
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
{items.map((item, index) => {
const id = keyExtractor(item, index);
return (
<SortableItem
key={id}
id={id}
item={item}
index={index}
renderItem={renderItem}
activeId={activeId}
useDragHandle={useDragHandle}
/>
);
})}
</SortableContext>
</DndContext>
{ListFooterComponent}
</>
)}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={DND_MODIFIERS}
onDragStart={handlers.onDragStart}
onDragCancel={handlers.onDragCancel}
onDragEnd={handlers.onDragEnd}
>
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
{items.map((item, index) => {
const id = keyExtractor(item);
return (
<SortableItem
key={id}
id={id}
item={item}
index={index}
renderItem={renderItem}
activeId={activeId}
useDragHandle={useDragHandle}
/>
);
})}
</SortableContext>
</DndContext>
</View>
);
}
@@ -669,7 +669,6 @@ function MobileSidebar({
onAddProject={handleOpenProject}
onImportSession={handleImportSession}
parentGestureRef={closeGestureRef}
dragGestureHostActive={active}
listHeaderComponent={workspacesSectionHeaderElement}
/>
)}
@@ -871,7 +870,6 @@ function DesktopSidebar({
onImportSession={handleImportSession}
onWorkspacePress={closeSidebar}
parentGestureRef={closeGestureRef}
dragGestureHostActive={active}
listHeaderComponent={workspacesSectionHeaderElement}
/>
)}
@@ -3,7 +3,6 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner";
import {
View,
Text,
ScrollView,
type GestureResponderEvent,
type PressableStateCallbackType,
type StyleProp,
@@ -39,8 +38,12 @@ import { type GestureType } from "react-native-gesture-handler";
import { useSidebarWorkspaceRename } from "@/workspace-rename";
import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions";
import { ExternalLink, Settings, MoreVertical, Plus, Trash2 } from "lucide-react-native";
import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
import {
DraggableList,
DraggableListHandle,
DraggableListScrollView,
type DraggableRenderItemInfo,
} from "./draggable-list";
import type { DraggableListDragHandleProps } from "./draggable-list.types";
import {
getHostRuntimeStore,
@@ -76,7 +79,6 @@ import {
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
useContextMenu,
} from "@/components/ui/context-menu";
import {
DropdownMenu,
@@ -97,7 +99,7 @@ import {
SidebarWorkspaceContextMenu,
SidebarWorkspaceMenu,
} from "@/components/sidebar/sidebar-workspace-menu";
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
import { useRowPressGuard } from "@/components/sidebar/use-row-press-guard";
import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header";
import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row";
import { createWorkspaceVisibilityPolicy } from "@/components/sidebar/sidebar-group-visibility";
@@ -240,7 +242,6 @@ interface SidebarWorkspaceListProps {
listHeaderComponent?: ReactElement | null;
/** Gesture ref for coordinating with parent gestures (e.g., sidebar close) */
parentGestureRef?: MutableRefObject<GestureType | undefined>;
dragGestureHostActive?: boolean;
}
interface ProjectHeaderRowProps {
@@ -257,10 +258,8 @@ interface ProjectHeaderRowProps {
onWorktreeCreated?: (workspaceId: string) => void;
shortcutNumber?: number | null;
showShortcutBadge?: boolean;
drag: () => void;
isDragging: boolean;
isArchiving?: boolean;
menuController: ReturnType<typeof useContextMenu> | null;
onRemoveProject?: () => void;
removeProjectStatus?: "idle" | "pending";
dragHandleProps?: DraggableListDragHandleProps;
@@ -275,12 +274,10 @@ interface WorkspaceRowInnerProps {
shortcutNumber: number | null;
showShortcutBadge: boolean;
onPress: () => void;
drag?: () => void;
isDragging: boolean;
isArchiving: boolean;
isCreating?: boolean;
dragHandleProps?: DraggableListDragHandleProps;
menuController: ReturnType<typeof useContextMenu> | null;
archiveLabel?: string;
archiveStatus?: "idle" | "pending" | "success";
archivePendingLabel?: string;
@@ -874,10 +871,8 @@ function ProjectHeaderRow({
onWorktreeCreated: _onWorktreeCreated,
shortcutNumber = null,
showShortcutBadge = false,
drag,
isDragging,
isArchiving = false,
menuController,
onRemoveProject,
removeProjectStatus = "idle",
dragHandleProps,
@@ -903,24 +898,14 @@ function ProjectHeaderRow({
}) as Href,
);
}, [displayName, onWorkspacePress, worktreeTarget]);
const interaction = useLongPressDragInteraction({
drag,
menuController,
});
const {
role: _dragRole,
tabIndex: _dragTabIndex,
"aria-roledescription": _dragRoleDescription,
...dragAttributes
} = dragHandleProps?.attributes ?? {};
const interaction = useRowPressGuard();
const handlePress = useCallback(() => {
if (interaction.didLongPressRef.current) {
interaction.didLongPressRef.current = false;
if (interaction.consumeMovedPress()) {
return;
}
onPress();
}, [interaction.didLongPressRef, onPress]);
}, [interaction, onPress]);
const handlePointerEnter = useCallback(() => {
if (!contextMenuOpen) setIsHovered(true);
@@ -996,10 +981,8 @@ function ProjectHeaderRow({
if (!onRemoveProject) {
return (
<View
{...dragAttributes}
{...dragHandleProps?.listeners}
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
<ProjectHeaderDragHandle
dragHandleProps={dragHandleProps}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
>
@@ -1015,16 +998,14 @@ function ProjectHeaderRow({
>
{rowChildren}
</PressHighlight>
</View>
</ProjectHeaderDragHandle>
);
}
return (
<ContextMenu open={contextMenuOpen} onOpenChange={handleContextMenuOpenChange}>
<View
{...dragAttributes}
{...dragHandleProps?.listeners}
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
<ProjectHeaderDragHandle
dragHandleProps={dragHandleProps}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
>
@@ -1041,7 +1022,7 @@ function ProjectHeaderRow({
>
{rowChildren}
</ContextMenuTrigger>
</View>
</ProjectHeaderDragHandle>
<ContextMenuContent
align="start"
width={220}
@@ -1060,6 +1041,38 @@ function ProjectHeaderRow({
);
}
function ProjectHeaderDragHandle({
children,
dragHandleProps,
onPointerEnter,
onPointerLeave,
}: {
children: ReactNode;
dragHandleProps: DraggableListDragHandleProps | undefined;
onPointerEnter: () => void;
onPointerLeave: () => void;
}) {
const {
role: _dragRole,
tabIndex: _dragTabIndex,
"aria-roledescription": _dragRoleDescription,
...dragAttributes
} = dragHandleProps?.attributes ?? {};
return (
<DraggableListHandle dragHandleProps={dragHandleProps}>
<View
{...dragAttributes}
{...dragHandleProps?.listeners}
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
onPointerEnter={onPointerEnter}
onPointerLeave={onPointerLeave}
>
{children}
</View>
</DraggableListHandle>
);
}
function WorkspaceRowInner({
workspace,
hostBadge,
@@ -1069,12 +1082,10 @@ function WorkspaceRowInner({
shortcutNumber,
showShortcutBadge,
onPress,
drag,
isDragging,
isArchiving,
isCreating = false,
dragHandleProps,
menuController,
archiveLabel,
archiveStatus = "idle",
archivePendingLabel,
@@ -1093,10 +1104,7 @@ function WorkspaceRowInner({
const isCompact = useIsCompactFormFactor();
const [isPressed, setIsPressed] = useState(false);
const isTouchPlatform = platformIsNative || isCompact;
const interaction = useLongPressDragInteraction({
drag,
menuController,
});
const interaction = useRowPressGuard();
const {
role: _dragRole,
tabIndex: _dragTabIndex,
@@ -1105,12 +1113,11 @@ function WorkspaceRowInner({
} = dragHandleProps?.attributes ?? {};
const handlePress = useCallback(() => {
if (interaction.didLongPressRef.current) {
interaction.didLongPressRef.current = false;
if (interaction.consumeMovedPress()) {
return;
}
onPress();
}, [interaction.didLongPressRef, onPress]);
}, [interaction, onPress]);
const handleWorkspacePressIn = useCallback(
(event: GestureResponderEvent) => {
setIsPressed(true);
@@ -1143,85 +1150,87 @@ function WorkspaceRowInner({
});
const backdrop = getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered });
return (
<View
{...dragAttributes}
{...(isEditingTitle ? null : dragHandleProps?.listeners)}
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
style={styles.workspaceRowContainer}
{...hoverHandlers}
>
<SidebarWorkspaceContextMenu
contextMenuOpen={contextMenuOpen}
onContextMenuOpenChange={onContextMenuOpenChange}
workspace={workspace}
leadingProjectName={leadingProjectName}
hostBadgeLabel={hostBadge?.label}
workspaceKey={workspace.workspaceKey}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
onRename={onRename}
onMarkAsRead={onMarkAsRead}
onMarkAsUnread={onMarkAsUnread}
onArchive={onArchive}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
archiveShortcutKeys={archiveShortcutKeys}
isPinned={isPinned}
onTogglePin={onTogglePin}
openInFileManagerPath={workspace.workspaceDirectory}
disabled={isArchiving}
enabled={!isEditingTitle}
aria-selected={selected}
accessibilityRole="button"
accessibilityState={accessibilityState}
style={workspaceRowStyle}
highlightStyle={styles.workspaceRowPressed}
onPressIn={isEditingTitle ? undefined : handleWorkspacePressIn}
onTouchMove={isEditingTitle ? undefined : interaction.handleTouchMove}
onPressOut={isEditingTitle ? undefined : handleWorkspacePressOut}
onPress={isEditingTitle ? undefined : handlePress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
<DraggableListHandle dragHandleProps={isEditingTitle ? undefined : dragHandleProps}>
<View
{...dragAttributes}
{...(isEditingTitle ? null : dragHandleProps?.listeners)}
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
style={styles.workspaceRowContainer}
{...hoverHandlers}
>
<SidebarWorkspaceRowContent
<SidebarWorkspaceContextMenu
contextMenuOpen={contextMenuOpen}
onContextMenuOpenChange={onContextMenuOpenChange}
workspace={workspace}
hostBadge={hostBadge}
leadingProjectName={leadingProjectName}
leadingProjectIconDataUri={leadingProjectIconDataUri}
serviceSummary={serviceSummary}
backdrop={backdrop}
isHovered={isHovered}
isLoading={isArchiving || isCreating}
isCreating={isCreating}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
titleEditor={titleEditor}
hostBadgeLabel={hostBadge?.label}
workspaceKey={workspace.workspaceKey}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
onRename={onRename}
onMarkAsRead={onMarkAsRead}
onMarkAsUnread={onMarkAsUnread}
onArchive={onArchive}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
archiveShortcutKeys={archiveShortcutKeys}
isPinned={isPinned}
onTogglePin={onTogglePin}
openInFileManagerPath={workspace.workspaceDirectory}
disabled={isArchiving}
enabled={!isEditingTitle}
aria-selected={selected}
accessibilityRole="button"
accessibilityState={accessibilityState}
style={workspaceRowStyle}
highlightStyle={styles.workspaceRowPressed}
onPressIn={isEditingTitle ? undefined : handleWorkspacePressIn}
onTouchMove={isEditingTitle ? undefined : interaction.handleTouchMove}
onPressOut={isEditingTitle ? undefined : handleWorkspacePressOut}
onPress={isEditingTitle ? undefined : handlePress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
<WorkspaceRowRightGroup
<SidebarWorkspaceRowContent
workspace={workspace}
hostBadge={hostBadge}
leadingProjectName={leadingProjectName}
leadingProjectIconDataUri={leadingProjectIconDataUri}
serviceSummary={serviceSummary}
backdrop={backdrop}
isHovered={isHovered}
isTouchPlatform={isTouchPlatform}
isLoading={isArchiving || isCreating}
isCreating={isCreating}
showShortcutBadge={showShortcutBadge}
shortcutNumber={shortcutNumber}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
archiveShortcutKeys={archiveShortcutKeys}
onArchive={onArchive}
onCopyBranchName={onCopyBranchName}
onCopyPath={onCopyPath}
onRename={onRename}
onMarkAsRead={onMarkAsRead}
onMarkAsUnread={onMarkAsUnread}
isPinned={isPinned}
onTogglePin={onTogglePin}
/>
</SidebarWorkspaceRowContent>
</SidebarWorkspaceContextMenu>
</View>
showShortcutBadge={showShortcutBadge}
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
titleEditor={titleEditor}
>
<WorkspaceRowRightGroup
workspace={workspace}
backdrop={backdrop}
isHovered={isHovered}
isTouchPlatform={isTouchPlatform}
isCreating={isCreating}
showShortcutBadge={showShortcutBadge}
shortcutNumber={shortcutNumber}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
archiveShortcutKeys={archiveShortcutKeys}
onArchive={onArchive}
onCopyBranchName={onCopyBranchName}
onCopyPath={onCopyPath}
onRename={onRename}
onMarkAsRead={onMarkAsRead}
onMarkAsUnread={onMarkAsUnread}
isPinned={isPinned}
onTogglePin={onTogglePin}
/>
</SidebarWorkspaceRowContent>
</SidebarWorkspaceContextMenu>
</View>
</DraggableListHandle>
);
}}
</SidebarWorkspaceRowFrame>
@@ -1237,7 +1246,6 @@ function WorkspaceRowWithMenu({
shortcutNumber,
showShortcutBadge,
onPress,
drag,
isDragging,
dragHandleProps,
canCopyBranchName,
@@ -1254,7 +1262,6 @@ function WorkspaceRowWithMenu({
shortcutNumber: number | null;
showShortcutBadge: boolean;
onPress: () => void;
drag?: () => void;
isDragging: boolean;
dragHandleProps?: DraggableListDragHandleProps;
canCopyBranchName: boolean;
@@ -1347,12 +1354,10 @@ function WorkspaceRowWithMenu({
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
onPress={onPress}
drag={drag}
isDragging={isDragging}
isArchiving={isArchiving}
isCreating={isCreating}
dragHandleProps={dragHandleProps}
menuController={null}
archiveLabel={t("sidebar.workspace.actions.archive")}
archiveStatus={isArchiving ? "pending" : "idle"}
archivePendingLabel={t("sidebar.workspace.actions.archiving")}
@@ -1389,7 +1394,6 @@ interface WorkspaceRowItemProps {
selectionEnabled: boolean;
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
onWorkspacePress?: () => void;
drag?: () => void;
isDragging?: boolean;
dragHandleProps?: DraggableListDragHandleProps;
}
@@ -1409,7 +1413,6 @@ function WorkspaceRowItem({
isCreating = false,
selectionEnabled,
activeWorkspaceSelection,
drag,
isDragging = false,
dragHandleProps,
}: WorkspaceRowItemProps) {
@@ -1440,7 +1443,6 @@ function WorkspaceRowItem({
enabled: selectionEnabled,
})}
onPress={handlePress}
drag={drag}
isDragging={isDragging}
dragHandleProps={dragHandleProps}
/>
@@ -1477,7 +1479,6 @@ function areWorkspaceRowItemPropsEqual(
previous.reserveIdleStatusIndicatorSpace === next.reserveIdleStatusIndicatorSpace &&
previous.isCreating === next.isCreating &&
previous.onWorkspacePress === next.onWorkspacePress &&
previous.drag === next.drag &&
previous.isDragging === next.isDragging &&
previous.dragHandleProps === next.dragHandleProps &&
previousSelected === nextSelected
@@ -1494,7 +1495,6 @@ function WorkspaceRow({
shortcutNumber,
showShortcutBadge,
onPress,
drag,
isDragging,
dragHandleProps,
canCopyBranchName,
@@ -1511,7 +1511,6 @@ function WorkspaceRow({
shortcutNumber: number | null;
showShortcutBadge: boolean;
onPress: () => void;
drag?: () => void;
isDragging: boolean;
dragHandleProps?: DraggableListDragHandleProps;
canCopyBranchName: boolean;
@@ -1535,7 +1534,6 @@ function WorkspaceRow({
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
onPress={onPress}
drag={drag}
isDragging={isDragging}
dragHandleProps={dragHandleProps}
canCopyBranchName={canCopyBranchName}
@@ -1558,13 +1556,9 @@ function ProjectBlock({
shortcutIndexByWorkspaceKey,
onToggleCollapsed,
onWorkspacePress,
parentGestureRef,
onWorkspaceReorder,
useNestable,
dragGestureHostActive,
manualSort,
onWorktreeCreated,
drag,
isDragging,
dragHandleProps,
creatingWorkspaceIds,
@@ -1584,13 +1578,9 @@ function ProjectBlock({
shortcutIndexByWorkspaceKey: Map<string, number>;
onToggleCollapsed: (projectViewKey: string) => void;
onWorkspacePress?: () => void;
parentGestureRef?: MutableRefObject<GestureType | undefined>;
onWorkspaceReorder: (projectViewKey: string, workspaces: SidebarWorkspacePlacement[]) => void;
useNestable: boolean;
dragGestureHostActive?: boolean;
manualSort: boolean;
onWorktreeCreated?: (workspaceId: string) => void;
drag: () => void;
isDragging: boolean;
dragHandleProps?: DraggableListDragHandleProps;
creatingWorkspaceIds: ReadonlySet<string>;
@@ -1646,7 +1636,6 @@ function ProjectBlock({
(
item: SidebarWorkspacePlacement,
input?: {
drag?: () => void;
isDragging?: boolean;
dragHandleProps?: DraggableListDragHandleProps;
},
@@ -1666,7 +1655,6 @@ function ProjectBlock({
selectionEnabled={selectionEnabled}
activeWorkspaceSelection={activeWorkspaceSelection}
onWorkspacePress={onWorkspacePress}
drag={input?.drag}
isDragging={input?.isDragging}
dragHandleProps={input?.dragHandleProps}
/>
@@ -1690,12 +1678,10 @@ function ProjectBlock({
const renderWorkspace = useCallback(
({
item,
drag: workspaceDrag,
isActive,
dragHandleProps: workspaceDragHandleProps,
}: DraggableRenderItemInfo<SidebarWorkspacePlacement>) => {
return renderWorkspaceRow(item, {
drag: workspaceDrag,
isDragging: isActive,
dragHandleProps: workspaceDragHandleProps,
});
@@ -1785,12 +1771,7 @@ function ProjectBlock({
keyExtractor={workspaceKeyExtractor}
renderItem={renderWorkspace}
onDragEnd={handleWorkspaceDragEnd}
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
scrollEnabled={false}
useDragHandle
nestable={useNestable}
simultaneousGestureRef={parentGestureRef}
gestureHostPresented={dragGestureHostActive}
containerStyle={styles.workspaceListContainer}
/>
</>
@@ -1844,10 +1825,8 @@ function ProjectBlock({
isProjectActive={active}
onWorkspacePress={onWorkspacePress}
onWorktreeCreated={onWorktreeCreated}
drag={drag}
isDragging={isDragging}
isArchiving={isRemovingProject}
menuController={null}
onRemoveProject={handleRemoveProject}
removeProjectStatus={isRemovingProject ? "pending" : "idle"}
dragHandleProps={dragHandleProps}
@@ -1877,13 +1856,9 @@ function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlo
previous.onToggleWorkspacePin === next.onToggleWorkspacePin &&
previous.onToggleCollapsed === next.onToggleCollapsed &&
previous.onWorkspacePress === next.onWorkspacePress &&
previous.parentGestureRef === next.parentGestureRef &&
previous.onWorkspaceReorder === next.onWorkspaceReorder &&
previous.useNestable === next.useNestable &&
previous.dragGestureHostActive === next.dragGestureHostActive &&
previous.manualSort === next.manualSort &&
previous.onWorktreeCreated === next.onWorktreeCreated &&
previous.drag === next.drag &&
previous.isDragging === next.isDragging &&
previous.dragHandleProps === next.dragHandleProps &&
previous.creatingWorkspaceIds === next.creatingWorkspaceIds &&
@@ -1965,7 +1940,6 @@ export function SidebarWorkspaceList({
listFooterComponent,
listHeaderComponent,
parentGestureRef,
dragGestureHostActive,
}: SidebarWorkspaceListProps) {
const pathname = usePathname();
const hosts = useHosts();
@@ -2037,8 +2011,6 @@ export function SidebarWorkspaceList({
onPinnedWorkspaceReorder={handlePinnedWorkspaceReorder}
listHeaderComponent={listHeaderComponent}
sidebarFilterEmpty={sidebarFilterEmpty}
parentGestureRef={parentGestureRef}
dragGestureHostActive={dragGestureHostActive}
/>
) : (
<ProjectModeList
@@ -2057,7 +2029,6 @@ export function SidebarWorkspaceList({
sidebarFilterEmpty={sidebarFilterEmpty}
hasActiveProjectFilter={hasActiveProjectFilter}
parentGestureRef={parentGestureRef}
dragGestureHostActive={dragGestureHostActive}
pathname={pathname}
hostBadgeByServerId={hostBadgeByServerId}
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
@@ -2089,8 +2060,6 @@ function SidebarGroupedModeList({
onPinnedWorkspaceReorder,
listHeaderComponent,
sidebarFilterEmpty,
parentGestureRef,
dragGestureHostActive,
}: {
workspaceGroups: SidebarWorkspaceGroup[];
pinnedGroups: PinnedSidebarGroups;
@@ -2104,8 +2073,6 @@ function SidebarGroupedModeList({
onPinnedWorkspaceReorder: (workspaces: SidebarWorkspacePlacement[]) => void;
listHeaderComponent?: ReactElement | null;
sidebarFilterEmpty: boolean;
parentGestureRef?: MutableRefObject<GestureType | undefined>;
dragGestureHostActive?: boolean;
}) {
const showShortcutBadges = useShowShortcutBadges();
const pinnedWorkspaces = useMemo(
@@ -2131,8 +2098,6 @@ function SidebarGroupedModeList({
onPinnedWorkspaceReorder={onPinnedWorkspaceReorder}
listHeaderComponent={listHeaderComponent}
sidebarFilterEmpty={sidebarFilterEmpty}
parentGestureRef={parentGestureRef}
dragGestureHostActive={dragGestureHostActive}
/>
);
}
@@ -2153,7 +2118,6 @@ function ProjectModeList({
sidebarFilterEmpty,
hasActiveProjectFilter,
parentGestureRef,
dragGestureHostActive,
pathname,
hostBadgeByServerId,
supportsMultiplicityByServerId,
@@ -2214,19 +2178,6 @@ function ProjectModeList({
items: pinnedChats,
selection: selectionEnabled ? activeWorkspaceSelection : null,
});
const nativeScrollGestureProps = useMemo(
() =>
parentGestureRef
? ({
// NestableScrollContainer forwards props to RNGH ScrollView. Keep
// vertical scroll and sidebar close pan simultaneous: vertical
// intent scrolls immediately, clear horizontal intent can still
// activate close from inside the list.
simultaneousHandlers: parentGestureRef,
} as object)
: undefined,
[parentGestureRef],
);
useEffect(() => {
const timeouts = creatingWorkspaceTimeoutsRef.current;
@@ -2351,7 +2302,6 @@ function ProjectModeList({
(
item: SidebarProjectEntry,
dragState: {
drag: () => void;
isDragging: boolean;
dragHandleProps?: DraggableRenderItemInfo<SidebarProjectEntry>["dragHandleProps"];
},
@@ -2370,12 +2320,8 @@ function ProjectModeList({
onToggleCollapsed={onToggleProjectCollapsed}
onWorkspacePress={onWorkspacePress}
manualSort={manualSort}
parentGestureRef={parentGestureRef}
onWorkspaceReorder={handleWorkspaceReorder}
useNestable={platformIsNative}
dragGestureHostActive={dragGestureHostActive}
onWorktreeCreated={handleWorktreeCreated}
drag={dragState.drag}
isDragging={dragState.isDragging}
dragHandleProps={dragState.dragHandleProps}
creatingWorkspaceIds={creatingWorkspaceIds}
@@ -2392,8 +2338,6 @@ function ProjectModeList({
activeWorkspaceSelection,
handleWorktreeCreated,
manualSort,
parentGestureRef,
dragGestureHostActive,
handleWorkspaceReorder,
hostBadgeByServerId,
supportsMultiplicityByServerId,
@@ -2411,8 +2355,8 @@ function ProjectModeList({
);
const renderProject = useCallback(
({ item, drag, isActive, dragHandleProps }: DraggableRenderItemInfo<SidebarProjectEntry>) =>
renderProjectBlock(item, { drag, isDragging: isActive, dragHandleProps }),
({ item, isActive, dragHandleProps }: DraggableRenderItemInfo<SidebarProjectEntry>) =>
renderProjectBlock(item, { isDragging: isActive, dragHandleProps }),
[renderProjectBlock],
);
@@ -2420,7 +2364,6 @@ function ProjectModeList({
(
workspace: SidebarWorkspacePlacement,
input?: {
drag?: () => void;
isDragging?: boolean;
dragHandleProps?: DraggableListDragHandleProps;
},
@@ -2443,7 +2386,6 @@ function ProjectModeList({
selectionEnabled={selectionEnabled}
activeWorkspaceSelection={activeWorkspaceSelection}
onWorkspacePress={onWorkspacePress}
drag={input?.drag}
isDragging={input?.isDragging}
dragHandleProps={input?.dragHandleProps}
/>
@@ -2466,12 +2408,10 @@ function ProjectModeList({
const renderPinnedChat = useCallback(
({
item: workspace,
drag,
isActive,
dragHandleProps,
}: DraggableRenderItemInfo<SidebarWorkspacePlacement>) => {
return renderPinnedChatRow(workspace, {
drag,
isDragging: isActive,
dragHandleProps,
});
@@ -2489,12 +2429,7 @@ function ProjectModeList({
keyExtractor={projectViewKeyExtractor}
renderItem={renderProject}
onDragEnd={handleProjectDragEnd}
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
scrollEnabled={false}
useDragHandle
nestable={platformIsNative}
simultaneousGestureRef={parentGestureRef}
gestureHostPresented={dragGestureHostActive}
containerStyle={styles.projectListContainer}
/>
);
@@ -2518,12 +2453,7 @@ function ProjectModeList({
keyExtractor={workspaceKeyExtractor}
renderItem={renderPinnedChat}
onDragEnd={onPinnedWorkspaceReorder}
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
scrollEnabled={false}
useDragHandle
nestable={platformIsNative}
simultaneousGestureRef={parentGestureRef}
gestureHostPresented={dragGestureHostActive}
containerStyle={styles.workspaceListContainer}
/>
{canTogglePinnedChats ? (
@@ -2556,26 +2486,17 @@ function ProjectModeList({
return (
<View style={styles.container}>
{platformIsNative ? (
<NestableScrollContainer
{...nativeScrollGestureProps}
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>
)}
{/* Vertical scroll and the sidebar close pan recognize together: vertical intent
scrolls immediately, and clear horizontal intent still closes from inside the list. */}
<DraggableListScrollView
simultaneousGestureRef={parentGestureRef}
style={styles.list}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
testID="sidebar-project-workspace-list-scroll"
>
{content}
</DraggableListScrollView>
</View>
);
}
@@ -1,23 +1,12 @@
import { PressHighlight as Pressable } from "@/components/ui/press-highlight";
import {
memo,
useCallback,
useMemo,
useState,
type MutableRefObject,
type ReactNode,
type Ref,
} from "react";
import { memo, useCallback, useMemo, useState, type ReactNode, type Ref } from "react";
import { useTranslation } from "react-i18next";
import {
View,
Text,
ScrollView,
type GestureResponderEvent,
type PressableStateCallbackType,
} from "react-native";
import { NestableScrollContainer } from "react-native-draggable-flatlist";
import type { GestureType } from "react-native-gesture-handler";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { type SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
@@ -77,9 +66,14 @@ import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-heade
import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row";
import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group";
import type { ToggleSidebarWorkspacePin } from "@/hooks/use-sidebar-workspace-pin";
import { DraggableList, type DraggableRenderItemInfo } from "@/components/draggable-list";
import {
DraggableList,
DraggableListHandle,
DraggableListScrollView,
type DraggableRenderItemInfo,
} from "@/components/draggable-list";
import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
import { useRowPressGuard } from "@/components/sidebar/use-row-press-guard";
// Themed icon wrappers
const mutedGroupIconProps = densityIconProps("sm", (theme) => ({
@@ -126,8 +120,6 @@ interface StatusWorkspaceListProps {
listHeaderComponent?: ReactNode;
/** Swaps the group list for the label filter's empty state. Never the header above it. */
sidebarFilterEmpty?: boolean;
parentGestureRef?: MutableRefObject<GestureType | undefined>;
dragGestureHostActive?: boolean;
}
export function SidebarStatusWorkspaceList({
@@ -143,8 +135,6 @@ export function SidebarStatusWorkspaceList({
onPinnedWorkspaceReorder,
listHeaderComponent,
sidebarFilterEmpty = false,
parentGestureRef,
dragGestureHostActive,
}: StatusWorkspaceListProps) {
const { t } = useTranslation();
const activeWorkspaceSelection = useActiveWorkspaceSelection();
@@ -170,7 +160,6 @@ export function SidebarStatusWorkspaceList({
(
workspace: SidebarWorkspaceEntry,
input?: {
drag?: () => void;
isDragging?: boolean;
dragHandleProps?: DraggableListDragHandleProps;
},
@@ -189,7 +178,6 @@ export function SidebarStatusWorkspaceList({
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
onToggleWorkspacePin={onToggleWorkspacePin}
onWorkspacePress={onWorkspacePress}
drag={input?.drag}
isDragging={input?.isDragging}
dragHandleProps={input?.dragHandleProps}
/>
@@ -207,12 +195,10 @@ export function SidebarStatusWorkspaceList({
const renderPinnedWorkspace = useCallback(
({
item: workspace,
drag,
isActive,
dragHandleProps,
}: DraggableRenderItemInfo<SidebarWorkspaceEntry>) =>
renderPinnedWorkspaceRow(workspace, {
drag,
isDragging: isActive,
dragHandleProps,
}),
@@ -237,11 +223,7 @@ export function SidebarStatusWorkspaceList({
keyExtractor={statusWorkspaceKeyExtractor}
renderItem={renderPinnedWorkspace}
onDragEnd={onPinnedWorkspaceReorder}
scrollEnabled={false}
useDragHandle
nestable={platformIsNative}
simultaneousGestureRef={parentGestureRef}
gestureHostPresented={dragGestureHostActive}
/>
{canTogglePinnedWorkspaces ? (
<SidebarGroupToggleRow
@@ -275,25 +257,14 @@ export function SidebarStatusWorkspaceList({
return (
<View style={styles.container}>
{platformIsNative ? (
<NestableScrollContainer
style={styles.list}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
testID="sidebar-status-list-scroll"
>
{content}
</NestableScrollContainer>
) : (
<ScrollView
style={styles.list}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
testID="sidebar-status-list-scroll"
>
{content}
</ScrollView>
)}
<DraggableListScrollView
style={styles.list}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
testID="sidebar-status-list-scroll"
>
{content}
</DraggableListScrollView>
</View>
);
}
@@ -543,7 +514,6 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
onToggleWorkspacePin,
reserveIdleStatusIndicatorSpace = true,
inStatusGroup = true,
drag,
isDragging = false,
dragHandleProps,
}: {
@@ -562,7 +532,6 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
*/
inStatusGroup?: boolean;
onWorkspacePress?: () => void;
drag?: () => void;
isDragging?: boolean;
dragHandleProps?: DraggableListDragHandleProps;
}) {
@@ -590,7 +559,6 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
inStatusGroup={inStatusGroup}
onPress={handlePress}
drag={drag}
isDragging={isDragging}
dragHandleProps={dragHandleProps}
/>
@@ -610,7 +578,6 @@ function StatusWorkspaceRowWithMenu({
reserveIdleStatusIndicatorSpace = true,
inStatusGroup = true,
onPress,
drag,
isDragging = false,
dragHandleProps,
}: {
@@ -630,7 +597,6 @@ function StatusWorkspaceRowWithMenu({
*/
inStatusGroup?: boolean;
onPress: () => void;
drag?: () => void;
isDragging?: boolean;
dragHandleProps?: DraggableListDragHandleProps;
}) {
@@ -735,7 +701,6 @@ function StatusWorkspaceRowWithMenu({
onTogglePin={onTogglePin}
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
inStatusGroup={inStatusGroup}
drag={drag}
isDragging={isDragging}
dragHandleProps={dragHandleProps}
/>
@@ -771,26 +736,20 @@ interface StatusWorkspaceRowInnerProps {
reserveIdleStatusIndicatorSpace?: boolean;
/** Pinned rows are flat under their own header; status-group rows indent from theirs. */
inStatusGroup?: boolean;
drag?: () => void;
isDragging?: boolean;
dragHandleProps?: DraggableListDragHandleProps;
}
function StatusWorkspaceRowInner(props: StatusWorkspaceRowInnerProps) {
if (props.drag) {
return <DraggableStatusWorkspaceRowInner {...props} drag={props.drag} />;
if (props.dragHandleProps) {
return <DraggableStatusWorkspaceRowInner {...props} />;
}
return <StatusWorkspaceRowInnerContent {...props} />;
}
function DraggableStatusWorkspaceRowInner(
props: StatusWorkspaceRowInnerProps & { drag: () => void },
) {
const dragInteraction = useLongPressDragInteraction({
drag: props.drag,
menuController: null,
});
return <StatusWorkspaceRowInnerContent {...props} dragInteraction={dragInteraction} />;
function DraggableStatusWorkspaceRowInner(props: StatusWorkspaceRowInnerProps) {
const pressGuard = useRowPressGuard();
return <StatusWorkspaceRowInnerContent {...props} pressGuard={pressGuard} />;
}
function StatusWorkspaceRowInnerContent({
@@ -820,9 +779,9 @@ function StatusWorkspaceRowInnerContent({
inStatusGroup = true,
isDragging = false,
dragHandleProps,
dragInteraction,
pressGuard,
}: StatusWorkspaceRowInnerProps & {
dragInteraction?: ReturnType<typeof useLongPressDragInteraction>;
pressGuard?: ReturnType<typeof useRowPressGuard>;
}) {
const isCompact = useIsCompactFormFactor();
const isTouchPlatform = platformIsNative || isCompact;
@@ -840,17 +799,16 @@ function StatusWorkspaceRowInnerContent({
const accessibilityState = useMemo(() => ({ selected }), [selected]);
const isEditingTitle = titleEditor !== null;
const didLongPressRef = dragInteraction?.didLongPressRef;
const startDragPress = dragInteraction?.handlePressIn;
const moveDragPress = dragInteraction?.handleTouchMove;
const endDragPress = dragInteraction?.handlePressOut;
const consumeMovedPress = pressGuard?.consumeMovedPress;
const startDragPress = pressGuard?.handlePressIn;
const moveDragPress = pressGuard?.handleTouchMove;
const endDragPress = pressGuard?.handlePressOut;
const handlePress = useCallback(() => {
if (didLongPressRef?.current) {
didLongPressRef.current = false;
if (consumeMovedPress?.()) {
return;
}
onPress();
}, [didLongPressRef, onPress]);
}, [consumeMovedPress, onPress]);
const handlePressIn = useCallback(
(event: GestureResponderEvent) => {
setIsPressed(true);
@@ -895,87 +853,89 @@ function StatusWorkspaceRowInnerContent({
});
const backdrop = getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered });
return (
<View
{...dragAttributes}
{...(isEditingTitle ? null : dragHandleProps?.listeners)}
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
style={styles.workspaceRowContainer}
{...hoverHandlers}
>
<SidebarWorkspaceContextMenu
contextMenuOpen={contextMenuOpen}
onContextMenuOpenChange={onContextMenuOpenChange}
workspace={workspace}
leadingProjectName={projectName}
hostBadgeLabel={hostBadge?.label}
serviceSummary={serviceSummary}
workspaceKey={workspace.workspaceKey}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
onRename={onRename}
onMarkAsRead={onMarkAsRead}
onMarkAsUnread={onMarkAsUnread}
onArchive={onArchive}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
archiveShortcutKeys={archiveShortcutKeys}
isPinned={isPinned}
onTogglePin={onTogglePin}
openInFileManagerPath={workspace.workspaceDirectory}
disabled={isArchiving}
enabled={!isEditingTitle}
accessibilityRole="button"
accessibilityState={accessibilityState}
style={workspaceRowStyle}
highlightStyle={styles.workspaceRowPressed}
onPressIn={isEditingTitle ? undefined : handlePressIn}
onTouchMove={isEditingTitle ? undefined : moveDragPress}
onPressOut={isEditingTitle ? undefined : handlePressOut}
onPress={isEditingTitle ? undefined : handlePress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
<DraggableListHandle dragHandleProps={isEditingTitle ? undefined : dragHandleProps}>
<View
{...dragAttributes}
{...(isEditingTitle ? null : dragHandleProps?.listeners)}
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
style={styles.workspaceRowContainer}
{...hoverHandlers}
>
<SidebarWorkspaceRowContent
<SidebarWorkspaceContextMenu
contextMenuOpen={contextMenuOpen}
onContextMenuOpenChange={onContextMenuOpenChange}
workspace={workspace}
hostBadge={hostBadge}
leadingProjectName={projectName}
leadingProjectIconDataUri={projectIconDataUri}
hostBadgeLabel={hostBadge?.label}
serviceSummary={serviceSummary}
backdrop={backdrop}
isHovered={isHovered}
isLoading={isArchiving}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
titleEditor={titleEditor}
workspaceKey={workspace.workspaceKey}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
onRename={onRename}
onMarkAsRead={onMarkAsRead}
onMarkAsUnread={onMarkAsUnread}
onArchive={onArchive}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
archiveShortcutKeys={archiveShortcutKeys}
isPinned={isPinned}
onTogglePin={onTogglePin}
openInFileManagerPath={workspace.workspaceDirectory}
disabled={isArchiving}
enabled={!isEditingTitle}
accessibilityRole="button"
accessibilityState={accessibilityState}
style={workspaceRowStyle}
highlightStyle={styles.workspaceRowPressed}
onPressIn={isEditingTitle ? undefined : handlePressIn}
onTouchMove={isEditingTitle ? undefined : moveDragPress}
onPressOut={isEditingTitle ? undefined : handlePressOut}
onPress={isEditingTitle ? undefined : handlePress}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
>
{renderSlot ? (
<StatusWorkspaceActionSlot
workspace={workspace}
backdrop={backdrop}
trailing={trailing}
trailingPresentation={trailingPresentation}
showKebab={showKebabInSlot}
showArchiveButton={showArchiveButton}
showScrim={showScrim}
reserveSlotWidth={reserveSlotWidth}
isPinned={isPinned}
onTogglePin={onTogglePin}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
onRename={onRename}
onMarkAsRead={onMarkAsRead}
onMarkAsUnread={onMarkAsUnread}
onArchive={onArchive}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
archiveShortcutKeys={archiveShortcutKeys}
/>
) : null}
</SidebarWorkspaceRowContent>
</SidebarWorkspaceContextMenu>
</View>
<SidebarWorkspaceRowContent
workspace={workspace}
hostBadge={hostBadge}
leadingProjectName={projectName}
leadingProjectIconDataUri={projectIconDataUri}
serviceSummary={serviceSummary}
backdrop={backdrop}
isHovered={isHovered}
isLoading={isArchiving}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
titleEditor={titleEditor}
>
{renderSlot ? (
<StatusWorkspaceActionSlot
workspace={workspace}
backdrop={backdrop}
trailing={trailing}
trailingPresentation={trailingPresentation}
showKebab={showKebabInSlot}
showArchiveButton={showArchiveButton}
showScrim={showScrim}
reserveSlotWidth={reserveSlotWidth}
isPinned={isPinned}
onTogglePin={onTogglePin}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
onRename={onRename}
onMarkAsRead={onMarkAsRead}
onMarkAsUnread={onMarkAsUnread}
onArchive={onArchive}
archiveLabel={archiveLabel}
archiveStatus={archiveStatus}
archivePendingLabel={archivePendingLabel}
archiveShortcutKeys={archiveShortcutKeys}
/>
) : null}
</SidebarWorkspaceRowContent>
</SidebarWorkspaceContextMenu>
</View>
</DraggableListHandle>
);
}}
</SidebarWorkspaceRowFrame>
@@ -1,226 +0,0 @@
import { useCallback, useEffect, useRef } from "react";
import type { GestureResponderEvent } from "react-native";
import * as Haptics from "expo-haptics";
import { isWeb as platformIsWeb } from "@/constants/platform";
import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration";
import type { useContextMenu } from "@/components/ui/context-menu";
export function useLongPressDragInteraction(input: {
drag?: () => void;
menuController: ReturnType<typeof useContextMenu> | null;
}) {
const { drag, menuController } = 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);
const touchStartRef = useRef<{ x: number; y: number } | null>(null);
const touchCurrentRef = useRef<{ x: number; y: number } | null>(null);
const dragArmTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const contextMenuTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearTimers = useCallback(() => {
if (dragArmTimerRef.current) {
clearTimeout(dragArmTimerRef.current);
dragArmTimerRef.current = null;
}
if (contextMenuTimerRef.current) {
clearTimeout(contextMenuTimerRef.current);
contextMenuTimerRef.current = null;
}
}, []);
const openContextMenuAtStartPoint = useCallback(() => {
if (!menuController || !touchStartRef.current) {
return;
}
menuController.setAnchorRect({
x: touchStartRef.current.x,
y: touchStartRef.current.y,
width: 0,
height: 0,
});
menuController.setOpen(true);
menuOpenedRef.current = true;
didLongPressRef.current = true;
}, [menuController]);
const handleLongPress = useCallback(() => {
// Manual timers own long-press behavior on mobile.
}, []);
useEffect(() => {
return () => {
clearTimers();
};
}, [clearTimers]);
const armTimers = useCallback(() => {
clearTimers();
if (platformIsWeb || !drag) {
return;
}
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;
dragArmTimerRef.current = setTimeout(() => {
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) {
return;
}
dragArmedRef.current = true;
dragActivatedRef.current = true;
didLongPressRef.current = true;
void Haptics.selectionAsync().catch(() => {});
drag();
}, DRAG_ARM_DELAY_MS);
if (!menuController) {
return;
}
contextMenuTimerRef.current = setTimeout(() => {
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 > CONTEXT_MENU_STATIONARY_SLOP_PX) {
return;
}
void Haptics.selectionAsync().catch(() => {});
openContextMenuAtStartPoint();
}, CONTEXT_MENU_DELAY_MS);
}, [clearTimers, drag, menuController, openContextMenuAtStartPoint]);
const handleDragIntent = useCallback(
(_details: { dx: number; dy: number; distance: number }) => {
if (!dragActivatedRef.current) {
return;
}
didStartDragRef.current = true;
didLongPressRef.current = true;
clearTimers();
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {});
},
[clearTimers],
);
const handleScrollIntent = useCallback(
(_details: { dx: number; dy: number; distance: number }) => {
scrollIntentRef.current = true;
didLongPressRef.current = true;
clearTimers();
},
[clearTimers],
);
const handleSwipeIntent = useCallback(
(_details: { dx: number; dy: number; distance: number }) => {
didLongPressRef.current = true;
clearTimers();
},
[clearTimers],
);
const handlePressIn = useCallback(
(event: GestureResponderEvent) => {
didLongPressRef.current = false;
dragArmedRef.current = false;
dragActivatedRef.current = false;
didStartDragRef.current = false;
scrollIntentRef.current = false;
menuOpenedRef.current = false;
touchStartRef.current = {
x: event.nativeEvent.pageX,
y: event.nativeEvent.pageY,
};
touchCurrentRef.current = {
x: event.nativeEvent.pageX,
y: event.nativeEvent.pageY,
};
armTimers();
},
[armTimers],
);
const handleTouchMove = useCallback(
(event: GestureResponderEvent) => {
const start = touchStartRef.current;
if (!start || didStartDragRef.current || menuOpenedRef.current) {
return;
}
const touch = event?.nativeEvent?.touches?.[0] ?? event?.nativeEvent;
const x = touch?.pageX;
const y = touch?.pageY;
if (typeof x !== "number" || typeof y !== "number") {
return;
}
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,
});
if (decision === "vertical_scroll") {
handleScrollIntent({ dx, dy, distance });
return;
}
if (decision === "horizontal_swipe" || decision === "cancel_long_press") {
handleSwipeIntent({ dx, dy, distance });
return;
}
if (decision === "start_drag") {
handleDragIntent({ dx, dy, distance });
}
},
[handleDragIntent, handleScrollIntent, handleSwipeIntent],
);
const handlePressOut = useCallback(() => {
clearTimers();
dragArmedRef.current = false;
dragActivatedRef.current = false;
touchStartRef.current = null;
touchCurrentRef.current = null;
}, [clearTimers]);
return {
didLongPressRef,
handleLongPress,
handlePressIn,
handleTouchMove,
handlePressOut,
};
}
@@ -0,0 +1,46 @@
import { useCallback, useMemo, useRef } from "react";
import type { GestureResponderEvent } from "react-native";
import { didPressMove } from "@/utils/sidebar-gesture-arbitration";
/**
* Tracks a row press so a touch that scrolled, swiped, or drifted does not navigate when it is
* released. Wire the handlers to the row's Pressable and ask `consumeMovedPress` in `onPress`.
*/
export function useRowPressGuard() {
const movedRef = useRef(false);
const startPointRef = useRef<{ x: number; y: number } | null>(null);
const handlePressIn = useCallback((event: GestureResponderEvent) => {
movedRef.current = false;
startPointRef.current = { x: event.nativeEvent.pageX, y: event.nativeEvent.pageY };
}, []);
const handleTouchMove = useCallback((event: GestureResponderEvent) => {
const startPoint = startPointRef.current;
if (!startPoint || movedRef.current) {
return;
}
const touch = event?.nativeEvent?.touches?.[0] ?? event?.nativeEvent;
const x = touch?.pageX;
const y = touch?.pageY;
if (typeof x !== "number" || typeof y !== "number") {
return;
}
movedRef.current = didPressMove({ startPoint, currentPoint: { x, y } });
}, []);
const handlePressOut = useCallback(() => {
startPointRef.current = null;
}, []);
const consumeMovedPress = useCallback(() => {
const moved = movedRef.current;
movedRef.current = false;
return moved;
}, []);
return useMemo(
() => ({ handlePressIn, handleTouchMove, handlePressOut, consumeMovedPress }),
[consumeMovedPress, handlePressIn, handlePressOut, handleTouchMove],
);
}
@@ -40,7 +40,6 @@ function SortableInlineListItem<T>({
const info: DraggableRenderItemInfo<T> = {
item,
index,
drag: () => {},
isActive: false,
};
return renderItem(info);
@@ -1,4 +1,4 @@
import { useCallback, useMemo, type ReactElement } from "react";
import { useMemo, type ReactElement } from "react";
import {
DndContext,
closestCenter,
@@ -61,11 +61,6 @@ function SortableItem<T>({
isDragging,
} = useSortable({ id, disabled, data: itemData });
const drag = useCallback(() => {
// dnd-kit handles drag initiation via listeners
// This is a no-op but matches the mobile API
}, []);
// External DnD contexts render their own insertion affordance, so keep the
// tab row static and let the DragOverlay carry the moving chip.
const baseTransform = externalDndContext
@@ -89,7 +84,6 @@ function SortableItem<T>({
const info: DraggableRenderItemInfo<T> = {
item,
index,
drag,
isActive: activeId === id,
dragHandleProps: useDragHandle
? {
@@ -1,110 +1,27 @@
import { describe, expect, it } from "vitest";
import {
decideLongPressMove,
shouldOpenContextMenuOnPressOut,
} from "./sidebar-gesture-arbitration";
import { didPressMove } from "./sidebar-gesture-arbitration";
describe("decideLongPressMove", () => {
it("keeps long press pending for small movement before long-press arm", () => {
expect(
decideLongPressMove({
dragArmed: false,
didStartDrag: false,
startPoint: { x: 0, y: 0 },
currentPoint: { x: 3, y: 2 },
}),
).toBe("none");
const start = { x: 0, y: 0 };
describe("didPressMove", () => {
it("keeps a small movement a tap", () => {
expect(didPressMove({ startPoint: start, currentPoint: { x: 3, y: 2 } })).toBe(false);
});
it("cancels long press when movement exceeds cancel slop before arm", () => {
expect(
decideLongPressMove({
dragArmed: false,
didStartDrag: false,
startPoint: { x: 0, y: 0 },
currentPoint: { x: 8, y: 8 },
}),
).toBe("cancel_long_press");
it("counts movement past the cancel slop", () => {
expect(didPressMove({ startPoint: start, currentPoint: { x: 8, y: 8 } })).toBe(true);
});
it("yields to vertical scroll before drag arm", () => {
expect(
decideLongPressMove({
dragArmed: false,
didStartDrag: false,
startPoint: { x: 0, y: 0 },
currentPoint: { x: 2, y: 7 },
}),
).toBe("vertical_scroll");
it("counts a clearly vertical move sooner, as a scroll", () => {
expect(didPressMove({ startPoint: start, currentPoint: { x: 2, y: 7 } })).toBe(true);
});
it("keeps diagonal motion neutral before drag arm", () => {
expect(
decideLongPressMove({
dragArmed: false,
didStartDrag: false,
startPoint: { x: 0, y: 0 },
currentPoint: { x: 5, y: 7 },
}),
).toBe("none");
it("keeps a short diagonal move a tap", () => {
expect(didPressMove({ startPoint: start, currentPoint: { x: 5, y: 7 } })).toBe(false);
});
it("yields to horizontal swipe before drag arm", () => {
expect(
decideLongPressMove({
dragArmed: false,
didStartDrag: false,
startPoint: { x: 0, y: 0 },
currentPoint: { x: -10, y: 2 },
}),
).toBe("horizontal_swipe");
});
it("starts drag when movement exceeds drag slop after long-press arm", () => {
expect(
decideLongPressMove({
dragArmed: true,
didStartDrag: false,
startPoint: { x: 0, y: 0 },
currentPoint: { x: 0, y: 9 },
}),
).toBe("start_drag");
});
it("does nothing when drag already started", () => {
expect(
decideLongPressMove({
dragArmed: true,
didStartDrag: true,
startPoint: { x: 0, y: 0 },
currentPoint: { x: 20, y: 20 },
}),
).toBe("none");
});
});
describe("shouldOpenContextMenuOnPressOut", () => {
it("opens menu only when long-press is armed and drag did not start", () => {
expect(
shouldOpenContextMenuOnPressOut({
longPressArmed: true,
didStartDrag: false,
}),
).toBe(true);
expect(
shouldOpenContextMenuOnPressOut({
longPressArmed: false,
didStartDrag: false,
}),
).toBe(false);
expect(
shouldOpenContextMenuOnPressOut({
longPressArmed: true,
didStartDrag: true,
}),
).toBe(false);
it("counts a clearly horizontal move sooner, as a swipe", () => {
expect(didPressMove({ startPoint: start, currentPoint: { x: -10, y: 2 } })).toBe(true);
});
});
@@ -1,57 +1,25 @@
export type LongPressMoveDecision =
| "none"
| "cancel_long_press"
| "vertical_scroll"
| "horizontal_swipe"
| "start_drag";
export function decideLongPressMove(input: {
dragArmed: boolean;
didStartDrag: boolean;
startPoint: { x: number; y: number } | null;
currentPoint: { x: number; y: number };
cancelSlopPx?: number;
scrollSlopPx?: number;
swipeSlopPx?: number;
directionalDominanceRatio?: number;
dragSlopPx?: number;
}): LongPressMoveDecision {
const cancelSlopPx = input.cancelSlopPx ?? 10;
const scrollSlopPx = input.scrollSlopPx ?? 6;
const swipeSlopPx = input.swipeSlopPx ?? 8;
const directionalDominanceRatio = input.directionalDominanceRatio ?? 1.5;
const dragSlopPx = input.dragSlopPx ?? 8;
if (!input.startPoint) {
return "none";
}
if (input.didStartDrag) {
return "none";
}
const dx = input.currentPoint.x - input.startPoint.x;
const dy = input.currentPoint.y - input.startPoint.y;
const absDx = Math.abs(dx);
const absDy = Math.abs(dy);
const distance = Math.sqrt(dx * dx + dy * dy);
const clearlyVertical = absDy >= absDx * directionalDominanceRatio;
const clearlyHorizontal = absDx >= absDy * directionalDominanceRatio;
if (!input.dragArmed) {
if (clearlyVertical && absDy > scrollSlopPx) {
return "vertical_scroll";
}
if (clearlyHorizontal && absDx > swipeSlopPx) {
return "horizontal_swipe";
}
return distance > cancelSlopPx ? "cancel_long_press" : "none";
}
return distance > dragSlopPx ? "start_drag" : "none";
interface Point {
x: number;
y: number;
}
export function shouldOpenContextMenuOnPressOut(input: {
longPressArmed: boolean;
didStartDrag: boolean;
}): boolean {
return input.longPressArmed && !input.didStartDrag;
const SCROLL_SLOP_PX = 6;
const SWIPE_SLOP_PX = 8;
const CANCEL_SLOP_PX = 10;
const DIRECTIONAL_DOMINANCE_RATIO = 1.5;
/**
* Whether a row press moved far enough to be a scroll, a swipe, or a drift rather than a tap.
* A clearly vertical or horizontal move counts sooner than a diagonal one.
*/
export function didPressMove(input: { startPoint: Point; currentPoint: Point }): boolean {
const absDx = Math.abs(input.currentPoint.x - input.startPoint.x);
const absDy = Math.abs(input.currentPoint.y - input.startPoint.y);
if (absDy >= absDx * DIRECTIONAL_DOMINANCE_RATIO && absDy > SCROLL_SLOP_PX) {
return true;
}
if (absDx >= absDy * DIRECTIONAL_DOMINANCE_RATIO && absDx > SWIPE_SLOP_PX) {
return true;
}
return Math.sqrt(absDx * absDx + absDy * absDy) > CANCEL_SLOP_PX;
}
@@ -1,475 +0,0 @@
diff --git a/node_modules/react-native-draggable-flatlist/lib/typescript/components/DraggableFlatList.d.ts b/node_modules/react-native-draggable-flatlist/lib/typescript/components/DraggableFlatList.d.ts
index d3a4f1f..083bf19 100644
--- a/node_modules/react-native-draggable-flatlist/lib/typescript/components/DraggableFlatList.d.ts
+++ b/node_modules/react-native-draggable-flatlist/lib/typescript/components/DraggableFlatList.d.ts
@@ -16,12 +16,14 @@ declare const _default: <T>(props: Omit<FlatListProps<T>, "keyExtractor" | "data
keyExtractor: (item: T, index: number) => string;
onDragBegin?: ((index: number) => void) | undefined;
onDragEnd?: ((params: import("../types").DragEndParams<T>) => void) | undefined;
+ onDragTerminate?: (() => void) | undefined;
onPlaceholderIndexChange?: ((placeholderIndex: number) => void) | undefined;
onRelease?: ((index: number) => void) | undefined;
onScrollOffsetChange?: ((scrollOffset: number) => void) | undefined;
renderItem: import("../types").RenderItem<T>;
renderPlaceholder?: import("../types").RenderPlaceholder<T> | undefined;
simultaneousHandlers?: React.Ref<any> | React.Ref<any>[] | undefined;
+ dragGestureHostPresented?: boolean | undefined;
outerScrollOffset?: Animated.SharedValue<number> | undefined;
onAnimValInit?: ((animVals: {
activeCellOffset: import("react-native-reanimated").SharedValue<number>;
diff --git a/node_modules/react-native-draggable-flatlist/lib/typescript/components/NestableDraggableFlatList.d.ts b/node_modules/react-native-draggable-flatlist/lib/typescript/components/NestableDraggableFlatList.d.ts
index d3c6cbb..36e9645 100644
--- a/node_modules/react-native-draggable-flatlist/lib/typescript/components/NestableDraggableFlatList.d.ts
+++ b/node_modules/react-native-draggable-flatlist/lib/typescript/components/NestableDraggableFlatList.d.ts
@@ -15,12 +15,14 @@ export declare const NestableDraggableFlatList: <T>(props: Omit<import("react-na
keyExtractor: (item: T, index: number) => string;
onDragBegin?: ((index: number) => void) | undefined;
onDragEnd?: ((params: import("../types").DragEndParams<T>) => void) | undefined;
+ onDragTerminate?: (() => void) | undefined;
onPlaceholderIndexChange?: ((placeholderIndex: number) => void) | undefined;
onRelease?: ((index: number) => void) | undefined;
onScrollOffsetChange?: ((scrollOffset: number) => void) | undefined;
renderItem: import("../types").RenderItem<T>;
renderPlaceholder?: import("../types").RenderPlaceholder<T> | undefined;
simultaneousHandlers?: React.Ref<any> | React.Ref<any>[] | undefined;
+ dragGestureHostPresented?: boolean | undefined;
outerScrollOffset?: Animated.SharedValue<number> | undefined;
onAnimValInit?: ((animVals: {
activeCellOffset: import("react-native-reanimated").SharedValue<number>;
diff --git a/node_modules/react-native-draggable-flatlist/lib/typescript/types.d.ts b/node_modules/react-native-draggable-flatlist/lib/typescript/types.d.ts
index 2abb4a2..8ca967f 100644
--- a/node_modules/react-native-draggable-flatlist/lib/typescript/types.d.ts
+++ b/node_modules/react-native-draggable-flatlist/lib/typescript/types.d.ts
@@ -23,12 +23,16 @@ export declare type DraggableFlatListProps<T> = Modify<FlatListProps<T>, {
keyExtractor: (item: T, index: number) => string;
onDragBegin?: (index: number) => void;
onDragEnd?: (params: DragEndParams<T>) => void;
+ /** @internal Used by the nestable wrapper to release its outer-scroll lock on cancellation. */
+ onDragTerminate?: () => void;
onPlaceholderIndexChange?: (placeholderIndex: number) => void;
onRelease?: (index: number) => void;
onScrollOffsetChange?: (scrollOffset: number) => void;
renderItem: RenderItem<T>;
renderPlaceholder?: RenderPlaceholder<T>;
simultaneousHandlers?: React.Ref<any> | React.Ref<any>[];
+ /** @internal Forces handler reattachment when a retained native host is presented. */
+ dragGestureHostPresented?: boolean;
outerScrollOffset?: Animated.SharedValue<number>;
onAnimValInit?: (animVals: ReturnType<typeof useAnimatedValues>) => void;
itemEnteringAnimation?: AnimateProps<Animated.View>["entering"];
diff --git a/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx b/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx
index 7c88afc..a74140f 100644
--- a/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx
+++ b/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx
@@ -16,9 +16,11 @@ import {
FlatList,
Gesture,
GestureDetector,
+ State,
} from "react-native-gesture-handler";
import Animated, {
runOnJS,
+ runOnUI,
useAnimatedReaction,
useAnimatedScrollHandler,
useSharedValue,
@@ -92,6 +94,17 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
activeCellOffset.value = -1;
setActiveKey(null);
});
+ const terminalHandled = useSharedValue(false);
+ const gestureSessionId = useSharedValue(0);
+ const gestureSessionActive = useSharedValue(false);
+ const currentGestureSessionRef = useRef<{
+ id: number;
+ active: boolean;
+ } | null>(null);
+ const activeDragRef = useRef<{
+ sessionId: number;
+ index: number;
+ } | null>(null);
const {
dragHitSlop = DEFAULT_PROPS.dragHitSlop,
@@ -144,19 +157,88 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
});
}, [props.data, keyExtractor, keyToIndexRef]);
+ const onDragCancel = useStableCallback((sessionId: number, index: number) => {
+ if (activeDragRef.current?.sessionId !== sessionId) return;
+ activeDragRef.current = null;
+ reset();
+ props.onRelease?.(index);
+ props.onDragTerminate?.();
+ });
+
+ useEffect(() => {
+ return () => {
+ const activeDrag = activeDragRef.current;
+ if (!activeDrag) return;
+ activeDragRef.current = null;
+ propsRef.current.onRelease?.(activeDrag.index);
+ propsRef.current.onDragTerminate?.();
+ };
+ }, [propsRef]);
+
+ const onGestureBegin = useStableCallback((sessionId: number) => {
+ currentGestureSessionRef.current = { id: sessionId, active: true };
+ });
+
+ const onGestureFinalize = useStableCallback(
+ (sessionId: number, committed: boolean) => {
+ if (currentGestureSessionRef.current?.id === sessionId) {
+ currentGestureSessionRef.current.active = false;
+ }
+ if (!committed && activeDragRef.current?.sessionId === sessionId) {
+ onDragCancel(sessionId, activeDragRef.current.index);
+ }
+ }
+ );
+
+ const onDragActivationRejected = useStableCallback((sessionId: number) => {
+ if (activeDragRef.current?.sessionId === sessionId) {
+ onDragCancel(sessionId, activeDragRef.current.index);
+ }
+ });
+
+ const activateDragOnUI = (
+ sessionId: number,
+ index: number,
+ cellOffset: number,
+ cellSize: number,
+ hasCellMeasurements: boolean
+ ) => {
+ "worklet";
+ if (
+ gestureSessionId.value !== sessionId ||
+ !gestureSessionActive.value ||
+ terminalHandled.value
+ ) {
+ runOnJS(onDragActivationRejected)(sessionId);
+ return;
+ }
+ if (hasCellMeasurements) {
+ activeCellOffset.value = cellOffset;
+ activeCellSize.value = cellSize;
+ }
+ spacerIndexAnim.value = index;
+ activeIndexAnim.value = index;
+ };
+
const drag = useStableCallback((activeKey: string) => {
- if (disabled.value) return;
+ const gestureSession = currentGestureSessionRef.current;
+ if (disabled.value || !gestureSession?.active) return;
const index = keyToIndexRef.current.get(activeKey);
const cellData = cellDataRef.current.get(activeKey);
- if (cellData) {
- activeCellOffset.value = cellData.measurements.offset;
- activeCellSize.value = cellData.measurements.size;
- }
const { onDragBegin } = propsRef.current;
if (index !== undefined) {
- spacerIndexAnim.value = index;
- activeIndexAnim.value = index;
+ runOnUI(activateDragOnUI)(
+ gestureSession.id,
+ index,
+ cellData?.measurements.offset ?? -1,
+ cellData?.measurements.size ?? -1,
+ Boolean(cellData)
+ );
+ activeDragRef.current = {
+ sessionId: gestureSession.id,
+ index,
+ };
setActiveKey(activeKey);
onDragBegin?.(index);
}
@@ -218,8 +300,25 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
props.onRelease?.(index);
});
+ const finalizeCancelledDrag = () => {
+ "worklet";
+ const activeIndex = activeIndexAnim.value;
+ if (terminalHandled.value || activeIndex < 0) return;
+
+ terminalHandled.value = true;
+ isTouchActiveNative.value = false;
+ touchTranslate.value = 0;
+ activeIndexAnim.value = -1;
+ spacerIndexAnim.value = -1;
+ activeCellSize.value = -1;
+ activeCellOffset.value = -1;
+ disabled.value = false;
+ };
+
const onDragEnd = useStableCallback(
- ({ from, to }: { from: number; to: number }) => {
+ ({ from, to, sessionId }: { from: number; to: number; sessionId: number }) => {
+ if (activeDragRef.current?.sessionId !== sessionId) return;
+ activeDragRef.current = null;
const { onDragEnd, data } = props;
const newData = [...data];
@@ -238,26 +337,6 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
props.onPlaceholderIndexChange?.(index);
});
- // Handle case where user ends drag without moving their finger.
- useAnimatedReaction(
- () => {
- return isTouchActiveNative.value;
- },
- (cur, prev) => {
- if (cur !== prev && !cur) {
- const hasMoved = !!touchTranslate.value;
- if (!hasMoved && activeIndexAnim.value >= 0 && !disabled.value) {
- runOnJS(onRelease)(activeIndexAnim.value);
- runOnJS(onDragEnd)({
- from: activeIndexAnim.value,
- to: spacerIndexAnim.value,
- });
- }
- }
- },
- [isTouchActiveNative, onDragEnd, onRelease]
- );
-
useAnimatedReaction(
() => {
return spacerIndexAnim.value;
@@ -271,11 +350,24 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
);
const gestureDisabled = useSharedValue(false);
+ const touchStartX = useSharedValue(0);
+ const touchStartY = useSharedValue(0);
+ const manualPanActivated = useSharedValue(false);
+ const coordinatesDragWithExternalGesture = Boolean(props.simultaneousHandlers);
- const panGesture = Gesture.Pan()
+ let panGesture = Gesture.Pan()
.onBegin((evt) => {
+ manualPanActivated.value = false;
gestureDisabled.value = disabled.value;
if (gestureDisabled.value) return;
+ const sessionId = gestureSessionId.value + 1;
+ gestureSessionId.value = sessionId;
+ gestureSessionActive.value = true;
+ terminalHandled.value = false;
+ runOnJS(onGestureBegin)(sessionId);
+ panGestureState.value = evt.state;
+ })
+ .onStart((evt) => {
panGestureState.value = evt.state;
})
.onUpdate((evt) => {
@@ -286,8 +378,15 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
: evt.translationY;
touchTranslate.value = translation;
})
- .onEnd((evt) => {
+ .onEnd((evt, success) => {
if (gestureDisabled.value) return;
+ if (
+ !success ||
+ terminalHandled.value ||
+ !gestureSessionActive.value
+ )
+ return;
+
// Set touch val to current translate val
isTouchActiveNative.value = false;
const translation = horizontalAnim.value
@@ -299,6 +398,7 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
// Only call onDragEnd if actually dragging a cell
if (activeIndexAnim.value === -1 || disabled.value) return;
+ terminalHandled.value = true;
disabled.value = true;
runOnJS(onRelease)(activeIndexAnim.value);
const springTo = placeholderOffset.value - activeCellOffset.value;
@@ -309,22 +409,84 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
runOnJS(onDragEnd)({
from: activeIndexAnim.value,
to: spacerIndexAnim.value,
+ sessionId: gestureSessionId.value,
});
disabled.value = false;
}
);
})
- .onTouchesDown(() => {
+ .onFinalize((evt) => {
+ manualPanActivated.value = false;
+ panGestureState.value = evt.state;
+ runOnJS(onContainerTouchEnd)();
+ if (gestureDisabled.value) return;
+
+ const sessionId = gestureSessionId.value;
+ const committed = terminalHandled.value;
+ gestureSessionActive.value = false;
+ if (!committed) finalizeCancelledDrag();
+ runOnJS(onGestureFinalize)(sessionId, committed);
+ })
+ .onTouchesDown((event) => {
+ const touch = event.changedTouches[0];
+ if (touch) {
+ touchStartX.value = touch.absoluteX;
+ touchStartY.value = touch.absoluteY;
+ }
runOnJS(onContainerTouchStart)();
})
- .onTouchesUp(() => {
+ .onTouchesMove((event, stateManager) => {
+ if (!coordinatesDragWithExternalGesture) return;
+
+ // Manual activation arbitrates only while the pan is waiting. Failing or
+ // reactivating an active recognizer can leave RNGH/Reanimated livelocked.
+ if (manualPanActivated.value) return;
+
+ const touch = event.changedTouches[0];
+ if (!touch || event.numberOfTouches !== 1) {
+ stateManager.fail();
+ return;
+ }
+
+ const deltaX = touch.absoluteX - touchStartX.value;
+ const deltaY = touch.absoluteY - touchStartY.value;
+ const absDeltaX = Math.abs(deltaX);
+ const absDeltaY = Math.abs(deltaY);
+
+ if (activeIndexAnim.value < 0) {
+ if (absDeltaX > 6 || absDeltaY > 6) {
+ stateManager.fail();
+ }
+ return;
+ }
+
+ if (absDeltaX > 10 && absDeltaX > absDeltaY) {
+ stateManager.fail();
+ } else if (absDeltaY >= activationDistanceProp && absDeltaY > absDeltaX) {
+ manualPanActivated.value = true;
+ stateManager.activate();
+ }
+ })
+ .onTouchesUp((event, stateManager) => {
// Turning this into a worklet causes timing issues. We want it to run
// just after the finger lifts.
runOnJS(onContainerTouchEnd)();
+ // A manually activated iOS pan can remain BEGAN after the last finger
+ // lifts. Fail it explicitly so onFinalize cancels the armed row.
+ if (event.numberOfTouches === 0 && panGestureState.value === State.BEGAN) {
+ stateManager.fail();
+ }
+ })
+ .onTouchesCancelled((_event, stateManager) => {
+ if (panGestureState.value === State.BEGAN) {
+ stateManager.fail();
+ }
});
if (dragHitSlop) panGesture.hitSlop(dragHitSlop);
- if (activationDistanceProp) {
+ if (coordinatesDragWithExternalGesture) {
+ panGesture = panGesture.manualActivation(true);
+ } else if (activationDistanceProp) {
const activeOffset = [-activationDistanceProp, activationDistanceProp];
if (props.horizontal) {
panGesture.activeOffsetX(activeOffset);
@@ -332,6 +494,21 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
panGesture.activeOffsetY(activeOffset);
}
}
+ if (props.simultaneousHandlers) {
+ panGesture.simultaneousWithExternalGesture(
+ ...(Array.isArray(props.simultaneousHandlers)
+ ? props.simultaneousHandlers
+ : [props.simultaneousHandlers])
+ );
+ }
+
+ // GestureDetector only updates handler config when a retained host reappears
+ // with the same native tag. Changing the gesture count forces a native
+ // reattachment without remounting the list subtree.
+ const detectorGesture =
+ props.dragGestureHostPresented === false
+ ? Gesture.Simultaneous(panGesture, Gesture.Manual().enabled(false))
+ : panGesture;
const onScroll = useStableCallback((scrollOffset: number) => {
props.onScrollOffsetChange?.(scrollOffset);
@@ -373,8 +550,9 @@ function DraggableFlatListInner<T>(props: DraggableFlatListProps<T>) {
horizontal={!!props.horizontal}
layoutAnimationDisabled={layoutAnimationDisabled}
>
- <GestureDetector gesture={panGesture}>
+ <GestureDetector gesture={detectorGesture}>
<Animated.View
+ collapsable={false}
style={props.containerStyle}
ref={containerRef}
onLayout={onContainerLayout}
diff --git a/node_modules/react-native-draggable-flatlist/src/components/NestableDraggableFlatList.tsx b/node_modules/react-native-draggable-flatlist/src/components/NestableDraggableFlatList.tsx
index 1559352..33eb5de 100644
--- a/node_modules/react-native-draggable-flatlist/src/components/NestableDraggableFlatList.tsx
+++ b/node_modules/react-native-draggable-flatlist/src/components/NestableDraggableFlatList.tsx
@@ -1,5 +1,5 @@
import React, { useRef, useState } from "react";
-import { findNodeHandle, LogBox } from "react-native";
+import { LogBox } from "react-native";
import Animated, {
useDerivedValue,
useSharedValue,
@@ -48,7 +48,7 @@ function NestableDraggableFlatListInner<T>(
});
const onListContainerLayout = useStableCallback(async ({ containerRef }) => {
- const nodeHandle = findNodeHandle(scrollableRef.current);
+ const nodeHandle = scrollableRef.current;
const onSuccess = (_x: number, y: number) => {
listVerticalOffset.value = y;
@@ -74,6 +74,10 @@ function NestableDraggableFlatListInner<T>(
}
);
+ const onDragTerminate = useStableCallback(() => {
+ setOuterScrollEnabled(true);
+ });
+
const onAnimValInit: DraggableFlatListProps<T>["onAnimValInit"] = useStableCallback(
(params) => {
setListHoverOffset(params.hoverOffset);
@@ -95,6 +99,7 @@ function NestableDraggableFlatListInner<T>(
outerScrollOffset={outerScrollOffset}
onDragBegin={onDragBegin}
onDragEnd={onDragEnd}
+ onDragTerminate={onDragTerminate}
onAnimValInit={onAnimValInit}
/>
);
diff --git a/node_modules/react-native-draggable-flatlist/src/types.ts b/node_modules/react-native-draggable-flatlist/src/types.ts
index d6755c8..1b244a6 100644
--- a/node_modules/react-native-draggable-flatlist/src/types.ts
+++ b/node_modules/react-native-draggable-flatlist/src/types.ts
@@ -36,12 +36,16 @@ export type DraggableFlatListProps<T> = Modify<
keyExtractor: (item: T, index: number) => string;
onDragBegin?: (index: number) => void;
onDragEnd?: (params: DragEndParams<T>) => void;
+ /** @internal Used by the nestable wrapper to release its outer-scroll lock on cancellation. */
+ onDragTerminate?: () => void;
onPlaceholderIndexChange?: (placeholderIndex: number) => void;
onRelease?: (index: number) => void;
onScrollOffsetChange?: (scrollOffset: number) => void;
renderItem: RenderItem<T>;
renderPlaceholder?: RenderPlaceholder<T>;
simultaneousHandlers?: React.Ref<any> | React.Ref<any>[];
+ /** @internal Forces handler reattachment when a retained native host is presented. */
+ dragGestureHostPresented?: boolean;
outerScrollOffset?: Animated.SharedValue<number>;
onAnimValInit?: (animVals: ReturnType<typeof useAnimatedValues>) => void;
itemEnteringAnimation?: AnimateProps<Animated.View>["entering"];
-4
View File
@@ -48,10 +48,6 @@ const patchedPackages = [
nodeModulesPath: "node_modules/@legendapp/list",
patchPrefix: "@legendapp+list+",
},
{
nodeModulesPath: "node_modules/react-native-draggable-flatlist",
patchPrefix: "react-native-draggable-flatlist+",
},
// Web pointer capture: capture only pointers the handler still tracks after pointer-down, so a
// touch-only pan that fails the mouse leaves browser text selection alone. Keep until upstream
// stops capturing pointers a handler has rejected.
+4 -3
View File
@@ -7,8 +7,9 @@ import { test } from "node:test";
const script = path.resolve("scripts", "postinstall-patches.mjs");
const bin = path.resolve("node_modules", ".bin");
const packageName = "react-native-draggable-flatlist";
const patchFile = `${packageName}+4.0.3.patch`;
const packageName = "react-native-gesture-handler";
const packageVersion = "2.32.0";
const patchFile = `${packageName}+${packageVersion}.patch`;
for (const platform of ["ios", "android"]) {
test(`patched Markdown ships ${platform} props generated from its native specs`, () => {
@@ -54,7 +55,7 @@ function createCheckout() {
mkdirSync(packageDir, { recursive: true });
writeFileSync(
path.join(packageDir, "package.json"),
JSON.stringify({ name: packageName, version: "4.0.3" }),
JSON.stringify({ name: packageName, version: packageVersion }),
);
writeFileSync(path.join(packageDir, "index.js"), "export const value = 1;\n");
mkdirSync(path.join(root, "patches"));