Keep diff headers and painted content on the same scroll clock while preserving native scrolling and independent per-file horizontal offsets. Complete review, selection, typography, tree, and palette parity without returning line rendering to React.
428 lines
13 KiB
TypeScript
428 lines
13 KiB
TypeScript
import { useCallback, useMemo } from "react";
|
|
import { Gesture } from "react-native-gesture-handler";
|
|
import { useSharedValue } from "react-native-reanimated";
|
|
import { scheduleOnRN } from "react-native-worklets";
|
|
import { isWeb } from "@/constants/platform";
|
|
import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-context";
|
|
import { usePanelStore } from "@/stores/panel-store";
|
|
import { canBeginMobilePanelGesture, isMobilePanelGestureCurrent } from "./model";
|
|
import { useMobilePanelsRuntime } from "./provider";
|
|
import { resolveMobilePanelGestureIntent } from "./gesture-intent";
|
|
|
|
const MOBILE_WEB_EDGE_SWIPE_WIDTH = 32;
|
|
|
|
function isCurrentSelection(startedRevision: number): boolean {
|
|
return usePanelStore.getState().mobilePanel.revision === startedRevision;
|
|
}
|
|
|
|
function useGestureState() {
|
|
return {
|
|
startedRevision: useSharedValue(-1),
|
|
touchStartX: useSharedValue(0),
|
|
touchStartY: useSharedValue(0),
|
|
};
|
|
}
|
|
|
|
function useRevisionCommit(action: () => void) {
|
|
return useCallback(
|
|
(revision: number) => {
|
|
if (isCurrentSelection(revision)) {
|
|
action();
|
|
}
|
|
},
|
|
[action],
|
|
);
|
|
}
|
|
|
|
export function useOpenAgentListGesture(enabled: boolean) {
|
|
const {
|
|
beginGesture,
|
|
finishGesture,
|
|
leftOpenGestureRef,
|
|
motionState,
|
|
openGesturesBlocked,
|
|
position,
|
|
updateGesture,
|
|
windowWidth,
|
|
} = useMobilePanelsRuntime();
|
|
const horizontalScroll = useHorizontalScrollOptional();
|
|
const { startedRevision, touchStartX, touchStartY } = useGestureState();
|
|
const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList);
|
|
const commit = useRevisionCommit(showMobileAgentList);
|
|
|
|
return useMemo(
|
|
() =>
|
|
Gesture.Pan()
|
|
.withRef(leftOpenGestureRef)
|
|
.enabled(enabled)
|
|
.manualActivation(true)
|
|
.onTouchesDown((event) => {
|
|
const touch = event.changedTouches[0];
|
|
if (touch) {
|
|
touchStartX.value = touch.absoluteX;
|
|
touchStartY.value = touch.absoluteY;
|
|
}
|
|
})
|
|
.onTouchesMove((event, stateManager) => {
|
|
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;
|
|
if (isMobilePanelGestureCurrent(motionState.value, startedRevision.value)) {
|
|
return;
|
|
}
|
|
|
|
const panIntent = resolveMobilePanelGestureIntent({
|
|
deltaX,
|
|
deltaY,
|
|
direction: 1,
|
|
openGesturesBlocked: openGesturesBlocked.value,
|
|
});
|
|
if (
|
|
!canBeginMobilePanelGesture(motionState.value, "agent", position.value) ||
|
|
horizontalScroll?.isAnyScrolledRight.value ||
|
|
(isWeb && touchStartX.value > MOBILE_WEB_EDGE_SWIPE_WIDTH) ||
|
|
panIntent === "fail"
|
|
) {
|
|
stateManager.fail();
|
|
return;
|
|
}
|
|
if (panIntent === "activate") {
|
|
stateManager.activate();
|
|
}
|
|
})
|
|
.onStart(() => {
|
|
startedRevision.value = beginGesture({ origin: "agent", preview: "agent-list" });
|
|
})
|
|
.onUpdate((event) => {
|
|
updateGesture(startedRevision.value, -event.translationX / windowWidth);
|
|
})
|
|
.onEnd((event, success) => {
|
|
const shouldOpen = event.translationX > windowWidth / 3 || event.velocityX > 500;
|
|
const result = finishGesture({
|
|
startedRevision: startedRevision.value,
|
|
target: shouldOpen ? "agent-list" : "agent",
|
|
success,
|
|
});
|
|
if (result) {
|
|
scheduleOnRN(commit, result.startedRevision);
|
|
}
|
|
}),
|
|
[
|
|
commit,
|
|
enabled,
|
|
beginGesture,
|
|
finishGesture,
|
|
horizontalScroll?.isAnyScrolledRight,
|
|
leftOpenGestureRef,
|
|
motionState,
|
|
openGesturesBlocked,
|
|
position,
|
|
startedRevision,
|
|
touchStartX,
|
|
touchStartY,
|
|
updateGesture,
|
|
windowWidth,
|
|
],
|
|
);
|
|
}
|
|
|
|
export function useCloseAgentListGesture() {
|
|
const {
|
|
beginGesture,
|
|
finishGesture,
|
|
leftCloseGestureRef,
|
|
motionState,
|
|
position,
|
|
updateGesture,
|
|
windowWidth,
|
|
} = useMobilePanelsRuntime();
|
|
const { startedRevision, touchStartX, touchStartY } = useGestureState();
|
|
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
|
const commit = useRevisionCommit(showMobileAgent);
|
|
|
|
const gesture = useMemo(
|
|
() =>
|
|
Gesture.Pan()
|
|
.withRef(leftCloseGestureRef)
|
|
.manualActivation(true)
|
|
.onTouchesDown((event) => {
|
|
const touch = event.changedTouches[0];
|
|
if (touch) {
|
|
touchStartX.value = touch.absoluteX;
|
|
touchStartY.value = touch.absoluteY;
|
|
}
|
|
})
|
|
.onTouchesMove((event, stateManager) => {
|
|
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;
|
|
if (isMobilePanelGestureCurrent(motionState.value, startedRevision.value)) {
|
|
return;
|
|
}
|
|
|
|
const panIntent = resolveMobilePanelGestureIntent({
|
|
deltaX,
|
|
deltaY,
|
|
direction: -1,
|
|
openGesturesBlocked: false,
|
|
});
|
|
if (
|
|
!canBeginMobilePanelGesture(motionState.value, "agent-list", position.value) ||
|
|
panIntent === "fail"
|
|
) {
|
|
stateManager.fail();
|
|
return;
|
|
}
|
|
if (panIntent === "activate") {
|
|
stateManager.activate();
|
|
}
|
|
})
|
|
.onStart(() => {
|
|
startedRevision.value = beginGesture({
|
|
origin: "agent-list",
|
|
preview: "agent-list",
|
|
});
|
|
})
|
|
.onUpdate((event) => {
|
|
updateGesture(startedRevision.value, -1 - event.translationX / windowWidth);
|
|
})
|
|
.onEnd((event, success) => {
|
|
const shouldClose = event.translationX < -windowWidth / 3 || event.velocityX < -500;
|
|
const result = finishGesture({
|
|
startedRevision: startedRevision.value,
|
|
target: shouldClose ? "agent" : "agent-list",
|
|
success,
|
|
});
|
|
if (result) {
|
|
scheduleOnRN(commit, result.startedRevision);
|
|
}
|
|
}),
|
|
[
|
|
beginGesture,
|
|
commit,
|
|
finishGesture,
|
|
leftCloseGestureRef,
|
|
motionState,
|
|
position,
|
|
startedRevision,
|
|
touchStartX,
|
|
touchStartY,
|
|
updateGesture,
|
|
windowWidth,
|
|
],
|
|
);
|
|
|
|
return { gesture, gestureRef: leftCloseGestureRef };
|
|
}
|
|
|
|
interface OpenFileExplorerGestureOptions {
|
|
enabled: boolean;
|
|
onOpen: () => void;
|
|
}
|
|
|
|
export function useOpenFileExplorerGesture({ enabled, onOpen }: OpenFileExplorerGestureOptions) {
|
|
const {
|
|
beginGesture,
|
|
finishGesture,
|
|
leftOpenGestureRef,
|
|
motionState,
|
|
openGesturesBlocked,
|
|
position,
|
|
rightOpenGestureRef,
|
|
updateGesture,
|
|
windowWidth,
|
|
} = useMobilePanelsRuntime();
|
|
const { startedRevision, touchStartX, touchStartY } = useGestureState();
|
|
const commit = useRevisionCommit(onOpen);
|
|
|
|
return useMemo(
|
|
() =>
|
|
Gesture.Pan()
|
|
.withRef(rightOpenGestureRef)
|
|
.simultaneousWithExternalGesture(leftOpenGestureRef)
|
|
.enabled(enabled)
|
|
.manualActivation(true)
|
|
.onTouchesDown((event) => {
|
|
const touch = event.changedTouches[0];
|
|
if (touch) {
|
|
touchStartX.value = touch.absoluteX;
|
|
touchStartY.value = touch.absoluteY;
|
|
}
|
|
})
|
|
.onTouchesMove((event, stateManager) => {
|
|
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;
|
|
if (isMobilePanelGestureCurrent(motionState.value, startedRevision.value)) {
|
|
return;
|
|
}
|
|
|
|
const panIntent = resolveMobilePanelGestureIntent({
|
|
deltaX,
|
|
deltaY,
|
|
direction: -1,
|
|
openGesturesBlocked: openGesturesBlocked.value,
|
|
});
|
|
if (
|
|
!canBeginMobilePanelGesture(motionState.value, "agent", position.value) ||
|
|
(isWeb && touchStartX.value < windowWidth - MOBILE_WEB_EDGE_SWIPE_WIDTH) ||
|
|
panIntent === "fail"
|
|
) {
|
|
stateManager.fail();
|
|
return;
|
|
}
|
|
if (panIntent === "activate") {
|
|
stateManager.activate();
|
|
}
|
|
})
|
|
.onStart(() => {
|
|
startedRevision.value = beginGesture({
|
|
origin: "agent",
|
|
preview: "file-explorer",
|
|
});
|
|
})
|
|
.onUpdate((event) => {
|
|
updateGesture(startedRevision.value, -event.translationX / windowWidth);
|
|
})
|
|
.onEnd((event, success) => {
|
|
const shouldOpen = event.translationX < -windowWidth / 3 || event.velocityX < -500;
|
|
const result = finishGesture({
|
|
startedRevision: startedRevision.value,
|
|
target: shouldOpen ? "file-explorer" : "agent",
|
|
success,
|
|
});
|
|
if (result) {
|
|
scheduleOnRN(commit, result.startedRevision);
|
|
}
|
|
}),
|
|
[
|
|
beginGesture,
|
|
commit,
|
|
enabled,
|
|
finishGesture,
|
|
leftOpenGestureRef,
|
|
motionState,
|
|
openGesturesBlocked,
|
|
position,
|
|
rightOpenGestureRef,
|
|
startedRevision,
|
|
touchStartX,
|
|
touchStartY,
|
|
updateGesture,
|
|
windowWidth,
|
|
],
|
|
);
|
|
}
|
|
|
|
export function useCloseFileExplorerGesture() {
|
|
const {
|
|
beginGesture,
|
|
finishGesture,
|
|
motionState,
|
|
position,
|
|
rightCloseGestureRef,
|
|
updateGesture,
|
|
windowWidth,
|
|
} = useMobilePanelsRuntime();
|
|
const { startedRevision, touchStartX, touchStartY } = useGestureState();
|
|
const horizontalScroll = useHorizontalScrollOptional();
|
|
const showMobileAgent = usePanelStore((state) => state.showMobileAgent);
|
|
const commit = useRevisionCommit(showMobileAgent);
|
|
|
|
const gesture = useMemo(
|
|
() =>
|
|
Gesture.Pan()
|
|
.withRef(rightCloseGestureRef)
|
|
.manualActivation(true)
|
|
.onTouchesDown((event) => {
|
|
const touch = event.changedTouches[0];
|
|
if (touch) {
|
|
touchStartX.value = touch.absoluteX;
|
|
touchStartY.value = touch.absoluteY;
|
|
}
|
|
})
|
|
.onTouchesMove((event, stateManager) => {
|
|
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;
|
|
if (isMobilePanelGestureCurrent(motionState.value, startedRevision.value)) {
|
|
return;
|
|
}
|
|
|
|
const panIntent = resolveMobilePanelGestureIntent({
|
|
deltaX,
|
|
deltaY,
|
|
direction: 1,
|
|
openGesturesBlocked: false,
|
|
});
|
|
if (
|
|
!canBeginMobilePanelGesture(motionState.value, "file-explorer", position.value) ||
|
|
horizontalScroll?.activeGestureStartedScrolled.value ||
|
|
panIntent === "fail"
|
|
) {
|
|
stateManager.fail();
|
|
return;
|
|
}
|
|
if (panIntent === "activate") {
|
|
stateManager.activate();
|
|
}
|
|
})
|
|
.onStart(() => {
|
|
startedRevision.value = beginGesture({
|
|
origin: "file-explorer",
|
|
preview: "file-explorer",
|
|
});
|
|
})
|
|
.onUpdate((event) => {
|
|
updateGesture(startedRevision.value, 1 - event.translationX / windowWidth);
|
|
})
|
|
.onEnd((event, success) => {
|
|
const shouldClose = event.translationX > windowWidth / 3 || event.velocityX > 500;
|
|
const result = finishGesture({
|
|
startedRevision: startedRevision.value,
|
|
target: shouldClose ? "agent" : "file-explorer",
|
|
success,
|
|
});
|
|
if (result) {
|
|
scheduleOnRN(commit, result.startedRevision);
|
|
}
|
|
}),
|
|
[
|
|
beginGesture,
|
|
commit,
|
|
finishGesture,
|
|
horizontalScroll?.activeGestureStartedScrolled,
|
|
motionState,
|
|
position,
|
|
rightCloseGestureRef,
|
|
startedRevision,
|
|
touchStartX,
|
|
touchStartY,
|
|
updateGesture,
|
|
windowWidth,
|
|
],
|
|
);
|
|
|
|
return { gesture, gestureRef: rightCloseGestureRef };
|
|
}
|
|
|
|
export function useFileExplorerCloseGestureRef() {
|
|
return useMobilePanelsRuntime().rightCloseGestureRef;
|
|
}
|