Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 7f3967d288 test(server): keep the forge parent retry test from fetching over the network (#9658)
Observing the workspace starts a background git fetch of origin. The test's
origin is an SSH alias, so a real fetch runs ssh in the checkout and can still
be running after the service is disposed. On Windows that holds the folder
open and cleanup fails with EBUSY. The test now supplies a fetch that returns
no ref changes; the parent lookup and status poll it checks are unchanged.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-14 12:23:33 +00:00
paseo-bot[bot]andquickwind ade902f195 Existing OpenCode sessions rebind after a daemon restart when OpenCode runs on Bun (#9653)
* fix(opencode): rebind a session when the previous bridge stops listening

A refused fetch proves the previous bridge is gone, but the check only
matched the Node shape (`cause.code`) and Bun's older spelling
(`code === "ConnectionRefused"`). OpenCode 2.2.5 runs on Bun 1.4.4,
which throws a TypeError with `code === "ECONNREFUSED"` and no
`cause`, so the error was rethrown, the stale binding was never
deleted, and every later bind for that session answered HTTP 500
(#8780).

Accept the code from either location. The narrow refusal check stays:
other connection failures remain inconclusive so two daemons cannot
take over the same session.

The probe is now classified by an exported predicate with the runtime
shapes as explicit cases, and the stale-binding test closes a real
listener instead of stubbing the network, so neither test patches a
global.

* test(opencode): bind the session on the restarted bridge before rebinding

The bridge now serves tools only for sessions it has bound, so the stale-binding
test registers the session on the restarted bridge, as a restarted daemon does
when it takes the agent back.

Co-authored-by: quickwind <wallacewei@live.cn>

---------

Co-authored-by: quickwind <wallacewei@live.cn>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-14 11:59:29 +00:00
Mohamed Boudra 1bd94ac2b0 Collapsing Activity in the PR pane gives the checks the space, and the separator above it resizes (#9656)
* fix(app): let the PR pane's Activity section collapse and resize

The PR pane was one ScrollView. Checks sat in a nested list capped at
268px, and the line above Activity was a plain 1px View. Collapsing
Activity only hid its body, so the checks stayed boxed with empty space
below, and nothing could be dragged.

The pane now splits like the Changes pane's docked Commits section. The
overview (header and checks) scrolls in its own region. While Activity is
open, a ResizeHandle between the two sets the overview's share. While it
is collapsed, the Activity row docks to the bottom and the overview fills
the rest.

* fix(app): keep the PR pane separator under the finger above the home indicator

The split measured the pane including its bottom safe-area padding, but
the overview's percentage height resolves against the box inside that
padding. On a phone with a home indicator the two heights differ, so a
drag jumped at the start and then lagged the finger (a 250px drag moved
the separator 212px on Android). The split is now measured inside the
padding, so the drag and the layout use the same height.

* fix(app): keep focus on the PR pane's Activity header when it toggles

The header moved between two parents as Activity opened and closed, so
React remounted the row and keyboard focus fell to the body. It now keeps
one slot in the split and only the timeline mounts. That also stops the
header shrinking into the space left by a large drag when the pane gets
short, since the header no longer sits in a min-height-0 wrapper.

The header exposes aria-expanded on web, which React Native Web does not
derive from accessibilityState. The Unistyles test stub now lets explicit
props override mapped ones, matching the library's merge order.

* test(app): fill unpinned test-theme tokens from the light theme

The Unistyles stub now runs uniProps mappings, and the markdown renderer's
mapping reads colors.terminal, which the hand-written test theme lacked.
Tokens a test does not pin now come from the real light theme.

* fix(app): save the PR pane separator where it is drawn

Dragging past the end of short checks left the separator at the content's
bottom but saved the pointer's share, so the separator jumped down when
more checks arrived. The saved share is now capped at the overview's
content height.

* fix(app): keep the PR pane's earlier share after a drag past short checks

Capping the saved share at the content height made any downward drag over
short checks pin the overview at that height, even a drag that visibly did
nothing. A drag that ends at or past the content's bottom now keeps the
larger of the current share and the content's, so growing checks return to
the share the overview had, and never to the pointer's position.

* fix(app): decide the PR pane's saved share when a drag ends

Drag previews wrote straight into the saved share, so a move above the end
of short checks during a drag replaced the share the overview had before
it, and a drag that ended back at the content's bottom left the overview
pinned there. Previews now live in their own drag state, and the saved
share is resolved once, on commit, against the share from before the drag.
2026-11-14 18:03:46 +07:00
hankyoandClaude Opus 5.5 7deb338d4c fix(opencode): re-probe a v1 runtime when the provider is refreshed (#9593)
Updating OpenCode from 1.x to 2.x while the daemon runs left it on the v1
client: the version probe result was cached for the client's lifetime and the
binary update does not change provider config, so the client was never rebuilt.
Once the old v1 server exited, every request failed with "Request is not
supported by this version of OpenCode Server (Server responded with
text/html)". Refresh and Reload did not recover; only a daemon restart did.

A forced catalog refresh now drops a cached v1 selection so the next call
re-probes the binary. A v2 selection owns live sessions and stays. A guard keeps
an overlapping forced refresh from discarding the selection the first one made.

Fixes #9583

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 11:00:45 +00:00
33 changed files with 2252 additions and 783 deletions
+1
View File
@@ -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",
+15 -32
View File
@@ -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",
+3 -3
View File
@@ -18,9 +18,9 @@ maestro test packages/app/maestro/ios-sidebar-close-regression.yaml --udid <simu
`test-sidebar-drag-cancellation-ios.sh` creates three temporary projects and
uses real native slow swipes to cover successful reorder, a second touch during
the drop spring, 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:
+1 -1
View File
@@ -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"];
+4
View File
@@ -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.
+3 -4
View File
@@ -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"));