mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-14 12:25:05 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f3967d288 | ||
|
|
ade902f195 | ||
|
|
1bd94ac2b0 | ||
|
|
7deb338d4c |
@@ -418,6 +418,7 @@
|
||||
"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",
|
||||
|
||||
Generated
+15
-32
@@ -31853,6 +31853,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -31895,22 +31909,6 @@
|
||||
"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",
|
||||
@@ -31994,21 +31992,6 @@
|
||||
"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",
|
||||
@@ -37566,6 +37549,7 @@
|
||||
"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",
|
||||
@@ -37574,7 +37558,6 @@
|
||||
"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",
|
||||
|
||||
@@ -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, 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.
|
||||
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.
|
||||
|
||||
Start the debug dev client against the intended Metro server, then run:
|
||||
|
||||
|
||||
@@ -49,22 +49,29 @@ 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 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.
|
||||
# Horizontal intent after the lift belongs to panel close. It must cancel the
|
||||
# drag without committing another reorder or leaving the outer list locked.
|
||||
- swipe:
|
||||
from:
|
||||
text: ".*${PASEO_MAESTRO_DRAG_B_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_B_NAME}, Project actions"
|
||||
direction: LEFT
|
||||
duration: 20000
|
||||
- assertVisible:
|
||||
- assertNotVisible:
|
||||
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 outer scroll still works after
|
||||
# the horizontal drag.
|
||||
# Reaching a fixture at the bottom proves the nestable outer scroll was
|
||||
# unlocked by cancellation.
|
||||
- scrollUntilVisible:
|
||||
element:
|
||||
text: ".*${PASEO_MAESTRO_DRAG_Z_NAME}, Create a new workspace for ${PASEO_MAESTRO_DRAG_Z_NAME}, Project actions"
|
||||
@@ -85,7 +92,7 @@ appId: ${PASEO_MAESTRO_APP_ID}
|
||||
visibilityPercentage: 50
|
||||
centerElement: true
|
||||
|
||||
# A real reorder after the horizontal drag proves the list is not merely scrollable;
|
||||
# A real reorder after cancellation proves the list is not merely scrollable;
|
||||
# its drag recognizer and semantic order update both recovered.
|
||||
- swipe:
|
||||
from:
|
||||
|
||||
@@ -122,6 +122,7 @@
|
||||
"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",
|
||||
@@ -130,7 +131,6 @@
|
||||
"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",
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
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"));
|
||||
});
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
/**
|
||||
* 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,169 +1,133 @@
|
||||
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";
|
||||
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";
|
||||
|
||||
export type { DraggableListProps, DraggableRenderItemInfo };
|
||||
|
||||
// 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>;
|
||||
}
|
||||
const SCROLL_ENABLED_FLEX_STYLE = { flex: 1 };
|
||||
|
||||
export function DraggableList<T>({
|
||||
data,
|
||||
keyExtractor,
|
||||
renderItem,
|
||||
onDragEnd,
|
||||
style,
|
||||
containerStyle,
|
||||
contentContainerStyle,
|
||||
testID,
|
||||
useDragHandle = false,
|
||||
ListFooterComponent,
|
||||
ListHeaderComponent,
|
||||
ListEmptyComponent,
|
||||
showsVerticalScrollIndicator = true,
|
||||
scrollEnabled = true,
|
||||
useDragHandle: _useDragHandle = false,
|
||||
refreshing,
|
||||
onRefresh,
|
||||
extraData,
|
||||
simultaneousGestureRef,
|
||||
gestureHostPresented,
|
||||
waitFor,
|
||||
onDragBegin: onDragBeginProp,
|
||||
nestable = false,
|
||||
}: DraggableListProps<T>) {
|
||||
const scrollViewRef = useContext(ScrollViewRefContext);
|
||||
const nested = useContext(NestedListContext);
|
||||
const dragHandleProps = useDragHandle ? NATIVE_DRAG_HANDLE_PROPS : undefined;
|
||||
const { theme } = useUnistyles();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const renderGridItem = useCallback<SortableGridRenderItem<T>>(
|
||||
({ item, index }) => (
|
||||
<DraggableListItem
|
||||
item={item}
|
||||
index={index}
|
||||
renderItem={renderItem}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
),
|
||||
[dragHandleProps, renderItem],
|
||||
// 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 handleDragEnd = useCallback(
|
||||
({ indexToKey }: SortableGridDragEndParams<T>) => {
|
||||
onDragEnd(orderDroppedItems({ data, keyExtractor, indexToKey }));
|
||||
({ data: newData }: { data: T[] }) => {
|
||||
setIsDragging(false);
|
||||
onDragEnd(newData);
|
||||
},
|
||||
[data, keyExtractor, onDragEnd],
|
||||
[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],
|
||||
);
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import type { MutableRefObject, PropsWithChildren, ReactElement } from "react";
|
||||
import type { ReactElement, MutableRefObject } 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;
|
||||
@@ -16,34 +15,53 @@ 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) => string;
|
||||
keyExtractor: (item: T, index: number) => 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;
|
||||
/**
|
||||
* 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.
|
||||
* Web-only: when true, the drag can only be initiated from the handle props
|
||||
* passed to `renderItem` (prevents nested lists from fighting).
|
||||
*/
|
||||
useDragHandle?: 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. */
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useMemo, useRef, type ReactElement } from "react";
|
||||
import { memo, useCallback, useMemo, useRef, type ReactElement } from "react";
|
||||
import { ScrollView, View } from "react-native";
|
||||
import {
|
||||
DndContext,
|
||||
@@ -17,12 +17,7 @@ import {
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import type {
|
||||
DraggableListHandleProps,
|
||||
DraggableListProps,
|
||||
DraggableListScrollViewProps,
|
||||
DraggableRenderItemInfo,
|
||||
} from "./draggable-list.types";
|
||||
import type { DraggableListProps, DraggableRenderItemInfo } from "./draggable-list.types";
|
||||
import { getDragActivationConstraints, useDragReorderState } from "./drag-reorder";
|
||||
|
||||
export type { DraggableListProps, DraggableRenderItemInfo };
|
||||
@@ -121,6 +116,18 @@ 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
|
||||
@@ -159,6 +166,7 @@ function SortableItemInner<T>({
|
||||
const info: DraggableRenderItemInfo<T> = {
|
||||
item,
|
||||
index,
|
||||
drag,
|
||||
isActive: activeId === id,
|
||||
dragHandleProps,
|
||||
};
|
||||
@@ -176,44 +184,31 @@ 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,
|
||||
// Native-only, as before.
|
||||
testID: _testID,
|
||||
contentContainerStyle,
|
||||
testID,
|
||||
ListFooterComponent,
|
||||
ListHeaderComponent,
|
||||
ListEmptyComponent,
|
||||
showsVerticalScrollIndicator = true,
|
||||
scrollEnabled = true,
|
||||
extraData: _extraData,
|
||||
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);
|
||||
|
||||
@@ -229,39 +224,89 @@ export function DraggableList<T>({
|
||||
}),
|
||||
);
|
||||
|
||||
const ids = useMemo(() => items.map((item) => keyExtractor(item)), [items, keyExtractor]);
|
||||
const ids = useMemo(
|
||||
() => items.map((item, index) => keyExtractor(item, index)),
|
||||
[items, keyExtractor],
|
||||
);
|
||||
const wrapperStyle = useMemo(
|
||||
() => [{ position: "relative" as const }, containerStyle],
|
||||
[containerStyle],
|
||||
() => [
|
||||
{ position: "relative" as const },
|
||||
scrollEnabled ? { flex: 1, minHeight: 0 } : null,
|
||||
containerStyle,
|
||||
],
|
||||
[scrollEnabled, containerStyle],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={wrapperStyle}>
|
||||
<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>
|
||||
{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}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -669,6 +669,7 @@ function MobileSidebar({
|
||||
onAddProject={handleOpenProject}
|
||||
onImportSession={handleImportSession}
|
||||
parentGestureRef={closeGestureRef}
|
||||
dragGestureHostActive={active}
|
||||
listHeaderComponent={workspacesSectionHeaderElement}
|
||||
/>
|
||||
)}
|
||||
@@ -870,6 +871,7 @@ function DesktopSidebar({
|
||||
onImportSession={handleImportSession}
|
||||
onWorkspacePress={closeSidebar}
|
||||
parentGestureRef={closeGestureRef}
|
||||
dragGestureHostActive={active}
|
||||
listHeaderComponent={workspacesSectionHeaderElement}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
type GestureResponderEvent,
|
||||
type PressableStateCallbackType,
|
||||
type StyleProp,
|
||||
@@ -38,12 +39,8 @@ 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 {
|
||||
DraggableList,
|
||||
DraggableListHandle,
|
||||
DraggableListScrollView,
|
||||
type DraggableRenderItemInfo,
|
||||
} from "./draggable-list";
|
||||
import { NestableScrollContainer } from "react-native-draggable-flatlist";
|
||||
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
|
||||
import type { DraggableListDragHandleProps } from "./draggable-list.types";
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
@@ -79,6 +76,7 @@ import {
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
useContextMenu,
|
||||
} from "@/components/ui/context-menu";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -99,7 +97,7 @@ import {
|
||||
SidebarWorkspaceContextMenu,
|
||||
SidebarWorkspaceMenu,
|
||||
} from "@/components/sidebar/sidebar-workspace-menu";
|
||||
import { useRowPressGuard } from "@/components/sidebar/use-row-press-guard";
|
||||
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
|
||||
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";
|
||||
@@ -242,6 +240,7 @@ interface SidebarWorkspaceListProps {
|
||||
listHeaderComponent?: ReactElement | null;
|
||||
/** Gesture ref for coordinating with parent gestures (e.g., sidebar close) */
|
||||
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||
dragGestureHostActive?: boolean;
|
||||
}
|
||||
|
||||
interface ProjectHeaderRowProps {
|
||||
@@ -258,8 +257,10 @@ 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;
|
||||
@@ -274,10 +275,12 @@ 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;
|
||||
@@ -871,8 +874,10 @@ function ProjectHeaderRow({
|
||||
onWorktreeCreated: _onWorktreeCreated,
|
||||
shortcutNumber = null,
|
||||
showShortcutBadge = false,
|
||||
drag,
|
||||
isDragging,
|
||||
isArchiving = false,
|
||||
menuController,
|
||||
onRemoveProject,
|
||||
removeProjectStatus = "idle",
|
||||
dragHandleProps,
|
||||
@@ -898,14 +903,24 @@ function ProjectHeaderRow({
|
||||
}) as Href,
|
||||
);
|
||||
}, [displayName, onWorkspacePress, worktreeTarget]);
|
||||
const interaction = useRowPressGuard();
|
||||
const interaction = useLongPressDragInteraction({
|
||||
drag,
|
||||
menuController,
|
||||
});
|
||||
const {
|
||||
role: _dragRole,
|
||||
tabIndex: _dragTabIndex,
|
||||
"aria-roledescription": _dragRoleDescription,
|
||||
...dragAttributes
|
||||
} = dragHandleProps?.attributes ?? {};
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
if (interaction.consumeMovedPress()) {
|
||||
if (interaction.didLongPressRef.current) {
|
||||
interaction.didLongPressRef.current = false;
|
||||
return;
|
||||
}
|
||||
onPress();
|
||||
}, [interaction, onPress]);
|
||||
}, [interaction.didLongPressRef, onPress]);
|
||||
|
||||
const handlePointerEnter = useCallback(() => {
|
||||
if (!contextMenuOpen) setIsHovered(true);
|
||||
@@ -981,8 +996,10 @@ function ProjectHeaderRow({
|
||||
|
||||
if (!onRemoveProject) {
|
||||
return (
|
||||
<ProjectHeaderDragHandle
|
||||
dragHandleProps={dragHandleProps}
|
||||
<View
|
||||
{...dragAttributes}
|
||||
{...dragHandleProps?.listeners}
|
||||
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
>
|
||||
@@ -998,14 +1015,16 @@ function ProjectHeaderRow({
|
||||
>
|
||||
{rowChildren}
|
||||
</PressHighlight>
|
||||
</ProjectHeaderDragHandle>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextMenu open={contextMenuOpen} onOpenChange={handleContextMenuOpenChange}>
|
||||
<ProjectHeaderDragHandle
|
||||
dragHandleProps={dragHandleProps}
|
||||
<View
|
||||
{...dragAttributes}
|
||||
{...dragHandleProps?.listeners}
|
||||
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
|
||||
onPointerEnter={handlePointerEnter}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
>
|
||||
@@ -1022,7 +1041,7 @@ function ProjectHeaderRow({
|
||||
>
|
||||
{rowChildren}
|
||||
</ContextMenuTrigger>
|
||||
</ProjectHeaderDragHandle>
|
||||
</View>
|
||||
<ContextMenuContent
|
||||
align="start"
|
||||
width={220}
|
||||
@@ -1041,38 +1060,6 @@ 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,
|
||||
@@ -1082,10 +1069,12 @@ function WorkspaceRowInner({
|
||||
shortcutNumber,
|
||||
showShortcutBadge,
|
||||
onPress,
|
||||
drag,
|
||||
isDragging,
|
||||
isArchiving,
|
||||
isCreating = false,
|
||||
dragHandleProps,
|
||||
menuController,
|
||||
archiveLabel,
|
||||
archiveStatus = "idle",
|
||||
archivePendingLabel,
|
||||
@@ -1104,7 +1093,10 @@ function WorkspaceRowInner({
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
const isTouchPlatform = platformIsNative || isCompact;
|
||||
const interaction = useRowPressGuard();
|
||||
const interaction = useLongPressDragInteraction({
|
||||
drag,
|
||||
menuController,
|
||||
});
|
||||
const {
|
||||
role: _dragRole,
|
||||
tabIndex: _dragTabIndex,
|
||||
@@ -1113,11 +1105,12 @@ function WorkspaceRowInner({
|
||||
} = dragHandleProps?.attributes ?? {};
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
if (interaction.consumeMovedPress()) {
|
||||
if (interaction.didLongPressRef.current) {
|
||||
interaction.didLongPressRef.current = false;
|
||||
return;
|
||||
}
|
||||
onPress();
|
||||
}, [interaction, onPress]);
|
||||
}, [interaction.didLongPressRef, onPress]);
|
||||
const handleWorkspacePressIn = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
setIsPressed(true);
|
||||
@@ -1150,87 +1143,85 @@ function WorkspaceRowInner({
|
||||
});
|
||||
const backdrop = getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered });
|
||||
return (
|
||||
<DraggableListHandle dragHandleProps={isEditingTitle ? undefined : dragHandleProps}>
|
||||
<View
|
||||
{...dragAttributes}
|
||||
{...(isEditingTitle ? null : dragHandleProps?.listeners)}
|
||||
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
|
||||
style={styles.workspaceRowContainer}
|
||||
{...hoverHandlers}
|
||||
<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}`}
|
||||
>
|
||||
<SidebarWorkspaceContextMenu
|
||||
contextMenuOpen={contextMenuOpen}
|
||||
onContextMenuOpenChange={onContextMenuOpenChange}
|
||||
<SidebarWorkspaceRowContent
|
||||
workspace={workspace}
|
||||
hostBadge={hostBadge}
|
||||
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}`}
|
||||
leadingProjectIconDataUri={leadingProjectIconDataUri}
|
||||
serviceSummary={serviceSummary}
|
||||
backdrop={backdrop}
|
||||
isHovered={isHovered}
|
||||
isLoading={isArchiving || isCreating}
|
||||
isCreating={isCreating}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
|
||||
titleEditor={titleEditor}
|
||||
>
|
||||
<SidebarWorkspaceRowContent
|
||||
<WorkspaceRowRightGroup
|
||||
workspace={workspace}
|
||||
hostBadge={hostBadge}
|
||||
leadingProjectName={leadingProjectName}
|
||||
leadingProjectIconDataUri={leadingProjectIconDataUri}
|
||||
serviceSummary={serviceSummary}
|
||||
backdrop={backdrop}
|
||||
isHovered={isHovered}
|
||||
isLoading={isArchiving || isCreating}
|
||||
isTouchPlatform={isTouchPlatform}
|
||||
isCreating={isCreating}
|
||||
shortcutNumber={shortcutNumber}
|
||||
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>
|
||||
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>
|
||||
);
|
||||
}}
|
||||
</SidebarWorkspaceRowFrame>
|
||||
@@ -1246,6 +1237,7 @@ function WorkspaceRowWithMenu({
|
||||
shortcutNumber,
|
||||
showShortcutBadge,
|
||||
onPress,
|
||||
drag,
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
canCopyBranchName,
|
||||
@@ -1262,6 +1254,7 @@ function WorkspaceRowWithMenu({
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
onPress: () => void;
|
||||
drag?: () => void;
|
||||
isDragging: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
canCopyBranchName: boolean;
|
||||
@@ -1354,10 +1347,12 @@ 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")}
|
||||
@@ -1394,6 +1389,7 @@ interface WorkspaceRowItemProps {
|
||||
selectionEnabled: boolean;
|
||||
activeWorkspaceSelection: ActiveWorkspaceSelection | null;
|
||||
onWorkspacePress?: () => void;
|
||||
drag?: () => void;
|
||||
isDragging?: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
}
|
||||
@@ -1413,6 +1409,7 @@ function WorkspaceRowItem({
|
||||
isCreating = false,
|
||||
selectionEnabled,
|
||||
activeWorkspaceSelection,
|
||||
drag,
|
||||
isDragging = false,
|
||||
dragHandleProps,
|
||||
}: WorkspaceRowItemProps) {
|
||||
@@ -1443,6 +1440,7 @@ function WorkspaceRowItem({
|
||||
enabled: selectionEnabled,
|
||||
})}
|
||||
onPress={handlePress}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
@@ -1479,6 +1477,7 @@ 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
|
||||
@@ -1495,6 +1494,7 @@ function WorkspaceRow({
|
||||
shortcutNumber,
|
||||
showShortcutBadge,
|
||||
onPress,
|
||||
drag,
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
canCopyBranchName,
|
||||
@@ -1511,6 +1511,7 @@ function WorkspaceRow({
|
||||
shortcutNumber: number | null;
|
||||
showShortcutBadge: boolean;
|
||||
onPress: () => void;
|
||||
drag?: () => void;
|
||||
isDragging: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
canCopyBranchName: boolean;
|
||||
@@ -1534,6 +1535,7 @@ function WorkspaceRow({
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
onPress={onPress}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
canCopyBranchName={canCopyBranchName}
|
||||
@@ -1556,9 +1558,13 @@ function ProjectBlock({
|
||||
shortcutIndexByWorkspaceKey,
|
||||
onToggleCollapsed,
|
||||
onWorkspacePress,
|
||||
parentGestureRef,
|
||||
onWorkspaceReorder,
|
||||
useNestable,
|
||||
dragGestureHostActive,
|
||||
manualSort,
|
||||
onWorktreeCreated,
|
||||
drag,
|
||||
isDragging,
|
||||
dragHandleProps,
|
||||
creatingWorkspaceIds,
|
||||
@@ -1578,9 +1584,13 @@ 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>;
|
||||
@@ -1636,6 +1646,7 @@ function ProjectBlock({
|
||||
(
|
||||
item: SidebarWorkspacePlacement,
|
||||
input?: {
|
||||
drag?: () => void;
|
||||
isDragging?: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
},
|
||||
@@ -1655,6 +1666,7 @@ function ProjectBlock({
|
||||
selectionEnabled={selectionEnabled}
|
||||
activeWorkspaceSelection={activeWorkspaceSelection}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
drag={input?.drag}
|
||||
isDragging={input?.isDragging}
|
||||
dragHandleProps={input?.dragHandleProps}
|
||||
/>
|
||||
@@ -1678,10 +1690,12 @@ function ProjectBlock({
|
||||
const renderWorkspace = useCallback(
|
||||
({
|
||||
item,
|
||||
drag: workspaceDrag,
|
||||
isActive,
|
||||
dragHandleProps: workspaceDragHandleProps,
|
||||
}: DraggableRenderItemInfo<SidebarWorkspacePlacement>) => {
|
||||
return renderWorkspaceRow(item, {
|
||||
drag: workspaceDrag,
|
||||
isDragging: isActive,
|
||||
dragHandleProps: workspaceDragHandleProps,
|
||||
});
|
||||
@@ -1771,7 +1785,12 @@ function ProjectBlock({
|
||||
keyExtractor={workspaceKeyExtractor}
|
||||
renderItem={renderWorkspace}
|
||||
onDragEnd={handleWorkspaceDragEnd}
|
||||
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
|
||||
scrollEnabled={false}
|
||||
useDragHandle
|
||||
nestable={useNestable}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
gestureHostPresented={dragGestureHostActive}
|
||||
containerStyle={styles.workspaceListContainer}
|
||||
/>
|
||||
</>
|
||||
@@ -1825,8 +1844,10 @@ function ProjectBlock({
|
||||
isProjectActive={active}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
onWorktreeCreated={onWorktreeCreated}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
isArchiving={isRemovingProject}
|
||||
menuController={null}
|
||||
onRemoveProject={handleRemoveProject}
|
||||
removeProjectStatus={isRemovingProject ? "pending" : "idle"}
|
||||
dragHandleProps={dragHandleProps}
|
||||
@@ -1856,9 +1877,13 @@ 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 &&
|
||||
@@ -1940,6 +1965,7 @@ export function SidebarWorkspaceList({
|
||||
listFooterComponent,
|
||||
listHeaderComponent,
|
||||
parentGestureRef,
|
||||
dragGestureHostActive,
|
||||
}: SidebarWorkspaceListProps) {
|
||||
const pathname = usePathname();
|
||||
const hosts = useHosts();
|
||||
@@ -2011,6 +2037,8 @@ export function SidebarWorkspaceList({
|
||||
onPinnedWorkspaceReorder={handlePinnedWorkspaceReorder}
|
||||
listHeaderComponent={listHeaderComponent}
|
||||
sidebarFilterEmpty={sidebarFilterEmpty}
|
||||
parentGestureRef={parentGestureRef}
|
||||
dragGestureHostActive={dragGestureHostActive}
|
||||
/>
|
||||
) : (
|
||||
<ProjectModeList
|
||||
@@ -2029,6 +2057,7 @@ export function SidebarWorkspaceList({
|
||||
sidebarFilterEmpty={sidebarFilterEmpty}
|
||||
hasActiveProjectFilter={hasActiveProjectFilter}
|
||||
parentGestureRef={parentGestureRef}
|
||||
dragGestureHostActive={dragGestureHostActive}
|
||||
pathname={pathname}
|
||||
hostBadgeByServerId={hostBadgeByServerId}
|
||||
supportsMultiplicityByServerId={supportsMultiplicityByServerId}
|
||||
@@ -2060,6 +2089,8 @@ function SidebarGroupedModeList({
|
||||
onPinnedWorkspaceReorder,
|
||||
listHeaderComponent,
|
||||
sidebarFilterEmpty,
|
||||
parentGestureRef,
|
||||
dragGestureHostActive,
|
||||
}: {
|
||||
workspaceGroups: SidebarWorkspaceGroup[];
|
||||
pinnedGroups: PinnedSidebarGroups;
|
||||
@@ -2073,6 +2104,8 @@ function SidebarGroupedModeList({
|
||||
onPinnedWorkspaceReorder: (workspaces: SidebarWorkspacePlacement[]) => void;
|
||||
listHeaderComponent?: ReactElement | null;
|
||||
sidebarFilterEmpty: boolean;
|
||||
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||
dragGestureHostActive?: boolean;
|
||||
}) {
|
||||
const showShortcutBadges = useShowShortcutBadges();
|
||||
const pinnedWorkspaces = useMemo(
|
||||
@@ -2098,6 +2131,8 @@ function SidebarGroupedModeList({
|
||||
onPinnedWorkspaceReorder={onPinnedWorkspaceReorder}
|
||||
listHeaderComponent={listHeaderComponent}
|
||||
sidebarFilterEmpty={sidebarFilterEmpty}
|
||||
parentGestureRef={parentGestureRef}
|
||||
dragGestureHostActive={dragGestureHostActive}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2118,6 +2153,7 @@ function ProjectModeList({
|
||||
sidebarFilterEmpty,
|
||||
hasActiveProjectFilter,
|
||||
parentGestureRef,
|
||||
dragGestureHostActive,
|
||||
pathname,
|
||||
hostBadgeByServerId,
|
||||
supportsMultiplicityByServerId,
|
||||
@@ -2178,6 +2214,19 @@ 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;
|
||||
@@ -2302,6 +2351,7 @@ function ProjectModeList({
|
||||
(
|
||||
item: SidebarProjectEntry,
|
||||
dragState: {
|
||||
drag: () => void;
|
||||
isDragging: boolean;
|
||||
dragHandleProps?: DraggableRenderItemInfo<SidebarProjectEntry>["dragHandleProps"];
|
||||
},
|
||||
@@ -2320,8 +2370,12 @@ 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}
|
||||
@@ -2338,6 +2392,8 @@ function ProjectModeList({
|
||||
activeWorkspaceSelection,
|
||||
handleWorktreeCreated,
|
||||
manualSort,
|
||||
parentGestureRef,
|
||||
dragGestureHostActive,
|
||||
handleWorkspaceReorder,
|
||||
hostBadgeByServerId,
|
||||
supportsMultiplicityByServerId,
|
||||
@@ -2355,8 +2411,8 @@ function ProjectModeList({
|
||||
);
|
||||
|
||||
const renderProject = useCallback(
|
||||
({ item, isActive, dragHandleProps }: DraggableRenderItemInfo<SidebarProjectEntry>) =>
|
||||
renderProjectBlock(item, { isDragging: isActive, dragHandleProps }),
|
||||
({ item, drag, isActive, dragHandleProps }: DraggableRenderItemInfo<SidebarProjectEntry>) =>
|
||||
renderProjectBlock(item, { drag, isDragging: isActive, dragHandleProps }),
|
||||
[renderProjectBlock],
|
||||
);
|
||||
|
||||
@@ -2364,6 +2420,7 @@ function ProjectModeList({
|
||||
(
|
||||
workspace: SidebarWorkspacePlacement,
|
||||
input?: {
|
||||
drag?: () => void;
|
||||
isDragging?: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
},
|
||||
@@ -2386,6 +2443,7 @@ function ProjectModeList({
|
||||
selectionEnabled={selectionEnabled}
|
||||
activeWorkspaceSelection={activeWorkspaceSelection}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
drag={input?.drag}
|
||||
isDragging={input?.isDragging}
|
||||
dragHandleProps={input?.dragHandleProps}
|
||||
/>
|
||||
@@ -2408,10 +2466,12 @@ function ProjectModeList({
|
||||
const renderPinnedChat = useCallback(
|
||||
({
|
||||
item: workspace,
|
||||
drag,
|
||||
isActive,
|
||||
dragHandleProps,
|
||||
}: DraggableRenderItemInfo<SidebarWorkspacePlacement>) => {
|
||||
return renderPinnedChatRow(workspace, {
|
||||
drag,
|
||||
isDragging: isActive,
|
||||
dragHandleProps,
|
||||
});
|
||||
@@ -2429,7 +2489,12 @@ function ProjectModeList({
|
||||
keyExtractor={projectViewKeyExtractor}
|
||||
renderItem={renderProject}
|
||||
onDragEnd={handleProjectDragEnd}
|
||||
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
|
||||
scrollEnabled={false}
|
||||
useDragHandle
|
||||
nestable={platformIsNative}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
gestureHostPresented={dragGestureHostActive}
|
||||
containerStyle={styles.projectListContainer}
|
||||
/>
|
||||
);
|
||||
@@ -2453,7 +2518,12 @@ function ProjectModeList({
|
||||
keyExtractor={workspaceKeyExtractor}
|
||||
renderItem={renderPinnedChat}
|
||||
onDragEnd={onPinnedWorkspaceReorder}
|
||||
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
|
||||
scrollEnabled={false}
|
||||
useDragHandle
|
||||
nestable={platformIsNative}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
gestureHostPresented={dragGestureHostActive}
|
||||
containerStyle={styles.workspaceListContainer}
|
||||
/>
|
||||
{canTogglePinnedChats ? (
|
||||
@@ -2486,17 +2556,26 @@ function ProjectModeList({
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* 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>
|
||||
{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>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
import { PressHighlight as Pressable } from "@/components/ui/press-highlight";
|
||||
import { memo, useCallback, useMemo, useState, type ReactNode, type Ref } from "react";
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
type MutableRefObject,
|
||||
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";
|
||||
@@ -66,14 +77,9 @@ 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,
|
||||
DraggableListHandle,
|
||||
DraggableListScrollView,
|
||||
type DraggableRenderItemInfo,
|
||||
} from "@/components/draggable-list";
|
||||
import { DraggableList, type DraggableRenderItemInfo } from "@/components/draggable-list";
|
||||
import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
|
||||
import { useRowPressGuard } from "@/components/sidebar/use-row-press-guard";
|
||||
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
|
||||
|
||||
// Themed icon wrappers
|
||||
const mutedGroupIconProps = densityIconProps("sm", (theme) => ({
|
||||
@@ -120,6 +126,8 @@ 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({
|
||||
@@ -135,6 +143,8 @@ export function SidebarStatusWorkspaceList({
|
||||
onPinnedWorkspaceReorder,
|
||||
listHeaderComponent,
|
||||
sidebarFilterEmpty = false,
|
||||
parentGestureRef,
|
||||
dragGestureHostActive,
|
||||
}: StatusWorkspaceListProps) {
|
||||
const { t } = useTranslation();
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
@@ -160,6 +170,7 @@ export function SidebarStatusWorkspaceList({
|
||||
(
|
||||
workspace: SidebarWorkspaceEntry,
|
||||
input?: {
|
||||
drag?: () => void;
|
||||
isDragging?: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
},
|
||||
@@ -178,6 +189,7 @@ export function SidebarStatusWorkspaceList({
|
||||
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
onWorkspacePress={onWorkspacePress}
|
||||
drag={input?.drag}
|
||||
isDragging={input?.isDragging}
|
||||
dragHandleProps={input?.dragHandleProps}
|
||||
/>
|
||||
@@ -195,10 +207,12 @@ export function SidebarStatusWorkspaceList({
|
||||
const renderPinnedWorkspace = useCallback(
|
||||
({
|
||||
item: workspace,
|
||||
drag,
|
||||
isActive,
|
||||
dragHandleProps,
|
||||
}: DraggableRenderItemInfo<SidebarWorkspaceEntry>) =>
|
||||
renderPinnedWorkspaceRow(workspace, {
|
||||
drag,
|
||||
isDragging: isActive,
|
||||
dragHandleProps,
|
||||
}),
|
||||
@@ -223,7 +237,11 @@ export function SidebarStatusWorkspaceList({
|
||||
keyExtractor={statusWorkspaceKeyExtractor}
|
||||
renderItem={renderPinnedWorkspace}
|
||||
onDragEnd={onPinnedWorkspaceReorder}
|
||||
scrollEnabled={false}
|
||||
useDragHandle
|
||||
nestable={platformIsNative}
|
||||
simultaneousGestureRef={parentGestureRef}
|
||||
gestureHostPresented={dragGestureHostActive}
|
||||
/>
|
||||
{canTogglePinnedWorkspaces ? (
|
||||
<SidebarGroupToggleRow
|
||||
@@ -257,14 +275,25 @@ export function SidebarStatusWorkspaceList({
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<DraggableListScrollView
|
||||
style={styles.list}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
testID="sidebar-status-list-scroll"
|
||||
>
|
||||
{content}
|
||||
</DraggableListScrollView>
|
||||
{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>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -514,6 +543,7 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
||||
onToggleWorkspacePin,
|
||||
reserveIdleStatusIndicatorSpace = true,
|
||||
inStatusGroup = true,
|
||||
drag,
|
||||
isDragging = false,
|
||||
dragHandleProps,
|
||||
}: {
|
||||
@@ -532,6 +562,7 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
||||
*/
|
||||
inStatusGroup?: boolean;
|
||||
onWorkspacePress?: () => void;
|
||||
drag?: () => void;
|
||||
isDragging?: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
}) {
|
||||
@@ -559,6 +590,7 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
||||
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
|
||||
inStatusGroup={inStatusGroup}
|
||||
onPress={handlePress}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
@@ -578,6 +610,7 @@ function StatusWorkspaceRowWithMenu({
|
||||
reserveIdleStatusIndicatorSpace = true,
|
||||
inStatusGroup = true,
|
||||
onPress,
|
||||
drag,
|
||||
isDragging = false,
|
||||
dragHandleProps,
|
||||
}: {
|
||||
@@ -597,6 +630,7 @@ function StatusWorkspaceRowWithMenu({
|
||||
*/
|
||||
inStatusGroup?: boolean;
|
||||
onPress: () => void;
|
||||
drag?: () => void;
|
||||
isDragging?: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
}) {
|
||||
@@ -701,6 +735,7 @@ function StatusWorkspaceRowWithMenu({
|
||||
onTogglePin={onTogglePin}
|
||||
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
|
||||
inStatusGroup={inStatusGroup}
|
||||
drag={drag}
|
||||
isDragging={isDragging}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
@@ -736,20 +771,26 @@ 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.dragHandleProps) {
|
||||
return <DraggableStatusWorkspaceRowInner {...props} />;
|
||||
if (props.drag) {
|
||||
return <DraggableStatusWorkspaceRowInner {...props} drag={props.drag} />;
|
||||
}
|
||||
return <StatusWorkspaceRowInnerContent {...props} />;
|
||||
}
|
||||
|
||||
function DraggableStatusWorkspaceRowInner(props: StatusWorkspaceRowInnerProps) {
|
||||
const pressGuard = useRowPressGuard();
|
||||
return <StatusWorkspaceRowInnerContent {...props} pressGuard={pressGuard} />;
|
||||
function DraggableStatusWorkspaceRowInner(
|
||||
props: StatusWorkspaceRowInnerProps & { drag: () => void },
|
||||
) {
|
||||
const dragInteraction = useLongPressDragInteraction({
|
||||
drag: props.drag,
|
||||
menuController: null,
|
||||
});
|
||||
return <StatusWorkspaceRowInnerContent {...props} dragInteraction={dragInteraction} />;
|
||||
}
|
||||
|
||||
function StatusWorkspaceRowInnerContent({
|
||||
@@ -779,9 +820,9 @@ function StatusWorkspaceRowInnerContent({
|
||||
inStatusGroup = true,
|
||||
isDragging = false,
|
||||
dragHandleProps,
|
||||
pressGuard,
|
||||
dragInteraction,
|
||||
}: StatusWorkspaceRowInnerProps & {
|
||||
pressGuard?: ReturnType<typeof useRowPressGuard>;
|
||||
dragInteraction?: ReturnType<typeof useLongPressDragInteraction>;
|
||||
}) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const isTouchPlatform = platformIsNative || isCompact;
|
||||
@@ -799,16 +840,17 @@ function StatusWorkspaceRowInnerContent({
|
||||
|
||||
const accessibilityState = useMemo(() => ({ selected }), [selected]);
|
||||
const isEditingTitle = titleEditor !== null;
|
||||
const consumeMovedPress = pressGuard?.consumeMovedPress;
|
||||
const startDragPress = pressGuard?.handlePressIn;
|
||||
const moveDragPress = pressGuard?.handleTouchMove;
|
||||
const endDragPress = pressGuard?.handlePressOut;
|
||||
const didLongPressRef = dragInteraction?.didLongPressRef;
|
||||
const startDragPress = dragInteraction?.handlePressIn;
|
||||
const moveDragPress = dragInteraction?.handleTouchMove;
|
||||
const endDragPress = dragInteraction?.handlePressOut;
|
||||
const handlePress = useCallback(() => {
|
||||
if (consumeMovedPress?.()) {
|
||||
if (didLongPressRef?.current) {
|
||||
didLongPressRef.current = false;
|
||||
return;
|
||||
}
|
||||
onPress();
|
||||
}, [consumeMovedPress, onPress]);
|
||||
}, [didLongPressRef, onPress]);
|
||||
const handlePressIn = useCallback(
|
||||
(event: GestureResponderEvent) => {
|
||||
setIsPressed(true);
|
||||
@@ -853,89 +895,87 @@ function StatusWorkspaceRowInnerContent({
|
||||
});
|
||||
const backdrop = getSidebarRowBackdrop({ isDragging, isPressed, selected, isHovered });
|
||||
return (
|
||||
<DraggableListHandle dragHandleProps={isEditingTitle ? undefined : dragHandleProps}>
|
||||
<View
|
||||
{...dragAttributes}
|
||||
{...(isEditingTitle ? null : dragHandleProps?.listeners)}
|
||||
ref={dragHandleProps?.setActivatorNodeRef as unknown as Ref<View>}
|
||||
style={styles.workspaceRowContainer}
|
||||
{...hoverHandlers}
|
||||
<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}`}
|
||||
>
|
||||
<SidebarWorkspaceContextMenu
|
||||
contextMenuOpen={contextMenuOpen}
|
||||
onContextMenuOpenChange={onContextMenuOpenChange}
|
||||
<SidebarWorkspaceRowContent
|
||||
workspace={workspace}
|
||||
hostBadge={hostBadge}
|
||||
leadingProjectName={projectName}
|
||||
hostBadgeLabel={hostBadge?.label}
|
||||
leadingProjectIconDataUri={projectIconDataUri}
|
||||
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}`}
|
||||
backdrop={backdrop}
|
||||
isHovered={isHovered}
|
||||
isLoading={isArchiving}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
|
||||
titleEditor={titleEditor}
|
||||
>
|
||||
<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>
|
||||
{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>
|
||||
);
|
||||
}}
|
||||
</SidebarWorkspaceRowFrame>
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
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,6 +40,7 @@ function SortableInlineListItem<T>({
|
||||
const info: DraggableRenderItemInfo<T> = {
|
||||
item,
|
||||
index,
|
||||
drag: () => {},
|
||||
isActive: false,
|
||||
};
|
||||
return renderItem(info);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, type ReactElement } from "react";
|
||||
import { useCallback, useMemo, type ReactElement } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
@@ -61,6 +61,11 @@ 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
|
||||
@@ -84,6 +89,7 @@ function SortableItem<T>({
|
||||
const info: DraggableRenderItemInfo<T> = {
|
||||
item,
|
||||
index,
|
||||
drag,
|
||||
isActive: activeId === id,
|
||||
dragHandleProps: useDragHandle
|
||||
? {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Pressable, ScrollView, Text, View, type GestureResponderEvent } from "react-native";
|
||||
import { Pressable, Text, View, type GestureResponderEvent } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, ChevronRight, MessageSquarePlus } from "lucide-react-native";
|
||||
@@ -18,12 +18,6 @@ import { useCheckGroupState } from "./check-group-state";
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
const ThemedChevronRight = withUnistyles(ChevronRight);
|
||||
|
||||
/**
|
||||
* Roughly eight rows. A repo with thirty checks would otherwise push the activity
|
||||
* timeline off the pane entirely, so past this height the list scrolls in place.
|
||||
*/
|
||||
const LIST_MAX_HEIGHT = 268;
|
||||
|
||||
/**
|
||||
* The three statuses the pane has always labelled for tests. Skipped has no id because
|
||||
* nothing asserts on it.
|
||||
@@ -112,11 +106,7 @@ export function ChecksSection({
|
||||
</Pressable>
|
||||
|
||||
{open && summary.groups.length > 0 ? (
|
||||
<ScrollView
|
||||
style={styles.list}
|
||||
contentContainerStyle={styles.listContent}
|
||||
nestedScrollEnabled
|
||||
>
|
||||
<View style={styles.list}>
|
||||
{summary.groups.map((group) => (
|
||||
<CheckGroup
|
||||
key={group.status}
|
||||
@@ -128,7 +118,7 @@ export function ChecksSection({
|
||||
onAddLogsToChat={onAddLogsToChat}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
@@ -263,9 +253,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
list: {
|
||||
maxHeight: LIST_MAX_HEIGHT,
|
||||
},
|
||||
listContent: {
|
||||
paddingBottom: theme.spacing[2],
|
||||
},
|
||||
groupHeader: {
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { within } from "@testing-library/dom";
|
||||
import { SafeAreaInsetsContext } from "react-native-safe-area-context";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { ToastApiProvider } from "@/contexts/toast-context";
|
||||
import type { ToastApi } from "@/components/toast-host";
|
||||
import type { PrPaneCheck, PrPaneData } from "./data";
|
||||
import { PullRequestPane } from "./pane";
|
||||
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
|
||||
// App sources compile against the classic JSX runtime, which expects React on the global.
|
||||
beforeEach(() => vi.stubGlobal("React", React));
|
||||
|
||||
const PANE_HEIGHT = 600;
|
||||
|
||||
const silentToasts: ToastApi = {
|
||||
show: () => () => {},
|
||||
copied: () => {},
|
||||
error: () => {},
|
||||
};
|
||||
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
container?.remove();
|
||||
root = null;
|
||||
container = null;
|
||||
});
|
||||
|
||||
function successfulChecks(count: number): PrPaneCheck[] {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
provider: "github",
|
||||
name: `check-${index + 1}`,
|
||||
status: "success",
|
||||
url: `https://example.test/checks/${index + 1}`,
|
||||
}));
|
||||
}
|
||||
|
||||
function pullRequest(checks: PrPaneCheck[]): PrPaneData {
|
||||
return {
|
||||
provider: { id: "github", label: "GitHub" },
|
||||
forge: "github",
|
||||
number: 42,
|
||||
repoOwner: "acme",
|
||||
repoName: "widgets",
|
||||
title: "Ship the widget",
|
||||
state: "open",
|
||||
url: "https://example.test/pull/42",
|
||||
reviewDecision: "pending",
|
||||
awaitingReviewers: [],
|
||||
checks,
|
||||
activity: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** A phone's home indicator: the pane clears it with bottom padding. */
|
||||
const HOME_INDICATOR_INSETS = { top: 0, right: 0, bottom: 34, left: 0 };
|
||||
|
||||
const NO_INSETS = { top: 0, right: 0, bottom: 0, left: 0 };
|
||||
|
||||
function renderPane(data: PrPaneData, insets = NO_INSETS) {
|
||||
act(() =>
|
||||
root?.render(
|
||||
<SafeAreaInsetsContext.Provider value={insets}>
|
||||
<ToastApiProvider api={silentToasts}>
|
||||
<PullRequestPane serverId="server-1" cwd="/repo" data={data} activityLoading={false} />
|
||||
</ToastApiProvider>
|
||||
</SafeAreaInsetsContext.Provider>,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function mountPane(data: PrPaneData, insets = NO_INSETS) {
|
||||
container = document.createElement("div");
|
||||
container.style.height = `${PANE_HEIGHT}px`;
|
||||
container.style.width = "600px";
|
||||
container.style.display = "flex";
|
||||
container.style.flexDirection = "column";
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
renderPane(data, insets);
|
||||
const pane = container.querySelector<HTMLElement>('[data-testid="pr-pane"]');
|
||||
if (!pane) throw new Error("The pull request pane did not render");
|
||||
return pane;
|
||||
}
|
||||
|
||||
/** The pressable row holding the Activity title. */
|
||||
function activityHeader(pane: HTMLElement): HTMLElement {
|
||||
const title = within(pane).getByText(i18n.t("workspace.git.pr.sections.activity"));
|
||||
const header = title.closest<HTMLElement>("[tabindex]");
|
||||
if (!header) throw new Error("The Activity title is not inside a pressable header");
|
||||
return header;
|
||||
}
|
||||
|
||||
function checksRegionBottom(pane: HTMLElement): number {
|
||||
return within(pane).getByRole("separator").getBoundingClientRect().top;
|
||||
}
|
||||
|
||||
/** Whether a user sees the element: no scroll container clips it out of view. */
|
||||
function isOnScreen(element: HTMLElement): boolean {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const hit = document.elementFromPoint(rect.left + 8, rect.top + rect.height / 2);
|
||||
return hit !== null && element.contains(hit);
|
||||
}
|
||||
|
||||
/** React Native Web reports layout from a ResizeObserver, a frame or two after mounting. */
|
||||
async function settleLayout() {
|
||||
for (let frame = 0; frame < 2; frame++) {
|
||||
await act(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())));
|
||||
}
|
||||
}
|
||||
|
||||
/** Drags the separator through each offset from where it starts, releasing at the last one. */
|
||||
function dragSeparator(pane: HTMLElement, ...deltasY: number[]) {
|
||||
const separator = within(pane).getByRole("separator");
|
||||
const rect = separator.getBoundingClientRect();
|
||||
const x = rect.left + rect.width / 2;
|
||||
const startY = rect.top + rect.height / 2;
|
||||
const pointer = { pointerId: 1, bubbles: true, cancelable: true, clientX: x, button: 0 };
|
||||
act(() => {
|
||||
separator.dispatchEvent(new PointerEvent("pointerdown", { ...pointer, clientY: startY }));
|
||||
});
|
||||
for (const deltaY of deltasY) {
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new PointerEvent("pointermove", { ...pointer, clientY: startY + deltaY }),
|
||||
);
|
||||
});
|
||||
}
|
||||
const endY = startY + (deltasY.at(-1) ?? 0);
|
||||
act(() => {
|
||||
window.dispatchEvent(new PointerEvent("pointerup", { ...pointer, clientY: endY }));
|
||||
});
|
||||
}
|
||||
|
||||
describe("pull request pane layout", () => {
|
||||
it("docks a collapsed Activity section to the bottom and gives the checks the pane", async () => {
|
||||
const pane = mountPane(pullRequest(successfulChecks(10)));
|
||||
|
||||
act(() => activityHeader(pane).click());
|
||||
|
||||
await expect
|
||||
.poll(() => Math.round(activityHeader(pane).getBoundingClientRect().bottom))
|
||||
.toBe(Math.round(pane.getBoundingClientRect().bottom));
|
||||
const rows = within(pane).getAllByTestId("pr-pane-check-row");
|
||||
expect(rows).toHaveLength(10);
|
||||
expect(rows.every(isOnScreen)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps keyboard focus on the Activity header while it collapses and expands", () => {
|
||||
const pane = mountPane(pullRequest(successfulChecks(3)));
|
||||
const header = activityHeader(pane);
|
||||
act(() => header.focus());
|
||||
|
||||
act(() => header.click());
|
||||
expect(document.activeElement).toBe(header);
|
||||
act(() => header.click());
|
||||
expect(document.activeElement).toBe(header);
|
||||
});
|
||||
|
||||
it("exposes whether Activity is expanded to assistive technology", () => {
|
||||
const pane = mountPane(pullRequest(successfulChecks(3)));
|
||||
expect(activityHeader(pane).getAttribute("aria-expanded")).toBe("true");
|
||||
|
||||
act(() => activityHeader(pane).click());
|
||||
|
||||
expect(activityHeader(pane).getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("keeps the Activity header inside the pane when a dragged pane gets short", async () => {
|
||||
const pane = mountPane(pullRequest(successfulChecks(30)));
|
||||
await settleLayout();
|
||||
dragSeparator(pane, PANE_HEIGHT);
|
||||
await settleLayout();
|
||||
|
||||
act(() => {
|
||||
if (container) container.style.height = "160px";
|
||||
});
|
||||
await settleLayout();
|
||||
|
||||
await expect
|
||||
.poll(() => activityHeader(pane).getBoundingClientRect().bottom)
|
||||
.toBeLessThanOrEqual(pane.getBoundingClientRect().bottom);
|
||||
expect(isOnScreen(activityHeader(pane))).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["5px down", [5]],
|
||||
["400px down", [400]],
|
||||
["up past their end and back down", [-20, 100]],
|
||||
])("gives the checks their earlier share when they grow after a drag %s", async (_name, path) => {
|
||||
const pane = mountPane(pullRequest(successfulChecks(2)));
|
||||
await settleLayout();
|
||||
const drawn = checksRegionBottom(pane);
|
||||
|
||||
dragSeparator(pane, ...path);
|
||||
await settleLayout();
|
||||
expect(checksRegionBottom(pane)).toBeCloseTo(drawn, 0);
|
||||
|
||||
renderPane(pullRequest(successfulChecks(30)));
|
||||
await settleLayout();
|
||||
const overview = within(pane).getByTestId("pr-pane-overview");
|
||||
expect(overview.getBoundingClientRect().height).toBeCloseTo(PANE_HEIGHT / 2, 0);
|
||||
});
|
||||
|
||||
it("resizes the checks against Activity by dragging the separator between them", async () => {
|
||||
const pane = mountPane(pullRequest(successfulChecks(30)));
|
||||
await settleLayout();
|
||||
const before = checksRegionBottom(pane);
|
||||
|
||||
dragSeparator(pane, 120);
|
||||
|
||||
await expect.poll(() => Math.round(checksRegionBottom(pane) - before)).toBe(120);
|
||||
expect(activityHeader(pane).getBoundingClientRect().top).toBeGreaterThan(before + 100);
|
||||
});
|
||||
|
||||
it("keeps the separator under the pointer when the pane clears a bottom inset", async () => {
|
||||
const pane = mountPane(pullRequest(successfulChecks(30)), HOME_INDICATOR_INSETS);
|
||||
await settleLayout();
|
||||
const before = checksRegionBottom(pane);
|
||||
|
||||
dragSeparator(pane, 120);
|
||||
|
||||
await expect.poll(() => Math.round(checksRegionBottom(pane) - before)).toBe(120);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { Image, Pressable, ScrollView, Text, View, type ViewStyle } from "react-native";
|
||||
import { Image, Pressable, Text, View, type ViewStyle } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
CircleCheck,
|
||||
@@ -74,13 +74,14 @@ import {
|
||||
type PrTimelineEntry,
|
||||
} from "./timeline";
|
||||
import {
|
||||
Section,
|
||||
SectionHeader,
|
||||
SummaryPill,
|
||||
dangerColorMapping,
|
||||
foregroundMutedColorMapping,
|
||||
sectionKitStyles,
|
||||
successColorMapping,
|
||||
} from "./section-kit";
|
||||
import { PullRequestPaneSplit } from "./split";
|
||||
|
||||
const ThemedCircleCheck = withUnistyles(CircleCheck);
|
||||
const ThemedCircleX = withUnistyles(CircleX);
|
||||
@@ -478,8 +479,8 @@ export function PullRequestPane({
|
||||
: null;
|
||||
|
||||
return (
|
||||
<View style={styles.root} testID="pr-pane">
|
||||
<ScrollView style={styles.scroll} showsVerticalScrollIndicator={false}>
|
||||
<PullRequestPaneSplit activityOpen={activityOpen}>
|
||||
<>
|
||||
<PaneContentToolbar style={styles.toolbar} testID="pr-pane-toolbar">
|
||||
<View style={styles.toolbarActions}>
|
||||
<Button
|
||||
@@ -499,7 +500,9 @@ export function PullRequestPane({
|
||||
accessibilityLabel={
|
||||
isRefreshing
|
||||
? t("workspace.git.diff.refreshing")
|
||||
: t("workspace.git.diff.refreshState", { brand: forgePresentation.brandLabel })
|
||||
: t("workspace.git.diff.refreshState", {
|
||||
brand: forgePresentation.brandLabel,
|
||||
})
|
||||
}
|
||||
testID="pr-pane-refresh"
|
||||
style={toolbarRefreshButtonStyle}
|
||||
@@ -564,60 +567,58 @@ export function PullRequestPane({
|
||||
onAddLogsToChat={handleAddCheckLogsToChat}
|
||||
/>
|
||||
)}
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<Section
|
||||
title={t("workspace.git.pr.sections.activity")}
|
||||
open={activityOpen}
|
||||
onToggle={handleToggleActivity}
|
||||
summary={
|
||||
<>
|
||||
<SummaryPill count={approvals} icon={SUMMARY_APPROVAL_ICON} variant="success" />
|
||||
<SummaryPill
|
||||
count={changesRequested}
|
||||
icon={SUMMARY_CHANGES_REQUESTED_ICON}
|
||||
variant="danger"
|
||||
</>
|
||||
<SectionHeader
|
||||
title={t("workspace.git.pr.sections.activity")}
|
||||
open={activityOpen}
|
||||
onToggle={handleToggleActivity}
|
||||
summary={
|
||||
<>
|
||||
<SummaryPill count={approvals} icon={SUMMARY_APPROVAL_ICON} variant="success" />
|
||||
<SummaryPill
|
||||
count={changesRequested}
|
||||
icon={SUMMARY_CHANGES_REQUESTED_ICON}
|
||||
variant="danger"
|
||||
/>
|
||||
<SummaryPill count={commentCount} icon={SUMMARY_COMMENT_ICON} variant="muted" />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<View style={sectionKitStyles.sectionBody}>
|
||||
{timelineEntries.length > 0 && attachEnabled && visibleEntries.length > 0 ? (
|
||||
<View style={styles.activityToolbar}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={MessageSquarePlus}
|
||||
onPress={handleAddAllToChat}
|
||||
disabled={activityLoading}
|
||||
>
|
||||
{t("workspace.git.pr.actions.addAllToChat")}
|
||||
</Button>
|
||||
</View>
|
||||
) : null}
|
||||
{activityLoading ? <PrActivitySkeleton /> : null}
|
||||
{!activityLoading && visibleEntries.length === 0 ? (
|
||||
<Text style={sectionKitStyles.emptyText}>{t("workspace.git.pr.empty.noActivity")}</Text>
|
||||
) : null}
|
||||
{!activityLoading
|
||||
? visibleEntries.map(({ entry, collapsed }) => (
|
||||
<TimelineEntryCard
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
collapsed={collapsed}
|
||||
collapsedEntryIds={collapsedEntryIds}
|
||||
attachEnabled={attachEnabled}
|
||||
brandLabel={forgePresentation.brandLabel}
|
||||
onAddToChat={handleAddActivityToChat}
|
||||
onAddThreadToChat={handleAddThreadToChat}
|
||||
onToggleCollapsed={handleToggleEntryCollapsed}
|
||||
/>
|
||||
<SummaryPill count={commentCount} icon={SUMMARY_COMMENT_ICON} variant="muted" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
{timelineEntries.length > 0 && attachEnabled && visibleEntries.length > 0 ? (
|
||||
<View style={styles.activityToolbar}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={MessageSquarePlus}
|
||||
onPress={handleAddAllToChat}
|
||||
disabled={activityLoading}
|
||||
>
|
||||
{t("workspace.git.pr.actions.addAllToChat")}
|
||||
</Button>
|
||||
</View>
|
||||
) : null}
|
||||
{activityLoading ? <PrActivitySkeleton /> : null}
|
||||
{!activityLoading && visibleEntries.length === 0 ? (
|
||||
<Text style={sectionKitStyles.emptyText}>{t("workspace.git.pr.empty.noActivity")}</Text>
|
||||
) : null}
|
||||
{!activityLoading
|
||||
? visibleEntries.map(({ entry, collapsed }) => (
|
||||
<TimelineEntryCard
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
collapsed={collapsed}
|
||||
collapsedEntryIds={collapsedEntryIds}
|
||||
attachEnabled={attachEnabled}
|
||||
brandLabel={forgePresentation.brandLabel}
|
||||
onAddToChat={handleAddActivityToChat}
|
||||
onAddThreadToChat={handleAddThreadToChat}
|
||||
onToggleCollapsed={handleToggleEntryCollapsed}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
</Section>
|
||||
</ScrollView>
|
||||
</View>
|
||||
))
|
||||
: null}
|
||||
</View>
|
||||
</PullRequestPaneSplit>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1192,15 +1193,6 @@ function ThreadComment({
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
},
|
||||
scroll: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
invisible: {
|
||||
opacity: 0,
|
||||
},
|
||||
@@ -1261,10 +1253,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
marginLeft: theme.spacing[1],
|
||||
opacity: 0,
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: theme.colors.border,
|
||||
},
|
||||
toolbar: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { type ReactNode } from "react";
|
||||
import React, { useMemo, type ReactNode } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { CountedCheckPresentation } from "@/git/check-presentation";
|
||||
import { CONTROL_HEIGHTS, mapLayout } from "@/components/ui/control-geometry";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import {
|
||||
CheckPresentationIcon,
|
||||
getCheckPresentationTone,
|
||||
@@ -21,39 +22,57 @@ export const successColorMapping = (theme: Theme) => ({ color: theme.colors.stat
|
||||
export const dangerColorMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
|
||||
export const warningColorMapping = (theme: Theme) => ({ color: theme.colors.statusWarning });
|
||||
|
||||
interface SectionProps {
|
||||
interface SectionHeaderProps {
|
||||
title: string;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
summary: ReactNode;
|
||||
children: ReactNode;
|
||||
accessibilityLabel?: string;
|
||||
}
|
||||
|
||||
export function Section({
|
||||
interface SectionProps extends SectionHeaderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** The disclosure row of a section, for layouts that place the section's body themselves. */
|
||||
export function SectionHeader({
|
||||
title,
|
||||
open,
|
||||
onToggle,
|
||||
summary,
|
||||
children,
|
||||
accessibilityLabel,
|
||||
}: SectionProps) {
|
||||
}: SectionHeaderProps) {
|
||||
const accessibilityState = useMemo(() => ({ expanded: open }), [open]);
|
||||
// React Native Web does not map `accessibilityState.expanded` to `aria-expanded`.
|
||||
const webExpandedProps = useMemo(
|
||||
() => (isWeb ? ({ "aria-expanded": open } as const) : null),
|
||||
[open],
|
||||
);
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={accessibilityLabel ?? title}
|
||||
accessibilityState={accessibilityState}
|
||||
{...webExpandedProps}
|
||||
style={sectionKitStyles.sectionHeader}
|
||||
onPress={onToggle}
|
||||
>
|
||||
{open ? (
|
||||
<ThemedChevronDown size={14} uniProps={foregroundMutedColorMapping} />
|
||||
) : (
|
||||
<ThemedChevronRight size={14} uniProps={foregroundMutedColorMapping} />
|
||||
)}
|
||||
<Text style={sectionKitStyles.sectionTitle}>{title}</Text>
|
||||
<View style={sectionKitStyles.summaryWrap}>{summary}</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export function Section({ children, ...header }: SectionProps) {
|
||||
return (
|
||||
<View>
|
||||
<Pressable
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
style={sectionKitStyles.sectionHeader}
|
||||
onPress={onToggle}
|
||||
>
|
||||
{open ? (
|
||||
<ThemedChevronDown size={14} uniProps={foregroundMutedColorMapping} />
|
||||
) : (
|
||||
<ThemedChevronRight size={14} uniProps={foregroundMutedColorMapping} />
|
||||
)}
|
||||
<Text style={sectionKitStyles.sectionTitle}>{title}</Text>
|
||||
<View style={sectionKitStyles.summaryWrap}>{summary}</View>
|
||||
</Pressable>
|
||||
{open ? <View style={sectionKitStyles.sectionBody}>{children}</View> : null}
|
||||
<SectionHeader {...header} />
|
||||
{header.open ? <View style={sectionKitStyles.sectionBody}>{children}</View> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { Children, useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import { ScrollView, View, type LayoutChangeEvent } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { ResizeHandle } from "@/components/resize-handle";
|
||||
|
||||
/** Most of the pane the overview may take before it scrolls, until the user drags the separator. */
|
||||
const DEFAULT_OVERVIEW_SHARE = 0.5;
|
||||
const SPLIT_GROUP_ID = "pr-pane";
|
||||
|
||||
interface PullRequestPaneSplitProps {
|
||||
/**
|
||||
* Exactly three children, in order:
|
||||
* 1. the overview (the PR's header and checks), which scrolls in its own region;
|
||||
* 2. the Activity disclosure row, docked to the bottom of the pane while collapsed;
|
||||
* 3. the Activity timeline, filling the space below the separator while open.
|
||||
*
|
||||
* Slots are children because `react-perf/jsx-no-jsx-as-prop` rejects JSX in a prop. Pass all
|
||||
* three unconditionally, the way `TreeRail` takes its slots.
|
||||
*/
|
||||
children: ReactNode;
|
||||
activityOpen: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lays the pane out the way stacked sidebar sections behave: the overview and Activity share the
|
||||
* height, a separator between them resizes the share, and a collapsed Activity gives its space
|
||||
* back by docking its header to the bottom.
|
||||
*/
|
||||
export function PullRequestPaneSplit({ children, activityOpen }: PullRequestPaneSplitProps) {
|
||||
const [overview, activityHeader, activityTimeline] = Children.toArray(children);
|
||||
const insets = useSafeAreaInsets();
|
||||
const [overviewShare, setOverviewShare] = useState(DEFAULT_OVERVIEW_SHARE);
|
||||
// The pointer's share while a drag is in progress. The saved share is decided once, when the
|
||||
// drag ends, so moves along the way cannot overwrite the share the overview had before it.
|
||||
const [dragShare, setDragShare] = useState<number | null>(null);
|
||||
const [splitHeight, setSplitHeight] = useState(0);
|
||||
const [overviewHeight, setOverviewHeight] = useState(0);
|
||||
const [overviewContentHeight, setOverviewContentHeight] = useState(0);
|
||||
|
||||
const handleSplitLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
setSplitHeight(event.nativeEvent.layout.height);
|
||||
}, []);
|
||||
const handleOverviewLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
setOverviewHeight(event.nativeEvent.layout.height);
|
||||
}, []);
|
||||
const handleOverviewContentSizeChange = useCallback((_width: number, height: number) => {
|
||||
setOverviewContentHeight(height);
|
||||
}, []);
|
||||
const handlePreviewResize = useCallback((_groupId: string, sizes: number[]) => {
|
||||
const [share] = sizes;
|
||||
if (share !== undefined) setDragShare(share);
|
||||
}, []);
|
||||
const handleResize = useCallback(
|
||||
(_groupId: string, sizes: number[]) => {
|
||||
setDragShare(null);
|
||||
const [share] = sizes;
|
||||
if (share === undefined || splitHeight <= 0) return;
|
||||
const contentShare = overviewContentHeight / splitHeight;
|
||||
setOverviewShare((current) => resolveDraggedShare(share, current, contentShare));
|
||||
},
|
||||
[overviewContentHeight, splitHeight],
|
||||
);
|
||||
|
||||
// The overview stops at its content height, so a drag starts from where the separator is
|
||||
// drawn rather than from the share the overview may grow to.
|
||||
const separatorSizes = useMemo(() => {
|
||||
const share = splitHeight > 0 ? overviewHeight / splitHeight : DEFAULT_OVERVIEW_SHARE;
|
||||
return [share, 1 - share];
|
||||
}, [overviewHeight, splitHeight]);
|
||||
// The Activity header docks to the bottom edge, so it clears the home indicator the way the
|
||||
// Changes pane's docked Commits section does. The split is measured inside that padding: the
|
||||
// overview's percentage height resolves against this box, so the drag must use the same one.
|
||||
const rootStyle = useMemo(() => [styles.root, { paddingBottom: insets.bottom }], [insets.bottom]);
|
||||
const drawnShare = dragShare ?? overviewShare;
|
||||
const overviewStyle = useMemo(
|
||||
() =>
|
||||
activityOpen
|
||||
? [styles.overviewShared, { maxHeight: `${drawnShare * 100}%` as const }]
|
||||
: styles.overviewFill,
|
||||
[activityOpen, drawnShare],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={rootStyle} testID="pr-pane">
|
||||
<View style={styles.split} onLayout={handleSplitLayout}>
|
||||
<ScrollView
|
||||
style={overviewStyle}
|
||||
onLayout={handleOverviewLayout}
|
||||
onContentSizeChange={handleOverviewContentSizeChange}
|
||||
showsVerticalScrollIndicator={false}
|
||||
testID="pr-pane-overview"
|
||||
>
|
||||
{overview}
|
||||
</ScrollView>
|
||||
{activityOpen ? (
|
||||
<ResizeHandle
|
||||
testID="pr-pane-split-handle"
|
||||
direction="vertical"
|
||||
groupId={SPLIT_GROUP_ID}
|
||||
index={0}
|
||||
sizes={separatorSizes}
|
||||
containerSize={splitHeight}
|
||||
onPreviewResizeSplit={handlePreviewResize}
|
||||
onResizeSplit={handleResize}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.divider} />
|
||||
)}
|
||||
{/* The header keeps one slot in both states, so toggling it does not remount the row
|
||||
and drop keyboard focus. Only the timeline mounts and unmounts. */}
|
||||
{activityHeader}
|
||||
{activityOpen ? (
|
||||
<ScrollView style={styles.activityScroll} showsVerticalScrollIndicator={false}>
|
||||
{activityTimeline}
|
||||
</ScrollView>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The share a drag leaves the overview. Short content stops the separator at the content's
|
||||
* bottom, so a drag that ends there or below saves what was drawn and keeps any larger share
|
||||
* the overview already had. The separator then neither jumps to the pointer nor stays pinned
|
||||
* at the old content height when the content grows.
|
||||
*/
|
||||
function resolveDraggedShare(pointerShare: number, currentShare: number, contentShare: number) {
|
||||
if (pointerShare < contentShare) return pointerShare;
|
||||
return Math.max(currentShare, contentShare);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
},
|
||||
split: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
overviewShared: {
|
||||
flexGrow: 0,
|
||||
flexShrink: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
overviewFill: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
// Same 1px line the separator draws, so collapsing Activity does not move the boundary's look.
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: theme.colors.border,
|
||||
},
|
||||
activityScroll: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
}));
|
||||
@@ -1,27 +1,110 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { didPressMove } from "./sidebar-gesture-arbitration";
|
||||
import {
|
||||
decideLongPressMove,
|
||||
shouldOpenContextMenuOnPressOut,
|
||||
} from "./sidebar-gesture-arbitration";
|
||||
|
||||
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);
|
||||
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");
|
||||
});
|
||||
|
||||
it("counts movement past the cancel slop", () => {
|
||||
expect(didPressMove({ startPoint: start, currentPoint: { x: 8, y: 8 } })).toBe(true);
|
||||
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 a clearly vertical move sooner, as a scroll", () => {
|
||||
expect(didPressMove({ startPoint: start, currentPoint: { x: 2, y: 7 } })).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("keeps a short diagonal move a tap", () => {
|
||||
expect(didPressMove({ startPoint: start, currentPoint: { x: 5, y: 7 } })).toBe(false);
|
||||
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("counts a clearly horizontal move sooner, as a swipe", () => {
|
||||
expect(didPressMove({ startPoint: start, currentPoint: { x: -10, y: 2 } })).toBe(true);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,25 +1,57 @@
|
||||
interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
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";
|
||||
}
|
||||
|
||||
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;
|
||||
export function shouldOpenContextMenuOnPressOut(input: {
|
||||
longPressArmed: boolean;
|
||||
didStartDrag: boolean;
|
||||
}): boolean {
|
||||
return input.longPressArmed && !input.didStartDrag;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createElement, type ElementType } from "react";
|
||||
import { lightHighlightColors } from "@getpaseo/highlight";
|
||||
import { lightTheme } from "../src/styles/theme";
|
||||
|
||||
const testTheme = {
|
||||
const pinnedTheme = {
|
||||
colorScheme: "light",
|
||||
colors: {
|
||||
syntax: lightHighlightColors,
|
||||
@@ -86,6 +87,14 @@ const testTheme = {
|
||||
},
|
||||
};
|
||||
|
||||
// Tokens a test does not pin come from the real light theme, so a style or `uniProps` mapping
|
||||
// that reads one (the markdown renderer reads `colors.terminal`) gets a value instead of crashing.
|
||||
const testTheme = {
|
||||
...lightTheme,
|
||||
...pinnedTheme,
|
||||
colors: { ...lightTheme.colors, ...pinnedTheme.colors },
|
||||
};
|
||||
|
||||
type StyleFactory<T> = (theme: typeof testTheme) => T;
|
||||
|
||||
function isStyleFactory<T>(styles: T | StyleFactory<T>): styles is StyleFactory<T> {
|
||||
@@ -142,7 +151,19 @@ export const StyleSheet = {
|
||||
resolveStyles(isStyleFactory(styles) ? styles(testTheme) : styles),
|
||||
};
|
||||
|
||||
export const withUnistyles = <T>(Component: T): T => Component;
|
||||
type UniPropsMapping = (theme: typeof testTheme, runtime: object) => Record<string, unknown>;
|
||||
|
||||
/** Applies `uniProps` with the test theme, the way Unistyles maps theme values onto props. */
|
||||
export const withUnistyles = <T>(Component: T): T => {
|
||||
function ThemedComponent({ uniProps, ...props }: { uniProps?: UniPropsMapping }) {
|
||||
// Explicit props win over mapped ones, as in Unistyles' `deepMergeObjects(mappings, uniProps, props)`.
|
||||
return createElement(Component as ElementType, {
|
||||
...(uniProps ? uniProps(testTheme, {}) : {}),
|
||||
...props,
|
||||
});
|
||||
}
|
||||
return ThemedComponent as T;
|
||||
};
|
||||
|
||||
export const useUnistyles = () => ({
|
||||
theme: testTheme,
|
||||
|
||||
@@ -113,6 +113,111 @@ test.each([
|
||||
}
|
||||
});
|
||||
|
||||
test("re-probes a v1 runtime on a forced refresh after OpenCode is updated to v2", async () => {
|
||||
const { mkdtemp, writeFile, rm } = await import("node:fs/promises");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { join } = await import("node:path");
|
||||
const { OpenCodeRuntimeClient } = await import("./runtime-client.js");
|
||||
const { createTestLogger } = await import("../../../../test-utils/test-logger.js");
|
||||
const root = await mkdtemp(join(tmpdir(), "opencode-version-upgrade-"));
|
||||
const script = join(root, "version.cjs");
|
||||
const client = new OpenCodeRuntimeClient(createTestLogger(), {
|
||||
command: { mode: "replace", argv: [process.execPath, script] },
|
||||
env: { XDG_STATE_HOME: root },
|
||||
});
|
||||
const controlLabel = async () =>
|
||||
(await client.listControls({ provider: "opencode", cwd: root })).controls[0]?.label;
|
||||
try {
|
||||
await writeFile(script, 'console.log("1.14.46")');
|
||||
expect(await controlLabel()).toBe("Auto Accept");
|
||||
await writeFile(script, 'console.log("opencode v2.0.10")');
|
||||
expect(await controlLabel()).toBe("Auto Accept");
|
||||
|
||||
// The fake binary cannot serve, so the refresh fails on the v2 startup path it switched to.
|
||||
await expect(client.fetchCatalog({ scope: "global", force: true })).rejects.toThrow(
|
||||
"OpenCode v2 server exited during startup",
|
||||
);
|
||||
|
||||
expect(await controlLabel()).toBe("Auto-accept");
|
||||
} finally {
|
||||
await client.shutdown();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function createCountingVersionBinary(prefix: string) {
|
||||
const { mkdtemp, writeFile, readFile, rm } = await import("node:fs/promises");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { join } = await import("node:path");
|
||||
const { OpenCodeRuntimeClient } = await import("./runtime-client.js");
|
||||
const { createTestLogger } = await import("../../../../test-utils/test-logger.js");
|
||||
const root = await mkdtemp(join(tmpdir(), prefix));
|
||||
const script = join(root, "version.cjs");
|
||||
const probes = join(root, "probes");
|
||||
await writeFile(probes, "");
|
||||
const client = new OpenCodeRuntimeClient(createTestLogger(), {
|
||||
command: { mode: "replace", argv: [process.execPath, script] },
|
||||
env: { XDG_STATE_HOME: root },
|
||||
});
|
||||
return {
|
||||
client,
|
||||
root,
|
||||
report: (version: string) =>
|
||||
writeFile(
|
||||
script,
|
||||
`if (process.argv.includes("--version")) require("node:fs").appendFileSync(${JSON.stringify(probes)}, "x"); console.log(${JSON.stringify(version)})`,
|
||||
),
|
||||
probeCount: async () => (await readFile(probes, "utf8")).length,
|
||||
controlLabel: async () =>
|
||||
(await client.listControls({ provider: "opencode", cwd: root })).controls[0]?.label,
|
||||
dispose: async () => {
|
||||
await client.shutdown();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("keeps a v2 runtime on a forced refresh without probing the binary again", async () => {
|
||||
const binary = await createCountingVersionBinary("opencode-v2-refresh-");
|
||||
try {
|
||||
await binary.report("opencode v2.0.10");
|
||||
expect(await binary.controlLabel()).toBe("Auto-accept");
|
||||
expect(await binary.probeCount()).toBe(1);
|
||||
|
||||
await expect(binary.client.fetchCatalog({ scope: "global", force: true })).rejects.toThrow(
|
||||
"OpenCode v2 server exited during startup",
|
||||
);
|
||||
|
||||
expect(await binary.probeCount()).toBe(1);
|
||||
expect(await binary.controlLabel()).toBe("Auto-accept");
|
||||
} finally {
|
||||
await binary.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test.each([0, 1, 2, 3, 4, 5, 6, 8])(
|
||||
"probes once when a second forced refresh starts %i microtasks after the first",
|
||||
async (ticks) => {
|
||||
const binary = await createCountingVersionBinary("opencode-concurrent-refresh-");
|
||||
try {
|
||||
await binary.report("1.14.46");
|
||||
expect(await binary.controlLabel()).toBe("Auto Accept");
|
||||
await binary.report("opencode v2.0.10");
|
||||
|
||||
const first = binary.client.fetchCatalog({ scope: "global", force: true });
|
||||
for (let tick = 0; tick < ticks; tick++) await Promise.resolve();
|
||||
const second = binary.client.fetchCatalog({ scope: "global", force: true });
|
||||
const results = await Promise.allSettled([first, second]);
|
||||
|
||||
expect(results.map((result) => result.status)).toEqual(["rejected", "rejected"]);
|
||||
expect(await binary.probeCount()).toBe(2);
|
||||
expect(await binary.controlLabel()).toBe("Auto-accept");
|
||||
} finally {
|
||||
await binary.dispose();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("appends a first notice after old history and records a changed major version", async () => {
|
||||
const { withOpenCodeRuntimeNotice } = await import("./runtime-notice.js");
|
||||
const { V2Harness } = await import("./test-utils/v2-harness.js");
|
||||
|
||||
@@ -155,8 +155,18 @@ export class OpenCodeRuntimeClient implements AgentClient {
|
||||
return client === this.legacy ? session : withOpenCodeRuntimeNotice(session, 2, handle);
|
||||
}
|
||||
async fetchCatalog(options: FetchCatalogOptions, context?: ProviderRefreshContext) {
|
||||
if (options.force) await this.reprobeLegacySelection();
|
||||
return (await this.client()).fetchCatalog(options, context);
|
||||
}
|
||||
// Updating the binary does not change provider config, so the client survives it. A forced
|
||||
// refresh re-probes a v1 pick so a 1.x to 2.x update takes effect without a daemon restart.
|
||||
// A v2 pick owns live sessions and stays.
|
||||
private async reprobeLegacySelection() {
|
||||
const current = this.selected;
|
||||
if (!current) return;
|
||||
const client = await current.catch(() => null);
|
||||
if (client === this.legacy && this.selected === current) this.selected = null;
|
||||
}
|
||||
async listCommands(config: AgentSessionConfig) {
|
||||
return (await this.client()).listCommands(config);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { STRUCTURED_OUTPUT_TOOL } from "./structured-output-plugin.mjs";
|
||||
|
||||
// A refused fetch proves the previous bridge stopped listening. Runtimes report
|
||||
// it differently: Node puts the code on `cause`, Bun puts it on the error.
|
||||
export function isRefused(error) {
|
||||
const code = error?.code ?? error?.cause?.code;
|
||||
return code === "ECONNREFUSED" || code === "ConnectionRefused";
|
||||
}
|
||||
|
||||
// OpenCode owns this plugin for the lifetime of a location. Paseo supplies only
|
||||
// live, per-session bindings over the authenticated OpenCode RPC channel.
|
||||
export async function registerSharedBridge(context) {
|
||||
@@ -130,12 +137,7 @@ export async function registerSharedBridge(context) {
|
||||
} catch (error) {
|
||||
// Refusal or rejected credentials mean this endpoint no longer
|
||||
// serves the previous binding. Other failures are inconclusive.
|
||||
if (
|
||||
error?.status !== 401 &&
|
||||
error?.cause?.code !== "ECONNREFUSED" &&
|
||||
error?.code !== "ConnectionRefused"
|
||||
)
|
||||
throw error;
|
||||
if (error?.status !== 401 && !isRefused(error)) throw error;
|
||||
}
|
||||
if (active)
|
||||
return { error: "OpenCode session is already bound to another Paseo bridge" };
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
@@ -167,3 +169,88 @@ test("a shared plugin binds Paseo sessions without involving unrelated sessions"
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a shared plugin rebinds a session after the previous bridge stops listening", async () => {
|
||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-shared-bridge-refused-"));
|
||||
const previous = createServer((_request, response) => {
|
||||
response.writeHead(200, { "Content-Type": "application/json", Connection: "close" });
|
||||
response.end(JSON.stringify({ tools: [] }));
|
||||
});
|
||||
await new Promise<void>((resolve) => previous.listen(0, "127.0.0.1", resolve));
|
||||
const previousPort = (previous.address() as AddressInfo).port;
|
||||
const current = new OpenCodeBridge({ paseoHome: home, logger: createTestLogger() });
|
||||
current.setManifestCatalog({ tools: new Map() });
|
||||
let rpc: Record<string, (input: Binding) => Promise<unknown>> = {};
|
||||
let dispose: (() => Promise<void>) | undefined;
|
||||
let releaseCurrent: (() => void) | undefined;
|
||||
try {
|
||||
const plugin = {
|
||||
package: await current.materializeV2Plugin(),
|
||||
options: { baseUrl: `http://127.0.0.1:${previousPort}`, token: "previous-bridge" },
|
||||
};
|
||||
const module = await import(
|
||||
pathToFileURL(path.join(fileURLToPath(plugin.package), "server.js")).href
|
||||
);
|
||||
dispose = await module.default.setup({
|
||||
options: {},
|
||||
location: { directory: home },
|
||||
provider: { reload: async () => {} },
|
||||
model: { reload: async () => {} },
|
||||
agent: { reload: async () => {} },
|
||||
command: { reload: async () => {} },
|
||||
skill: { reload: async () => {} },
|
||||
rpc: {
|
||||
register: async (_definition: unknown, handlers: typeof rpc) => {
|
||||
rpc = handlers;
|
||||
return { dispose: async () => {} };
|
||||
},
|
||||
},
|
||||
tool: { transform: async () => ({ dispose: async () => {} }) },
|
||||
session: {
|
||||
context: async () => [],
|
||||
get: async () => ({ parentID: "" }),
|
||||
hook: async () => ({ dispose: async () => {} }),
|
||||
},
|
||||
});
|
||||
await expect(rpc.bind({ sessionID: "paseo", ...plugin.options })).resolves.toEqual({});
|
||||
await current.start();
|
||||
// The restarted daemon owns the agent again, so its bridge serves this session.
|
||||
releaseCurrent = current.bindSession({ sessionId: "paseo", env: {} });
|
||||
await new Promise<void>((resolve) => {
|
||||
previous.close(() => resolve());
|
||||
previous.closeAllConnections();
|
||||
});
|
||||
await expect(
|
||||
rpc.bind({ sessionID: "paseo", ...current.sharedV2Connection() }),
|
||||
).resolves.toEqual({});
|
||||
} finally {
|
||||
await dispose?.();
|
||||
releaseCurrent?.();
|
||||
await current.close();
|
||||
await new Promise<void>((resolve) => previous.close(() => resolve()));
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a refused connection is the only probe failure that clears a binding", async () => {
|
||||
const source = new URL("./shared-bridge-plugin.mjs", import.meta.url).href;
|
||||
const { isRefused } = (await import(source)) as { isRefused: (error: unknown) => boolean };
|
||||
const nodeShape = Object.assign(new Error("fetch failed"), {
|
||||
cause: Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNREFUSED" }),
|
||||
});
|
||||
const olderBunShape = Object.assign(new TypeError("Unable to connect"), {
|
||||
code: "ConnectionRefused",
|
||||
});
|
||||
// OpenCode 2.2.5 runs on Bun 1.4.4, which carries the code on the error and
|
||||
// leaves `cause` unset. Node puts the same failure on `cause` instead.
|
||||
const bunShape = Object.assign(
|
||||
new TypeError("ECONNREFUSED: Unable to connect. Is the computer able to access the url?"),
|
||||
{ code: "ECONNREFUSED" },
|
||||
);
|
||||
const inconclusive = Object.assign(new Error("request timed out"), { code: "ETIMEDOUT" });
|
||||
expect(isRefused(nodeShape)).toBe(true);
|
||||
expect(isRefused(olderBunShape)).toBe(true);
|
||||
expect(isRefused(bunShape)).toBe(true);
|
||||
expect(isRefused(inconclusive)).toBe(false);
|
||||
expect(isRefused(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1238,6 +1238,9 @@ test("a failed parent lookup is retried by the pull request status poll, keeping
|
||||
paseoHome: path.join(path.dirname(repo), "home"),
|
||||
deps: {
|
||||
now: () => new Date(nowMs),
|
||||
// Observing the workspace starts a background fetch of origin. A real one would run ssh
|
||||
// against the alias host in the checkout, and can outlive disposal and hold the folder.
|
||||
runGitFetch: async () => ({ changes: [], error: null }),
|
||||
resolveSshHostname: async (host) => (host === "github.com-work" ? "github.com" : null),
|
||||
forgeOverrides: {
|
||||
// The adapter is real; only the poll is driven by the test instead of a timer.
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
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"];
|
||||
@@ -48,6 +48,10 @@ 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.
|
||||
|
||||
@@ -7,9 +7,8 @@ import { test } from "node:test";
|
||||
|
||||
const script = path.resolve("scripts", "postinstall-patches.mjs");
|
||||
const bin = path.resolve("node_modules", ".bin");
|
||||
const packageName = "react-native-gesture-handler";
|
||||
const packageVersion = "2.32.0";
|
||||
const patchFile = `${packageName}+${packageVersion}.patch`;
|
||||
const packageName = "react-native-draggable-flatlist";
|
||||
const patchFile = `${packageName}+4.0.3.patch`;
|
||||
|
||||
for (const platform of ["ios", "android"]) {
|
||||
test(`patched Markdown ships ${platform} props generated from its native specs`, () => {
|
||||
@@ -55,7 +54,7 @@ function createCheckout() {
|
||||
mkdirSync(packageDir, { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(packageDir, "package.json"),
|
||||
JSON.stringify({ name: packageName, version: packageVersion }),
|
||||
JSON.stringify({ name: packageName, version: "4.0.3" }),
|
||||
);
|
||||
writeFileSync(path.join(packageDir, "index.js"), "export const value = 1;\n");
|
||||
mkdirSync(path.join(root, "patches"));
|
||||
|
||||
Reference in New Issue
Block a user