Update files
This commit is contained in:
Generated
+58
@@ -4643,6 +4643,34 @@
|
|||||||
"js-yaml": "bin/js-yaml.js"
|
"js-yaml": "bin/js-yaml.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@floating-ui/core": {
|
||||||
|
"version": "1.7.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
|
||||||
|
"integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/utils": "^0.2.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@floating-ui/react-native": {
|
||||||
|
"version": "0.10.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@floating-ui/react-native/-/react-native-0.10.7.tgz",
|
||||||
|
"integrity": "sha512-deSecLPrdfl8RL1yyNJlbgqDDZFPuhBtJhY2aTnOZOoJWaal2vVOad9EBVIa0QV/YordgTyFPgDI8oLfyLZuZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/core": "^1.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-native": ">=0.64.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@floating-ui/utils": {
|
||||||
|
"version": "0.2.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
|
||||||
|
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@gorhom/bottom-sheet": {
|
"node_modules/@gorhom/bottom-sheet": {
|
||||||
"version": "5.2.6",
|
"version": "5.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/@gorhom/bottom-sheet/-/bottom-sheet-5.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/@gorhom/bottom-sheet/-/bottom-sheet-5.2.6.tgz",
|
||||||
@@ -7176,6 +7204,12 @@
|
|||||||
"node": ">= 20.19.4"
|
"node": ">= 20.19.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@react-native/normalize-color": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-native/normalize-color/-/normalize-color-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-Z1jQI2NpdFJCVgpY+8Dq/Bt3d+YUi1928Q+/CZm/oh66fzM0RUl54vvuXlPJKybH4pdCZey1eDTPaLHkMPNgWA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@react-native/normalize-colors": {
|
"node_modules/@react-native/normalize-colors": {
|
||||||
"version": "0.82.1",
|
"version": "0.82.1",
|
||||||
"resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.82.1.tgz",
|
"resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.82.1.tgz",
|
||||||
@@ -11937,6 +11971,17 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/deprecated-react-native-prop-types": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/deprecated-react-native-prop-types/-/deprecated-react-native-prop-types-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-pWD0voFtNYxrVqvBMYf5gq3NA2GCpfodS1yNynTPc93AYA/KEMGeWDqqeUB6R2Z9ZofVhks2aeJXiuQqKNpesA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-native/normalize-color": "*",
|
||||||
|
"invariant": "*",
|
||||||
|
"prop-types": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/destroy": {
|
"node_modules/destroy": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
||||||
@@ -20197,6 +20242,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-native-popover-view": {
|
||||||
|
"version": "6.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-native-popover-view/-/react-native-popover-view-6.1.0.tgz",
|
||||||
|
"integrity": "sha512-j1CB+yPwTKlBvIJBNb1AwiHyF/r+W5+AJIbHk79GRa+0z6PVtW4C7NWJWPqUVkCMOcJtewl6Pr6f2dc/87cVyQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"deprecated-react-native-prop-types": "^2.3.0",
|
||||||
|
"prop-types": "^15.8.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-native-reanimated": {
|
"node_modules/react-native-reanimated": {
|
||||||
"version": "4.1.3",
|
"version": "4.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.3.tgz",
|
||||||
@@ -25052,7 +25107,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@boudra/expo-two-way-audio": "^0.1.3",
|
"@boudra/expo-two-way-audio": "^0.1.3",
|
||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
|
"@floating-ui/react-native": "^0.10.7",
|
||||||
"@gorhom/bottom-sheet": "^5.2.6",
|
"@gorhom/bottom-sheet": "^5.2.6",
|
||||||
|
"@gorhom/portal": "^1.0.14",
|
||||||
"@lezer/common": "^1.5.0",
|
"@lezer/common": "^1.5.0",
|
||||||
"@lezer/css": "^1.3.0",
|
"@lezer/css": "^1.3.0",
|
||||||
"@lezer/highlight": "^1.2.3",
|
"@lezer/highlight": "^1.2.3",
|
||||||
@@ -25105,6 +25162,7 @@
|
|||||||
"react-native-markdown-display": "^7.0.2",
|
"react-native-markdown-display": "^7.0.2",
|
||||||
"react-native-nitro-modules": "^0.30.0",
|
"react-native-nitro-modules": "^0.30.0",
|
||||||
"react-native-permissions": "^5.4.2",
|
"react-native-permissions": "^5.4.2",
|
||||||
|
"react-native-popover-view": "^6.1.0",
|
||||||
"react-native-reanimated": "~4.1.1",
|
"react-native-reanimated": "~4.1.1",
|
||||||
"react-native-safe-area-context": "~5.6.0",
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
"react-native-screens": "~4.16.0",
|
"react-native-screens": "~4.16.0",
|
||||||
|
|||||||
@@ -21,7 +21,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@boudra/expo-two-way-audio": "^0.1.3",
|
"@boudra/expo-two-way-audio": "^0.1.3",
|
||||||
"@expo/vector-icons": "^15.0.2",
|
"@expo/vector-icons": "^15.0.2",
|
||||||
|
"@floating-ui/react-native": "^0.10.7",
|
||||||
"@gorhom/bottom-sheet": "^5.2.6",
|
"@gorhom/bottom-sheet": "^5.2.6",
|
||||||
|
"@gorhom/portal": "^1.0.14",
|
||||||
"@lezer/common": "^1.5.0",
|
"@lezer/common": "^1.5.0",
|
||||||
"@lezer/css": "^1.3.0",
|
"@lezer/css": "^1.3.0",
|
||||||
"@lezer/highlight": "^1.2.3",
|
"@lezer/highlight": "^1.2.3",
|
||||||
@@ -74,6 +76,7 @@
|
|||||||
"react-native-markdown-display": "^7.0.2",
|
"react-native-markdown-display": "^7.0.2",
|
||||||
"react-native-nitro-modules": "^0.30.0",
|
"react-native-nitro-modules": "^0.30.0",
|
||||||
"react-native-permissions": "^5.4.2",
|
"react-native-permissions": "^5.4.2",
|
||||||
|
"react-native-popover-view": "^6.1.0",
|
||||||
"react-native-reanimated": "~4.1.1",
|
"react-native-reanimated": "~4.1.1",
|
||||||
"react-native-safe-area-context": "~5.6.0",
|
"react-native-safe-area-context": "~5.6.0",
|
||||||
"react-native-screens": "~4.16.0",
|
"react-native-screens": "~4.16.0",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { SafeAreaProvider } from "react-native-safe-area-context";
|
|||||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||||
import { GestureHandlerRootView, Gesture, GestureDetector } from "react-native-gesture-handler";
|
import { GestureHandlerRootView, Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||||
|
import { PortalProvider } from "@gorhom/portal";
|
||||||
import { RealtimeProvider } from "@/contexts/realtime-context";
|
import { RealtimeProvider } from "@/contexts/realtime-context";
|
||||||
import { useAppSettings } from "@/hooks/use-settings";
|
import { useAppSettings } from "@/hooks/use-settings";
|
||||||
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
||||||
@@ -306,42 +307,44 @@ function MissingDaemonView() {
|
|||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
return (
|
return (
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<SafeAreaProvider>
|
<PortalProvider>
|
||||||
<KeyboardProvider>
|
<SafeAreaProvider>
|
||||||
<BottomSheetModalProvider>
|
<KeyboardProvider>
|
||||||
<QueryProvider>
|
<BottomSheetModalProvider>
|
||||||
<DaemonRegistryProvider>
|
<QueryProvider>
|
||||||
<DaemonConnectionsProvider>
|
<DaemonRegistryProvider>
|
||||||
<MultiDaemonSessionHost />
|
<DaemonConnectionsProvider>
|
||||||
<ProvidersWrapper>
|
<MultiDaemonSessionHost />
|
||||||
<SidebarAnimationProvider>
|
<ProvidersWrapper>
|
||||||
<HorizontalScrollProvider>
|
<SidebarAnimationProvider>
|
||||||
<AppWithSidebar>
|
<HorizontalScrollProvider>
|
||||||
<Stack
|
<AppWithSidebar>
|
||||||
screenOptions={{
|
<Stack
|
||||||
headerShown: false,
|
screenOptions={{
|
||||||
animation: "none",
|
headerShown: false,
|
||||||
}}
|
animation: "none",
|
||||||
>
|
}}
|
||||||
<Stack.Screen name="index" />
|
>
|
||||||
<Stack.Screen name="agents" />
|
<Stack.Screen name="index" />
|
||||||
<Stack.Screen name="orchestrator" />
|
<Stack.Screen name="agents" />
|
||||||
<Stack.Screen name="agent/[id]" options={{ gestureEnabled: false }} />
|
<Stack.Screen name="orchestrator" />
|
||||||
<Stack.Screen name="agent/[serverId]/[agentId]" options={{ gestureEnabled: false }} />
|
<Stack.Screen name="agent/[id]" options={{ gestureEnabled: false }} />
|
||||||
<Stack.Screen name="settings" />
|
<Stack.Screen name="agent/[serverId]/[agentId]" options={{ gestureEnabled: false }} />
|
||||||
<Stack.Screen name="audio-test" />
|
<Stack.Screen name="settings" />
|
||||||
<Stack.Screen name="pair-scan" />
|
<Stack.Screen name="audio-test" />
|
||||||
</Stack>
|
<Stack.Screen name="pair-scan" />
|
||||||
</AppWithSidebar>
|
</Stack>
|
||||||
</HorizontalScrollProvider>
|
</AppWithSidebar>
|
||||||
</SidebarAnimationProvider>
|
</HorizontalScrollProvider>
|
||||||
</ProvidersWrapper>
|
</SidebarAnimationProvider>
|
||||||
</DaemonConnectionsProvider>
|
</ProvidersWrapper>
|
||||||
</DaemonRegistryProvider>
|
</DaemonConnectionsProvider>
|
||||||
</QueryProvider>
|
</DaemonRegistryProvider>
|
||||||
</BottomSheetModalProvider>
|
</QueryProvider>
|
||||||
</KeyboardProvider>
|
</BottomSheetModalProvider>
|
||||||
</SafeAreaProvider>
|
</KeyboardProvider>
|
||||||
|
</SafeAreaProvider>
|
||||||
|
</PortalProvider>
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,6 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Pressable,
|
Pressable,
|
||||||
Modal,
|
|
||||||
useWindowDimensions,
|
|
||||||
LayoutChangeEvent,
|
|
||||||
ScrollView,
|
ScrollView,
|
||||||
Platform,
|
Platform,
|
||||||
BackHandler,
|
BackHandler,
|
||||||
@@ -58,6 +55,13 @@ import {
|
|||||||
import { extractAgentModel } from "@/utils/extract-agent-model";
|
import { extractAgentModel } from "@/utils/extract-agent-model";
|
||||||
import { startPerfMonitor } from "@/utils/perf-monitor";
|
import { startPerfMonitor } from "@/utils/perf-monitor";
|
||||||
import { shortenPath } from "@/utils/shorten-path";
|
import { shortenPath } from "@/utils/shorten-path";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
|
||||||
const DROPDOWN_WIDTH = 220;
|
const DROPDOWN_WIDTH = 220;
|
||||||
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
||||||
@@ -169,12 +173,7 @@ function AgentScreenContent({
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
|
||||||
|
|
||||||
const [menuVisible, setMenuVisible] = useState(false);
|
|
||||||
const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 });
|
|
||||||
const [menuContentHeight, setMenuContentHeight] = useState(0);
|
|
||||||
const menuButtonRef = useRef<View>(null);
|
|
||||||
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
|
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
|
||||||
|
|
||||||
const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
|
const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
|
||||||
@@ -464,101 +463,22 @@ function AgentScreenContent({
|
|||||||
}
|
}
|
||||||
}, [resolvedAgentId, agent?.status, client]);
|
}, [resolvedAgentId, agent?.status, client]);
|
||||||
|
|
||||||
const recalculateMenuPosition = useCallback(
|
|
||||||
(onMeasured?: () => void) => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const anchor = menuButtonRef.current;
|
|
||||||
|
|
||||||
if (!anchor) {
|
|
||||||
if (onMeasured) {
|
|
||||||
onMeasured();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
anchor.measureInWindow((x, y, width, height) => {
|
|
||||||
const verticalOffset = 8;
|
|
||||||
const horizontalMargin = 16;
|
|
||||||
const desiredLeft = x + width - DROPDOWN_WIDTH;
|
|
||||||
const maxLeft = windowWidth - DROPDOWN_WIDTH - horizontalMargin;
|
|
||||||
const clampedLeft = Math.min(
|
|
||||||
Math.max(desiredLeft, horizontalMargin),
|
|
||||||
maxLeft
|
|
||||||
);
|
|
||||||
|
|
||||||
// Position menu below button using the raw coordinates from measureInWindow
|
|
||||||
const buttonBottom = y + height;
|
|
||||||
const top = buttonBottom + verticalOffset;
|
|
||||||
|
|
||||||
// If menu would go off screen, clamp to visible area
|
|
||||||
const bottomEdge = top + menuContentHeight;
|
|
||||||
const maxBottom = windowHeight - horizontalMargin;
|
|
||||||
const clampedTop =
|
|
||||||
bottomEdge > maxBottom
|
|
||||||
? Math.max(verticalOffset, maxBottom - menuContentHeight)
|
|
||||||
: top;
|
|
||||||
|
|
||||||
setMenuPosition({
|
|
||||||
top: clampedTop,
|
|
||||||
left: clampedLeft,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (onMeasured) {
|
|
||||||
onMeasured();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[menuContentHeight, windowHeight, windowWidth]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleOpenMenu = useCallback(() => {
|
|
||||||
if (agent?.cwd) {
|
|
||||||
refetchRepoInfo().catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
recalculateMenuPosition(() => {
|
|
||||||
setMenuVisible(true);
|
|
||||||
});
|
|
||||||
}, [agent?.cwd, recalculateMenuPosition, refetchRepoInfo]);
|
|
||||||
|
|
||||||
const handleCloseMenu = useCallback(() => {
|
|
||||||
setMenuVisible(false);
|
|
||||||
setMenuContentHeight(0);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!menuVisible) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
recalculateMenuPosition();
|
|
||||||
}, [menuVisible, recalculateMenuPosition]);
|
|
||||||
|
|
||||||
const handleMenuLayout = useCallback((event: LayoutChangeEvent) => {
|
|
||||||
const { height } = event.nativeEvent.layout;
|
|
||||||
setMenuContentHeight((current) => (current === height ? current : height));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleViewChanges = useCallback(() => {
|
const handleViewChanges = useCallback(() => {
|
||||||
handleCloseMenu();
|
|
||||||
setExplorerTab("changes");
|
setExplorerTab("changes");
|
||||||
openFileExplorer();
|
openFileExplorer();
|
||||||
}, [handleCloseMenu, setExplorerTab, openFileExplorer]);
|
}, [setExplorerTab, openFileExplorer]);
|
||||||
|
|
||||||
const handleBrowseFiles = useCallback(() => {
|
const handleBrowseFiles = useCallback(() => {
|
||||||
handleCloseMenu();
|
|
||||||
setExplorerTab("files");
|
setExplorerTab("files");
|
||||||
openFileExplorer();
|
openFileExplorer();
|
||||||
}, [handleCloseMenu, setExplorerTab, openFileExplorer]);
|
}, [setExplorerTab, openFileExplorer]);
|
||||||
|
|
||||||
const handleRefreshAgent = useCallback(() => {
|
const handleRefreshAgent = useCallback(() => {
|
||||||
if (!resolvedAgentId || !refreshAgent) {
|
if (!resolvedAgentId || !refreshAgent) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
handleCloseMenu();
|
|
||||||
refreshAgent({ agentId: resolvedAgentId });
|
refreshAgent({ agentId: resolvedAgentId });
|
||||||
}, [handleCloseMenu, resolvedAgentId, refreshAgent]);
|
}, [resolvedAgentId, refreshAgent]);
|
||||||
|
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
return (
|
return (
|
||||||
@@ -601,11 +521,99 @@ function AgentScreenContent({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<View ref={menuButtonRef} collapsable={false}>
|
<DropdownMenu
|
||||||
<Pressable testID="agent-overflow-menu" onPress={handleOpenMenu} style={styles.menuButton}>
|
onOpenChange={(open) => {
|
||||||
|
if (open && agent?.cwd) {
|
||||||
|
refetchRepoInfo().catch(() => {});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenuTrigger testID="agent-overflow-menu" style={styles.menuButton}>
|
||||||
<MoreVertical size={16} color={theme.colors.foregroundMuted} />
|
<MoreVertical size={16} color={theme.colors.foregroundMuted} />
|
||||||
</Pressable>
|
</DropdownMenuTrigger>
|
||||||
</View>
|
<DropdownMenuContent align="end" width={DROPDOWN_WIDTH} testID="agent-overflow-content">
|
||||||
|
<View style={styles.menuMetaContainer}>
|
||||||
|
<View style={styles.menuMetaRow}>
|
||||||
|
<Text style={styles.menuMetaLabel}>Directory</Text>
|
||||||
|
<Text
|
||||||
|
style={styles.menuMetaValue}
|
||||||
|
numberOfLines={2}
|
||||||
|
ellipsizeMode="middle"
|
||||||
|
>
|
||||||
|
{shortenPath(agent.cwd)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.menuMetaRow}>
|
||||||
|
<Text style={styles.menuMetaLabel}>Model</Text>
|
||||||
|
<Text
|
||||||
|
style={styles.menuMetaValue}
|
||||||
|
numberOfLines={1}
|
||||||
|
ellipsizeMode="middle"
|
||||||
|
>
|
||||||
|
{modelDisplayValue}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.menuMetaRow}>
|
||||||
|
<Text style={styles.menuMetaLabel}>Branch</Text>
|
||||||
|
<View style={styles.menuMetaValueRow}>
|
||||||
|
{branchStatus === "loading" ? (
|
||||||
|
<>
|
||||||
|
<ActivityIndicator
|
||||||
|
size="small"
|
||||||
|
color={theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
|
<Text style={styles.menuMetaPendingText}>Fetching…</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.menuMetaValue,
|
||||||
|
branchStatus === "error" ? styles.menuMetaValueError : null,
|
||||||
|
]}
|
||||||
|
numberOfLines={1}
|
||||||
|
ellipsizeMode="middle"
|
||||||
|
>
|
||||||
|
{branchDisplayValue}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
<DropdownMenuItem
|
||||||
|
leading={<GitBranch size={16} color={theme.colors.foreground} />}
|
||||||
|
onSelect={handleViewChanges}
|
||||||
|
>
|
||||||
|
View Changes
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
leading={<Folder size={16} color={theme.colors.foreground} />}
|
||||||
|
onSelect={handleBrowseFiles}
|
||||||
|
>
|
||||||
|
Browse Files
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
leading={<RotateCcw size={16} color={theme.colors.foreground} />}
|
||||||
|
disabled={isInitializing}
|
||||||
|
trailing={
|
||||||
|
isInitializing ? (
|
||||||
|
<ActivityIndicator
|
||||||
|
size="small"
|
||||||
|
color={theme.colors.primary}
|
||||||
|
style={styles.menuItemSpinner}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
onSelect={handleRefreshAgent}
|
||||||
|
>
|
||||||
|
{isInitializing ? "Refreshing..." : "Refresh"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</View>
|
</View>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -640,114 +648,6 @@ function AgentScreenContent({
|
|||||||
<AgentInputArea agentId={resolvedAgentId} serverId={serverId} autoFocus onAddImages={handleAddImagesCallback} />
|
<AgentInputArea agentId={resolvedAgentId} serverId={serverId} autoFocus onAddImages={handleAddImagesCallback} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Dropdown Menu */}
|
|
||||||
<Modal
|
|
||||||
visible={menuVisible}
|
|
||||||
animationType="fade"
|
|
||||||
transparent={true}
|
|
||||||
onRequestClose={handleCloseMenu}
|
|
||||||
>
|
|
||||||
<View style={styles.menuOverlay}>
|
|
||||||
<Pressable style={styles.menuBackdrop} onPress={handleCloseMenu} />
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.dropdownMenu,
|
|
||||||
{
|
|
||||||
position: "absolute",
|
|
||||||
top: menuPosition.top,
|
|
||||||
left: menuPosition.left,
|
|
||||||
width: DROPDOWN_WIDTH,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
onLayout={handleMenuLayout}
|
|
||||||
>
|
|
||||||
<View style={styles.menuMetaContainer}>
|
|
||||||
<View style={styles.menuMetaRow}>
|
|
||||||
<Text style={styles.menuMetaLabel}>Directory</Text>
|
|
||||||
<Text
|
|
||||||
style={styles.menuMetaValue}
|
|
||||||
numberOfLines={2}
|
|
||||||
ellipsizeMode="middle"
|
|
||||||
>
|
|
||||||
{shortenPath(agent.cwd)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.menuMetaRow}>
|
|
||||||
<Text style={styles.menuMetaLabel}>Model</Text>
|
|
||||||
<Text
|
|
||||||
style={styles.menuMetaValue}
|
|
||||||
numberOfLines={1}
|
|
||||||
ellipsizeMode="middle"
|
|
||||||
>
|
|
||||||
{modelDisplayValue}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.menuMetaRow}>
|
|
||||||
<Text style={styles.menuMetaLabel}>Branch</Text>
|
|
||||||
<View style={styles.menuMetaValueRow}>
|
|
||||||
{branchStatus === "loading" ? (
|
|
||||||
<>
|
|
||||||
<ActivityIndicator
|
|
||||||
size="small"
|
|
||||||
color={theme.colors.foregroundMuted}
|
|
||||||
/>
|
|
||||||
<Text style={styles.menuMetaPendingText}>
|
|
||||||
Fetching…
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.menuMetaValue,
|
|
||||||
branchStatus === "error"
|
|
||||||
? styles.menuMetaValueError
|
|
||||||
: null,
|
|
||||||
]}
|
|
||||||
numberOfLines={1}
|
|
||||||
ellipsizeMode="middle"
|
|
||||||
>
|
|
||||||
{branchDisplayValue}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.menuDivider} />
|
|
||||||
|
|
||||||
<Pressable onPress={handleViewChanges} style={styles.menuItem}>
|
|
||||||
<GitBranch size={16} color={theme.colors.foreground} />
|
|
||||||
<Text style={styles.menuItemText}>View Changes</Text>
|
|
||||||
</Pressable>
|
|
||||||
<Pressable onPress={handleBrowseFiles} style={styles.menuItem}>
|
|
||||||
<Folder size={16} color={theme.colors.foreground} />
|
|
||||||
<Text style={styles.menuItemText}>Browse Files</Text>
|
|
||||||
</Pressable>
|
|
||||||
<Pressable
|
|
||||||
onPress={handleRefreshAgent}
|
|
||||||
style={[
|
|
||||||
styles.menuItem,
|
|
||||||
isInitializing ? styles.menuItemDisabled : null,
|
|
||||||
]}
|
|
||||||
disabled={isInitializing}
|
|
||||||
>
|
|
||||||
<RotateCcw size={16} color={theme.colors.foreground} />
|
|
||||||
<Text style={styles.menuItemText}>
|
|
||||||
{isInitializing ? "Refreshing..." : "Refresh"}
|
|
||||||
</Text>
|
|
||||||
{isInitializing && (
|
|
||||||
<ActivityIndicator
|
|
||||||
size="small"
|
|
||||||
color={theme.colors.primary}
|
|
||||||
style={styles.menuItemSpinner}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</Modal>
|
|
||||||
</View>
|
</View>
|
||||||
</FileDropZone>
|
</FileDropZone>
|
||||||
|
|
||||||
@@ -924,25 +824,10 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
padding: theme.spacing[3],
|
padding: theme.spacing[3],
|
||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
},
|
},
|
||||||
menuOverlay: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
menuBackdrop: {
|
|
||||||
...StyleSheet.absoluteFillObject,
|
|
||||||
},
|
|
||||||
dropdownMenu: {
|
|
||||||
backgroundColor: theme.colors.surface2,
|
|
||||||
borderRadius: theme.borderRadius.lg,
|
|
||||||
padding: theme.spacing[2],
|
|
||||||
shadowColor: "#000",
|
|
||||||
shadowOffset: { width: 0, height: 2 },
|
|
||||||
shadowOpacity: 0.25,
|
|
||||||
shadowRadius: 8,
|
|
||||||
elevation: 5,
|
|
||||||
},
|
|
||||||
menuMetaContainer: {
|
menuMetaContainer: {
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
paddingVertical: theme.spacing[2],
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
marginBottom: theme.spacing[2],
|
|
||||||
},
|
},
|
||||||
menuMetaRow: {
|
menuMetaRow: {
|
||||||
gap: theme.spacing[1],
|
gap: theme.spacing[1],
|
||||||
@@ -969,28 +854,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
menuMetaValueError: {
|
menuMetaValueError: {
|
||||||
color: theme.colors.destructive,
|
color: theme.colors.destructive,
|
||||||
},
|
},
|
||||||
menuDivider: {
|
|
||||||
height: StyleSheet.hairlineWidth,
|
|
||||||
backgroundColor: theme.colors.border,
|
|
||||||
marginVertical: theme.spacing[2],
|
|
||||||
},
|
|
||||||
menuItem: {
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: theme.spacing[3],
|
|
||||||
paddingVertical: theme.spacing[3],
|
|
||||||
paddingHorizontal: theme.spacing[3],
|
|
||||||
borderRadius: theme.borderRadius.md,
|
|
||||||
},
|
|
||||||
menuItemDisabled: {
|
|
||||||
opacity: 0.6,
|
|
||||||
},
|
|
||||||
menuItemSpinner: {
|
menuItemSpinner: {
|
||||||
marginLeft: "auto",
|
marginLeft: "auto",
|
||||||
},
|
},
|
||||||
menuItemText: {
|
|
||||||
fontSize: theme.fontSize.base,
|
|
||||||
color: theme.colors.foreground,
|
|
||||||
fontWeight: theme.fontWeight.normal,
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import {
|
|||||||
Modal,
|
Modal,
|
||||||
TextInput,
|
TextInput,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
useWindowDimensions,
|
|
||||||
ScrollView,
|
ScrollView,
|
||||||
Platform,
|
Platform,
|
||||||
|
StatusBar,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { StyleSheet, UnistylesRuntime } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime } from "react-native-unistyles";
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +21,13 @@ import {
|
|||||||
} from "@gorhom/bottom-sheet";
|
} from "@gorhom/bottom-sheet";
|
||||||
import Animated from "react-native-reanimated";
|
import Animated from "react-native-reanimated";
|
||||||
import { ChevronDown, ChevronRight, Pencil, Check, X } from "lucide-react-native";
|
import { ChevronDown, ChevronRight, Pencil, Check, X } from "lucide-react-native";
|
||||||
|
import {
|
||||||
|
flip,
|
||||||
|
offset as floatingOffset,
|
||||||
|
shift,
|
||||||
|
size as floatingSize,
|
||||||
|
useFloating,
|
||||||
|
} from "@floating-ui/react-native";
|
||||||
import { theme as defaultTheme } from "@/styles/theme";
|
import { theme as defaultTheme } from "@/styles/theme";
|
||||||
import type {
|
import type {
|
||||||
AgentMode,
|
AgentMode,
|
||||||
@@ -263,43 +270,62 @@ export function AdaptiveSelect({
|
|||||||
}: AdaptiveSelectProps): ReactElement {
|
}: AdaptiveSelectProps): ReactElement {
|
||||||
const isMobile =
|
const isMobile =
|
||||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
|
||||||
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
||||||
const snapPoints = useMemo(() => ["60%", "90%"], []);
|
const snapPoints = useMemo(() => ["60%", "90%"], []);
|
||||||
const [dropdownPosition, setDropdownPosition] = useState({
|
const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(null);
|
||||||
top: 0,
|
const [referenceWidth, setReferenceWidth] = useState<number | null>(null);
|
||||||
left: 0,
|
|
||||||
width: 0,
|
const collisionPadding = useMemo(() => {
|
||||||
|
const basePadding = 16;
|
||||||
|
if (Platform.OS !== "android") return basePadding;
|
||||||
|
const statusBarHeight = StatusBar.currentHeight ?? 0;
|
||||||
|
return Math.max(basePadding, statusBarHeight + basePadding);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const middleware = useMemo(
|
||||||
|
() => [
|
||||||
|
floatingOffset({ mainAxis: 4 }),
|
||||||
|
flip({ padding: collisionPadding }),
|
||||||
|
// Avoid `crossAxis: true` here: per Floating UI docs it can cause overlap with the reference.
|
||||||
|
shift({ padding: collisionPadding }),
|
||||||
|
floatingSize({
|
||||||
|
padding: collisionPadding,
|
||||||
|
apply({ availableWidth, availableHeight, rects }) {
|
||||||
|
setAvailableSize((prev) => {
|
||||||
|
const next = { width: availableWidth, height: availableHeight };
|
||||||
|
if (!prev) return next;
|
||||||
|
if (prev.width === next.width && prev.height === next.height) return prev;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setReferenceWidth((prev) => {
|
||||||
|
const next = rects.reference.width;
|
||||||
|
if (prev === next) return prev;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[collisionPadding]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { refs, floatingStyles, update } = useFloating({
|
||||||
|
placement: "bottom-start",
|
||||||
|
middleware,
|
||||||
|
sameScrollView: false,
|
||||||
|
elements: {
|
||||||
|
reference: anchorRef.current ?? undefined,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible || isMobile) {
|
if (!visible || isMobile) {
|
||||||
|
setAvailableSize(null);
|
||||||
|
setReferenceWidth(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const raf = requestAnimationFrame(() => update());
|
||||||
const anchor = anchorRef.current;
|
return () => cancelAnimationFrame(raf);
|
||||||
if (!anchor) {
|
}, [isMobile, update, visible]);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
anchor.measureInWindow((x, y, width, height) => {
|
|
||||||
const verticalOffset = 4;
|
|
||||||
const horizontalMargin = 16;
|
|
||||||
const maxDropdownHeight = 400;
|
|
||||||
|
|
||||||
let top = y + height + verticalOffset;
|
|
||||||
const left = Math.max(horizontalMargin, Math.min(x, windowWidth - width - horizontalMargin));
|
|
||||||
|
|
||||||
if (top + maxDropdownHeight > windowHeight - horizontalMargin) {
|
|
||||||
top = y - maxDropdownHeight - verticalOffset;
|
|
||||||
if (top < horizontalMargin) {
|
|
||||||
top = y + height + verticalOffset;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setDropdownPosition({ top, left, width });
|
|
||||||
});
|
|
||||||
}, [visible, isMobile, anchorRef, windowWidth, windowHeight]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isMobile) return;
|
if (!isMobile) return;
|
||||||
@@ -367,19 +393,23 @@ export function AdaptiveSelect({
|
|||||||
visible={visible}
|
visible={visible}
|
||||||
onRequestClose={onClose}
|
onRequestClose={onClose}
|
||||||
>
|
>
|
||||||
<View style={styles.desktopDropdownOverlay}>
|
<View ref={refs.setOffsetParent} collapsable={false} style={styles.desktopDropdownOverlay}>
|
||||||
<Pressable style={styles.desktopDropdownBackdrop} onPress={onClose} />
|
<Pressable style={styles.desktopDropdownBackdrop} onPress={onClose} />
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.desktopDropdownContainer,
|
styles.desktopDropdownContainer,
|
||||||
{
|
{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: dropdownPosition.top,
|
|
||||||
left: dropdownPosition.left,
|
|
||||||
width: dropdownPosition.width,
|
|
||||||
minWidth: 200,
|
minWidth: 200,
|
||||||
|
width: referenceWidth ?? undefined,
|
||||||
},
|
},
|
||||||
|
floatingStyles,
|
||||||
|
typeof availableSize?.height === "number" ? { maxHeight: Math.min(availableSize.height, 400) } : null,
|
||||||
|
typeof availableSize?.width === "number" ? { maxWidth: availableSize.width } : null,
|
||||||
]}
|
]}
|
||||||
|
ref={refs.setFloating}
|
||||||
|
collapsable={false}
|
||||||
|
onLayout={() => update()}
|
||||||
>
|
>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={styles.desktopDropdownScrollContent}
|
contentContainerStyle={styles.desktopDropdownScrollContent}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { View, Text, Pressable } from "react-native";
|
import { View, Text } from "react-native";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { ChevronDown } from "lucide-react-native";
|
import { ChevronDown } from "lucide-react-native";
|
||||||
import { useState } from "react";
|
|
||||||
import { ModeSelectorModal } from "./mode-selector-modal";
|
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
|
||||||
interface AgentStatusBarProps {
|
interface AgentStatusBarProps {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
@@ -23,8 +28,6 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
|||||||
(state) => state.sessions[serverId]?.methods?.setAgentMode
|
(state) => state.sessions[serverId]?.methods?.setAgentMode
|
||||||
);
|
);
|
||||||
|
|
||||||
const [showModeSelector, setShowModeSelector] = useState(false);
|
|
||||||
|
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -39,30 +42,48 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
|||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{/* Agent Mode Badge */}
|
{/* Agent Mode Badge */}
|
||||||
{agent.availableModes && agent.availableModes.length > 0 && (
|
{agent.availableModes && agent.availableModes.length > 0 && (
|
||||||
<Pressable
|
<DropdownMenu>
|
||||||
onPress={() => setShowModeSelector(true)}
|
<DropdownMenuTrigger
|
||||||
style={({ pressed }) => [
|
style={({ pressed }) => [
|
||||||
styles.modeBadge,
|
styles.modeBadge,
|
||||||
pressed && styles.modeBadgePressed,
|
pressed && styles.modeBadgePressed,
|
||||||
]}
|
]}
|
||||||
>
|
accessibilityRole="button"
|
||||||
<Text style={styles.modeBadgeText}>
|
accessibilityLabel="Select agent mode"
|
||||||
{agent.availableModes?.find((m) => m.id === agent.currentModeId)
|
testID="agent-mode-selector"
|
||||||
?.label ||
|
>
|
||||||
agent.currentModeId ||
|
<Text style={styles.modeBadgeText}>
|
||||||
"default"}
|
{agent.availableModes?.find((m) => m.id === agent.currentModeId)
|
||||||
</Text>
|
?.label ||
|
||||||
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
|
agent.currentModeId ||
|
||||||
</Pressable>
|
"default"}
|
||||||
|
</Text>
|
||||||
|
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
side="top"
|
||||||
|
align="center"
|
||||||
|
fullWidth
|
||||||
|
testID="agent-mode-menu"
|
||||||
|
>
|
||||||
|
<DropdownMenuLabel>Mode</DropdownMenuLabel>
|
||||||
|
{agent.availableModes.map((mode) => {
|
||||||
|
const isActive = mode.id === agent.currentModeId;
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={mode.id}
|
||||||
|
selected={isActive}
|
||||||
|
selectedVariant="accent"
|
||||||
|
description={mode.description}
|
||||||
|
onSelect={() => handleModeChange(mode.id)}
|
||||||
|
>
|
||||||
|
{mode.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mode selector modal */}
|
|
||||||
<ModeSelectorModal
|
|
||||||
visible={showModeSelector}
|
|
||||||
agent={agent}
|
|
||||||
onModeChange={handleModeChange}
|
|
||||||
onClose={() => setShowModeSelector(false)}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,6 @@ import {
|
|||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Pressable,
|
Pressable,
|
||||||
FlatList,
|
FlatList,
|
||||||
Modal,
|
|
||||||
useWindowDimensions,
|
|
||||||
type LayoutChangeEvent,
|
|
||||||
type NativeSyntheticEvent,
|
type NativeSyntheticEvent,
|
||||||
type NativeScrollEvent,
|
type NativeScrollEvent,
|
||||||
type ListRenderItem,
|
type ListRenderItem,
|
||||||
@@ -32,6 +29,14 @@ import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-contex
|
|||||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||||
import { Fonts } from "@/constants/theme";
|
import { Fonts } from "@/constants/theme";
|
||||||
import { getNowMs, isPerfLoggingEnabled, perfLog } from "@/utils/perf";
|
import { getNowMs, isPerfLoggingEnabled, perfLog } from "@/utils/perf";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuHint,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
|
||||||
const DIFF_PANE_LOG_TAG = "[GitDiffPane]";
|
const DIFF_PANE_LOG_TAG = "[GitDiffPane]";
|
||||||
const DIFF_FILE_LOG_TAG = "[DiffFileSection]";
|
const DIFF_FILE_LOG_TAG = "[DiffFileSection]";
|
||||||
@@ -353,22 +358,12 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
|
||||||
const client = useSessionStore(
|
const client = useSessionStore(
|
||||||
(state) => state.sessions[serverId]?.client ?? null
|
(state) => state.sessions[serverId]?.client ?? null
|
||||||
);
|
);
|
||||||
const [diffMode, setDiffMode] = useState<"uncommitted" | "base">("uncommitted");
|
const [diffMode, setDiffMode] = useState<"uncommitted" | "base">("uncommitted");
|
||||||
const [actionError, setActionError] = useState<string | null>(null);
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
const [actionStatus, setActionStatus] = useState<string | null>(null);
|
const [actionStatus, setActionStatus] = useState<string | null>(null);
|
||||||
const [overflowVisible, setOverflowVisible] = useState(false);
|
|
||||||
const [overflowAnchor, setOverflowAnchor] = useState({ left: 0, top: 0 });
|
|
||||||
const [overflowHeight, setOverflowHeight] = useState(0);
|
|
||||||
const [viewMenuVisible, setViewMenuVisible] = useState(false);
|
|
||||||
const [viewMenuAnchor, setViewMenuAnchor] = useState({ left: 0, top: 0 });
|
|
||||||
const [viewMenuHeight, setViewMenuHeight] = useState(0);
|
|
||||||
const [shipMenuVisible, setShipMenuVisible] = useState(false);
|
|
||||||
const [shipMenuAnchor, setShipMenuAnchor] = useState({ left: 0, top: 0 });
|
|
||||||
const [shipMenuHeight, setShipMenuHeight] = useState(0);
|
|
||||||
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
|
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("merge");
|
||||||
const { status, isLoading: isStatusLoading, isFetching: isStatusFetching, isError: isStatusError, error: statusError, refresh: refreshStatus } =
|
const { status, isLoading: isStatusLoading, isFetching: isStatusFetching, isError: isStatusError, error: statusError, refresh: refreshStatus } =
|
||||||
useCheckoutStatusQuery({ serverId, agentId });
|
useCheckoutStatusQuery({ serverId, agentId });
|
||||||
@@ -475,63 +470,6 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
|||||||
[shipDefaultStorageKey]
|
[shipDefaultStorageKey]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleOpenOverflowMenu = useCallback((event: any) => {
|
|
||||||
const { pageX, pageY } = event?.nativeEvent ?? {};
|
|
||||||
setOverflowAnchor({
|
|
||||||
left: typeof pageX === "number" ? pageX : 0,
|
|
||||||
top: typeof pageY === "number" ? pageY : 0,
|
|
||||||
});
|
|
||||||
setOverflowVisible(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleCloseOverflowMenu = useCallback(() => {
|
|
||||||
setOverflowVisible(false);
|
|
||||||
setOverflowHeight(0);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleOverflowLayout = useCallback((event: LayoutChangeEvent) => {
|
|
||||||
const { height } = event.nativeEvent.layout;
|
|
||||||
setOverflowHeight((current) => (current === height ? current : height));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleOpenViewMenu = useCallback((event: any) => {
|
|
||||||
const { pageX, pageY } = event?.nativeEvent ?? {};
|
|
||||||
setViewMenuAnchor({
|
|
||||||
left: typeof pageX === "number" ? pageX : 0,
|
|
||||||
top: typeof pageY === "number" ? pageY : 0,
|
|
||||||
});
|
|
||||||
setViewMenuVisible(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleCloseViewMenu = useCallback(() => {
|
|
||||||
setViewMenuVisible(false);
|
|
||||||
setViewMenuHeight(0);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleViewMenuLayout = useCallback((event: LayoutChangeEvent) => {
|
|
||||||
const { height } = event.nativeEvent.layout;
|
|
||||||
setViewMenuHeight((current) => (current === height ? current : height));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleOpenShipMenu = useCallback((event: any) => {
|
|
||||||
const { pageX, pageY } = event?.nativeEvent ?? {};
|
|
||||||
setShipMenuAnchor({
|
|
||||||
left: typeof pageX === "number" ? pageX : 0,
|
|
||||||
top: typeof pageY === "number" ? pageY : 0,
|
|
||||||
});
|
|
||||||
setShipMenuVisible(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleCloseShipMenu = useCallback(() => {
|
|
||||||
setShipMenuVisible(false);
|
|
||||||
setShipMenuHeight(0);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleShipMenuLayout = useCallback((event: LayoutChangeEvent) => {
|
|
||||||
const { height } = event.nativeEvent.layout;
|
|
||||||
setShipMenuHeight((current) => (current === height ? current : height));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleToggleExpanded = useCallback((path: string) => {
|
const handleToggleExpanded = useCallback((path: string) => {
|
||||||
setExpandedByPath((prev) => ({
|
setExpandedByPath((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -824,81 +762,6 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
|||||||
return hasPullRequest ? false : prMutation.isPending;
|
return hasPullRequest ? false : prMutation.isPending;
|
||||||
}, [hasPullRequest, mergeMutation.isPending, prMutation.isPending, resolvedShipPrimary]);
|
}, [hasPullRequest, mergeMutation.isPending, prMutation.isPending, resolvedShipPrimary]);
|
||||||
|
|
||||||
const overflowPosition = useMemo(() => {
|
|
||||||
if (!overflowVisible) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const menuWidth = 220;
|
|
||||||
const horizontalPadding = theme.spacing[2];
|
|
||||||
const verticalPadding = theme.spacing[2];
|
|
||||||
const maxLeft = Math.max(horizontalPadding, windowWidth - menuWidth - horizontalPadding);
|
|
||||||
const maxTop = Math.max(verticalPadding, windowHeight - overflowHeight - verticalPadding);
|
|
||||||
const left = Math.min(
|
|
||||||
Math.max(overflowAnchor.left - menuWidth + horizontalPadding, horizontalPadding),
|
|
||||||
maxLeft
|
|
||||||
);
|
|
||||||
const top = Math.min(Math.max(overflowAnchor.top + verticalPadding, verticalPadding), maxTop);
|
|
||||||
return { top, left, width: menuWidth };
|
|
||||||
}, [
|
|
||||||
overflowAnchor.left,
|
|
||||||
overflowAnchor.top,
|
|
||||||
overflowHeight,
|
|
||||||
overflowVisible,
|
|
||||||
theme.spacing,
|
|
||||||
windowHeight,
|
|
||||||
windowWidth,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const viewMenuPosition = useMemo(() => {
|
|
||||||
if (!viewMenuVisible) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const menuWidth = 180;
|
|
||||||
const horizontalPadding = theme.spacing[2];
|
|
||||||
const verticalPadding = theme.spacing[2];
|
|
||||||
const maxLeft = Math.max(horizontalPadding, windowWidth - menuWidth - horizontalPadding);
|
|
||||||
const maxTop = Math.max(verticalPadding, windowHeight - viewMenuHeight - verticalPadding);
|
|
||||||
const left = Math.min(
|
|
||||||
Math.max(viewMenuAnchor.left - menuWidth + horizontalPadding, horizontalPadding),
|
|
||||||
maxLeft
|
|
||||||
);
|
|
||||||
const top = Math.min(Math.max(viewMenuAnchor.top + verticalPadding, verticalPadding), maxTop);
|
|
||||||
return { top, left, width: menuWidth };
|
|
||||||
}, [
|
|
||||||
theme.spacing,
|
|
||||||
viewMenuAnchor.left,
|
|
||||||
viewMenuAnchor.top,
|
|
||||||
viewMenuHeight,
|
|
||||||
viewMenuVisible,
|
|
||||||
windowHeight,
|
|
||||||
windowWidth,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const shipMenuPosition = useMemo(() => {
|
|
||||||
if (!shipMenuVisible) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const menuWidth = 220;
|
|
||||||
const horizontalPadding = theme.spacing[2];
|
|
||||||
const verticalPadding = theme.spacing[2];
|
|
||||||
const maxLeft = Math.max(horizontalPadding, windowWidth - menuWidth - horizontalPadding);
|
|
||||||
const maxTop = Math.max(verticalPadding, windowHeight - shipMenuHeight - verticalPadding);
|
|
||||||
const left = Math.min(
|
|
||||||
Math.max(shipMenuAnchor.left - menuWidth + horizontalPadding, horizontalPadding),
|
|
||||||
maxLeft
|
|
||||||
);
|
|
||||||
const top = Math.min(Math.max(shipMenuAnchor.top + verticalPadding, verticalPadding), maxTop);
|
|
||||||
return { top, left, width: menuWidth };
|
|
||||||
}, [
|
|
||||||
shipMenuAnchor.left,
|
|
||||||
shipMenuAnchor.top,
|
|
||||||
shipMenuHeight,
|
|
||||||
shipMenuVisible,
|
|
||||||
theme.spacing,
|
|
||||||
windowHeight,
|
|
||||||
windowWidth,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<View style={styles.header} testID="changes-header">
|
<View style={styles.header} testID="changes-header">
|
||||||
@@ -912,18 +775,35 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
{isGit ? (
|
{isGit ? (
|
||||||
<Pressable
|
<DropdownMenu>
|
||||||
testID="changes-view-selector"
|
<DropdownMenuTrigger
|
||||||
onPress={handleOpenViewMenu}
|
testID="changes-view-selector"
|
||||||
style={styles.viewSelector}
|
style={styles.viewSelector}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="Change diff view"
|
accessibilityLabel="Change diff view"
|
||||||
>
|
>
|
||||||
<Text style={styles.viewSelectorText}>
|
<Text style={styles.viewSelectorText}>
|
||||||
{diffMode === "uncommitted" ? "Working" : "Base"}
|
{diffMode === "uncommitted" ? "Working" : "Base"}
|
||||||
</Text>
|
</Text>
|
||||||
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
|
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
|
||||||
</Pressable>
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" width={180} testID="changes-view-menu">
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID="changes-mode-uncommitted"
|
||||||
|
selected={diffMode === "uncommitted"}
|
||||||
|
onSelect={() => setDiffMode("uncommitted")}
|
||||||
|
>
|
||||||
|
Working
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID="changes-mode-base"
|
||||||
|
selected={diffMode === "base"}
|
||||||
|
onSelect={() => setDiffMode("base")}
|
||||||
|
>
|
||||||
|
Base
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -965,15 +845,17 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
|||||||
if (resolvedShipPrimary === "merge") {
|
if (resolvedShipPrimary === "merge") {
|
||||||
if (mergeDisabled) return;
|
if (mergeDisabled) return;
|
||||||
mergeMutation.mutate();
|
mergeMutation.mutate();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (hasPullRequest && prStatus?.url) {
|
|
||||||
|
if (hasPullRequest && prStatus?.url) {
|
||||||
void Linking.openURL(prStatus.url);
|
void Linking.openURL(prStatus.url);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (prDisabled) return;
|
|
||||||
prMutation.mutate();
|
if (prDisabled) return;
|
||||||
}}
|
prMutation.mutate();
|
||||||
|
}}
|
||||||
disabled={shipPrimaryDisabled}
|
disabled={shipPrimaryDisabled}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="Ship changes"
|
accessibilityLabel="Ship changes"
|
||||||
@@ -991,27 +873,79 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable
|
<DropdownMenu>
|
||||||
testID="changes-ship-caret"
|
<DropdownMenuTrigger
|
||||||
onPress={handleOpenShipMenu}
|
testID="changes-ship-caret"
|
||||||
style={styles.shipCaretButton}
|
style={styles.shipCaretButton}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="More ship options"
|
accessibilityLabel="More ship options"
|
||||||
>
|
>
|
||||||
<ChevronDown size={16} color={theme.colors.foregroundMuted} />
|
<ChevronDown size={16} color={theme.colors.foregroundMuted} />
|
||||||
</Pressable>
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" width={260} testID="changes-ship-menu">
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID="changes-ship-merge"
|
||||||
|
disabled={mergeDisabled}
|
||||||
|
onSelect={() => {
|
||||||
|
void persistShipDefault("merge");
|
||||||
|
mergeMutation.mutate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Merge into {baseRefLabel}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{mergeDisabled && hasUncommittedChanges ? (
|
||||||
|
<DropdownMenuHint>Requires a clean working tree.</DropdownMenuHint>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID={hasPullRequest ? "changes-ship-open-pr" : "changes-ship-create-pr"}
|
||||||
|
disabled={prDisabled}
|
||||||
|
onSelect={() => {
|
||||||
|
void persistShipDefault("pr");
|
||||||
|
if (hasPullRequest && prStatus?.url) {
|
||||||
|
void Linking.openURL(prStatus.url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
prMutation.mutate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hasPullRequest ? "Open PR" : `Create PR into ${baseRefLabel}`}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
<Pressable
|
<DropdownMenu>
|
||||||
testID="changes-overflow-menu"
|
<DropdownMenuTrigger
|
||||||
onPress={handleOpenOverflowMenu}
|
testID="changes-overflow-menu"
|
||||||
hitSlop={8}
|
hitSlop={8}
|
||||||
style={styles.iconButton}
|
style={styles.iconButton}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="More actions"
|
accessibilityLabel="More actions"
|
||||||
>
|
>
|
||||||
<MoreVertical size={16} color={theme.colors.foregroundMuted} />
|
<MoreVertical size={16} color={theme.colors.foregroundMuted} />
|
||||||
</Pressable>
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" width={220} testID="changes-overflow-content">
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID="changes-menu-refresh"
|
||||||
|
leading={<RotateCcw size={16} color={theme.colors.foreground} />}
|
||||||
|
onSelect={handleRefresh}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
testID="changes-menu-archive"
|
||||||
|
destructive
|
||||||
|
disabled={archiveDisabled}
|
||||||
|
onSelect={() => archiveMutation.mutate()}
|
||||||
|
>
|
||||||
|
Archive worktree
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1040,195 +974,6 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<View style={styles.diffContainer}>{bodyContent}</View>
|
<View style={styles.diffContainer}>{bodyContent}</View>
|
||||||
|
|
||||||
<Modal
|
|
||||||
visible={overflowVisible}
|
|
||||||
animationType="fade"
|
|
||||||
transparent={true}
|
|
||||||
onRequestClose={handleCloseOverflowMenu}
|
|
||||||
>
|
|
||||||
<View style={styles.menuOverlay}>
|
|
||||||
<Pressable
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel="Overflow menu backdrop"
|
|
||||||
style={styles.menuBackdrop}
|
|
||||||
onPress={handleCloseOverflowMenu}
|
|
||||||
/>
|
|
||||||
{overflowPosition ? (
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.dropdownMenu,
|
|
||||||
{
|
|
||||||
position: "absolute",
|
|
||||||
top: overflowPosition.top,
|
|
||||||
left: overflowPosition.left,
|
|
||||||
width: overflowPosition.width,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
onLayout={handleOverflowLayout}
|
|
||||||
>
|
|
||||||
<Pressable
|
|
||||||
testID="changes-menu-refresh"
|
|
||||||
style={styles.menuItem}
|
|
||||||
onPress={() => {
|
|
||||||
handleCloseOverflowMenu();
|
|
||||||
handleRefresh();
|
|
||||||
}}
|
|
||||||
accessibilityRole="button"
|
|
||||||
>
|
|
||||||
<RotateCcw size={16} color={theme.colors.foreground} />
|
|
||||||
<Text style={styles.menuItemText}>Refresh</Text>
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
<View style={styles.menuDivider} />
|
|
||||||
|
|
||||||
<Pressable
|
|
||||||
testID="changes-menu-archive"
|
|
||||||
style={[
|
|
||||||
styles.menuItem,
|
|
||||||
styles.menuItemDestructive,
|
|
||||||
archiveDisabled && styles.menuItemDisabled,
|
|
||||||
]}
|
|
||||||
onPress={() => {
|
|
||||||
if (archiveDisabled) return;
|
|
||||||
handleCloseOverflowMenu();
|
|
||||||
archiveMutation.mutate();
|
|
||||||
}}
|
|
||||||
disabled={archiveDisabled}
|
|
||||||
accessibilityRole="button"
|
|
||||||
>
|
|
||||||
<Text style={[styles.menuItemText, styles.menuItemTextDestructive]}>
|
|
||||||
Archive worktree
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
visible={viewMenuVisible}
|
|
||||||
animationType="fade"
|
|
||||||
transparent={true}
|
|
||||||
onRequestClose={handleCloseViewMenu}
|
|
||||||
>
|
|
||||||
<View style={styles.menuOverlay}>
|
|
||||||
<Pressable
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel="View menu backdrop"
|
|
||||||
style={styles.menuBackdrop}
|
|
||||||
onPress={handleCloseViewMenu}
|
|
||||||
/>
|
|
||||||
{viewMenuPosition ? (
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.dropdownMenu,
|
|
||||||
{
|
|
||||||
position: "absolute",
|
|
||||||
top: viewMenuPosition.top,
|
|
||||||
left: viewMenuPosition.left,
|
|
||||||
width: viewMenuPosition.width,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
onLayout={handleViewMenuLayout}
|
|
||||||
>
|
|
||||||
<Pressable
|
|
||||||
testID="changes-mode-uncommitted"
|
|
||||||
style={[styles.menuItem, diffMode === "uncommitted" && styles.menuItemSelected]}
|
|
||||||
onPress={() => {
|
|
||||||
handleCloseViewMenu();
|
|
||||||
setDiffMode("uncommitted");
|
|
||||||
}}
|
|
||||||
accessibilityRole="button"
|
|
||||||
>
|
|
||||||
<Text style={styles.menuItemText}>Working</Text>
|
|
||||||
</Pressable>
|
|
||||||
<Pressable
|
|
||||||
testID="changes-mode-base"
|
|
||||||
style={[styles.menuItem, diffMode === "base" && styles.menuItemSelected]}
|
|
||||||
onPress={() => {
|
|
||||||
handleCloseViewMenu();
|
|
||||||
setDiffMode("base");
|
|
||||||
}}
|
|
||||||
accessibilityRole="button"
|
|
||||||
>
|
|
||||||
<Text style={styles.menuItemText}>Base</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<Modal
|
|
||||||
visible={shipMenuVisible}
|
|
||||||
animationType="fade"
|
|
||||||
transparent={true}
|
|
||||||
onRequestClose={handleCloseShipMenu}
|
|
||||||
>
|
|
||||||
<View style={styles.menuOverlay}>
|
|
||||||
<Pressable
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel="Ship menu backdrop"
|
|
||||||
style={styles.menuBackdrop}
|
|
||||||
onPress={handleCloseShipMenu}
|
|
||||||
/>
|
|
||||||
{shipMenuPosition ? (
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.dropdownMenu,
|
|
||||||
{
|
|
||||||
position: "absolute",
|
|
||||||
top: shipMenuPosition.top,
|
|
||||||
left: shipMenuPosition.left,
|
|
||||||
width: shipMenuPosition.width,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
onLayout={handleShipMenuLayout}
|
|
||||||
>
|
|
||||||
<Pressable
|
|
||||||
testID="changes-ship-merge"
|
|
||||||
style={[styles.menuItem, mergeDisabled && styles.menuItemDisabled]}
|
|
||||||
onPress={() => {
|
|
||||||
if (mergeDisabled) return;
|
|
||||||
handleCloseShipMenu();
|
|
||||||
void persistShipDefault("merge");
|
|
||||||
mergeMutation.mutate();
|
|
||||||
}}
|
|
||||||
disabled={mergeDisabled}
|
|
||||||
accessibilityRole="button"
|
|
||||||
>
|
|
||||||
<Text style={styles.menuItemText}>Merge into {baseRefLabel}</Text>
|
|
||||||
</Pressable>
|
|
||||||
{mergeDisabled && hasUncommittedChanges ? (
|
|
||||||
<Text style={styles.menuHintText}>Requires a clean working tree.</Text>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<View style={styles.menuDivider} />
|
|
||||||
|
|
||||||
<Pressable
|
|
||||||
testID={hasPullRequest ? "changes-ship-open-pr" : "changes-ship-create-pr"}
|
|
||||||
style={[styles.menuItem, prDisabled && styles.menuItemDisabled]}
|
|
||||||
onPress={() => {
|
|
||||||
if (prDisabled) return;
|
|
||||||
handleCloseShipMenu();
|
|
||||||
void persistShipDefault("pr");
|
|
||||||
if (hasPullRequest && prStatus?.url) {
|
|
||||||
void Linking.openURL(prStatus.url);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
prMutation.mutate();
|
|
||||||
}}
|
|
||||||
disabled={prDisabled}
|
|
||||||
accessibilityRole="button"
|
|
||||||
>
|
|
||||||
<Text style={styles.menuItemText}>
|
|
||||||
{hasPullRequest ? "Open PR" : `Create PR into ${baseRefLabel}`}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
</Modal>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { Dimensions, Platform, StatusBar, type View } from "react-native";
|
||||||
|
|
||||||
|
export type Placement = "top" | "bottom" | "left" | "right";
|
||||||
|
export type Alignment = "start" | "center" | "end";
|
||||||
|
|
||||||
|
interface Rect {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FloatingStyles {
|
||||||
|
position: "absolute";
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GeometryResult {
|
||||||
|
popoverOrigin: { x: number; y: number };
|
||||||
|
placement: Placement;
|
||||||
|
availableSize: { width: number; height: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
function measureElement(element: View): Promise<Rect> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
element.measureInWindow((x, y, width, height) => {
|
||||||
|
resolve({ x, y, width, height });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeGeometry({
|
||||||
|
fromRect,
|
||||||
|
contentSize,
|
||||||
|
displayArea,
|
||||||
|
placement,
|
||||||
|
alignment,
|
||||||
|
offset,
|
||||||
|
padding,
|
||||||
|
}: {
|
||||||
|
fromRect: Rect;
|
||||||
|
contentSize: { width: number; height: number };
|
||||||
|
displayArea: Rect;
|
||||||
|
placement: Placement;
|
||||||
|
alignment: Alignment;
|
||||||
|
offset: number;
|
||||||
|
padding: number;
|
||||||
|
}): GeometryResult {
|
||||||
|
const { width: contentWidth, height: contentHeight } = contentSize;
|
||||||
|
|
||||||
|
// Calculate available space in each direction
|
||||||
|
const spaceTop = fromRect.y - displayArea.y - padding;
|
||||||
|
const spaceBottom = displayArea.y + displayArea.height - (fromRect.y + fromRect.height) - padding;
|
||||||
|
const spaceLeft = fromRect.x - displayArea.x - padding;
|
||||||
|
const spaceRight = displayArea.x + displayArea.width - (fromRect.x + fromRect.width) - padding;
|
||||||
|
|
||||||
|
// Determine actual placement (may flip if not enough space)
|
||||||
|
let actualPlacement = placement;
|
||||||
|
if (placement === "bottom" && spaceBottom < contentHeight && spaceTop > spaceBottom) {
|
||||||
|
actualPlacement = "top";
|
||||||
|
} else if (placement === "top" && spaceTop < contentHeight && spaceBottom > spaceTop) {
|
||||||
|
actualPlacement = "bottom";
|
||||||
|
} else if (placement === "left" && spaceLeft < contentWidth && spaceRight > spaceLeft) {
|
||||||
|
actualPlacement = "right";
|
||||||
|
} else if (placement === "right" && spaceRight < contentWidth && spaceLeft > spaceRight) {
|
||||||
|
actualPlacement = "left";
|
||||||
|
}
|
||||||
|
|
||||||
|
let x: number;
|
||||||
|
let y: number;
|
||||||
|
|
||||||
|
// Position based on placement
|
||||||
|
if (actualPlacement === "bottom") {
|
||||||
|
y = fromRect.y + fromRect.height + offset;
|
||||||
|
} else if (actualPlacement === "top") {
|
||||||
|
y = fromRect.y - contentHeight - offset;
|
||||||
|
} else if (actualPlacement === "left") {
|
||||||
|
x = fromRect.x - contentWidth - offset;
|
||||||
|
} else {
|
||||||
|
x = fromRect.x + fromRect.width + offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alignment on cross axis
|
||||||
|
if (actualPlacement === "top" || actualPlacement === "bottom") {
|
||||||
|
if (alignment === "start") {
|
||||||
|
x = fromRect.x;
|
||||||
|
} else if (alignment === "end") {
|
||||||
|
x = fromRect.x + fromRect.width - contentWidth;
|
||||||
|
} else {
|
||||||
|
x = fromRect.x + (fromRect.width - contentWidth) / 2;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (alignment === "start") {
|
||||||
|
y = fromRect.y;
|
||||||
|
} else if (alignment === "end") {
|
||||||
|
y = fromRect.y + fromRect.height - contentHeight;
|
||||||
|
} else {
|
||||||
|
y = fromRect.y + (fromRect.height - contentHeight) / 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constrain to display area (shift)
|
||||||
|
const minX = displayArea.x + padding;
|
||||||
|
const maxX = displayArea.x + displayArea.width - contentWidth - padding;
|
||||||
|
const minY = displayArea.y + padding;
|
||||||
|
const maxY = displayArea.y + displayArea.height - contentHeight - padding;
|
||||||
|
|
||||||
|
x = Math.max(minX, Math.min(maxX, x!));
|
||||||
|
y = Math.max(minY, Math.min(maxY, y!));
|
||||||
|
|
||||||
|
// Calculate available size
|
||||||
|
const availableWidth = displayArea.width - padding * 2;
|
||||||
|
const availableHeight =
|
||||||
|
actualPlacement === "bottom"
|
||||||
|
? displayArea.y + displayArea.height - (fromRect.y + fromRect.height) - offset - padding
|
||||||
|
: actualPlacement === "top"
|
||||||
|
? fromRect.y - displayArea.y - offset - padding
|
||||||
|
: displayArea.height - padding * 2;
|
||||||
|
|
||||||
|
return {
|
||||||
|
popoverOrigin: { x, y },
|
||||||
|
placement: actualPlacement,
|
||||||
|
availableSize: {
|
||||||
|
width: Math.max(0, availableWidth),
|
||||||
|
height: Math.max(0, availableHeight),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseDropdownFloatingOptions {
|
||||||
|
open: boolean;
|
||||||
|
placement?: Placement;
|
||||||
|
alignment?: Alignment;
|
||||||
|
offset?: number;
|
||||||
|
padding?: number;
|
||||||
|
referenceEl: View | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseDropdownFloatingReturn {
|
||||||
|
floatingRef: (el: View | null) => void;
|
||||||
|
floatingStyles: FloatingStyles;
|
||||||
|
update: () => void;
|
||||||
|
onLayout: () => void;
|
||||||
|
availableSize: { width: number; height: number } | null;
|
||||||
|
actualPlacement: Placement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDropdownFloating({
|
||||||
|
open,
|
||||||
|
placement = "bottom",
|
||||||
|
alignment = "start",
|
||||||
|
offset = 8,
|
||||||
|
padding = 8,
|
||||||
|
referenceEl,
|
||||||
|
}: UseDropdownFloatingOptions): UseDropdownFloatingReturn {
|
||||||
|
const floatingElRef = useRef<View | null>(null);
|
||||||
|
const [geometry, setGeometry] = useState<GeometryResult | null>(null);
|
||||||
|
|
||||||
|
const displayArea = useMemo(() => {
|
||||||
|
const { width, height } = Dimensions.get("window");
|
||||||
|
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
|
||||||
|
return {
|
||||||
|
x: 0,
|
||||||
|
y: statusBarHeight,
|
||||||
|
width,
|
||||||
|
height: height - statusBarHeight,
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const update = useCallback(async () => {
|
||||||
|
console.log("[useDropdownFloating] update called", {
|
||||||
|
hasReferenceEl: !!referenceEl,
|
||||||
|
hasFloatingEl: !!floatingElRef.current,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!referenceEl || !floatingElRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [fromRect, contentRect] = await Promise.all([
|
||||||
|
measureElement(referenceEl),
|
||||||
|
measureElement(floatingElRef.current),
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log("[useDropdownFloating] measured", {
|
||||||
|
fromRect,
|
||||||
|
contentRect,
|
||||||
|
displayArea,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = computeGeometry({
|
||||||
|
fromRect,
|
||||||
|
contentSize: { width: contentRect.width, height: contentRect.height },
|
||||||
|
displayArea,
|
||||||
|
placement,
|
||||||
|
alignment,
|
||||||
|
offset,
|
||||||
|
padding,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("[useDropdownFloating] geometry result", result);
|
||||||
|
|
||||||
|
setGeometry(result);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[useDropdownFloating] measure failed:", e);
|
||||||
|
}
|
||||||
|
}, [referenceEl, displayArea, placement, alignment, offset, padding]);
|
||||||
|
|
||||||
|
const floatingRef = useCallback((el: View | null) => {
|
||||||
|
console.log("[useDropdownFloating] floatingRef called", { hasEl: !!el });
|
||||||
|
floatingElRef.current = el;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Track when floating element is ready
|
||||||
|
const [floatingReady, setFloatingReady] = useState(false);
|
||||||
|
|
||||||
|
const handleFloatingLayout = useCallback(() => {
|
||||||
|
console.log("[useDropdownFloating] handleFloatingLayout called");
|
||||||
|
setFloatingReady(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("[useDropdownFloating] useEffect", {
|
||||||
|
open,
|
||||||
|
floatingReady,
|
||||||
|
hasReferenceEl: !!referenceEl,
|
||||||
|
hasFloatingEl: !!floatingElRef.current,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
setGeometry(null);
|
||||||
|
setFloatingReady(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (floatingReady && referenceEl && floatingElRef.current) {
|
||||||
|
console.log("[useDropdownFloating] calling update from useEffect");
|
||||||
|
update();
|
||||||
|
}
|
||||||
|
}, [open, floatingReady, referenceEl, update]);
|
||||||
|
|
||||||
|
const floatingStyles: FloatingStyles = {
|
||||||
|
position: "absolute",
|
||||||
|
top: geometry?.popoverOrigin.y ?? 0,
|
||||||
|
left: geometry?.popoverOrigin.x ?? 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
floatingRef,
|
||||||
|
floatingStyles,
|
||||||
|
update,
|
||||||
|
onLayout: handleFloatingLayout,
|
||||||
|
availableSize: geometry?.availableSize ?? null,
|
||||||
|
actualPlacement: geometry?.placement ?? placement,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,557 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type PropsWithChildren,
|
||||||
|
type ReactElement,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
Text,
|
||||||
|
View,
|
||||||
|
Dimensions,
|
||||||
|
Platform,
|
||||||
|
StatusBar,
|
||||||
|
type PressableProps,
|
||||||
|
type ViewStyle,
|
||||||
|
} from "react-native";
|
||||||
|
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||||
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { Check } from "lucide-react-native";
|
||||||
|
|
||||||
|
type Placement = "top" | "bottom" | "left" | "right";
|
||||||
|
type Alignment = "start" | "center" | "end";
|
||||||
|
|
||||||
|
interface Rect {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DropdownMenuContextValue = {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
triggerRef: React.RefObject<View | null>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DropdownMenuContext = createContext<DropdownMenuContextValue | null>(null);
|
||||||
|
|
||||||
|
function useDropdownMenuContext(componentName: string): DropdownMenuContextValue {
|
||||||
|
const ctx = useContext(DropdownMenuContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error(`${componentName} must be used within <DropdownMenu />`);
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useControllableOpenState({
|
||||||
|
open,
|
||||||
|
defaultOpen,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
open?: boolean;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
}): [boolean, (next: boolean) => void] {
|
||||||
|
const [internalOpen, setInternalOpen] = useState(Boolean(defaultOpen));
|
||||||
|
const isControlled = typeof open === "boolean";
|
||||||
|
const value = isControlled ? Boolean(open) : internalOpen;
|
||||||
|
const setValue = useCallback(
|
||||||
|
(next: boolean) => {
|
||||||
|
if (!isControlled) setInternalOpen(next);
|
||||||
|
onOpenChange?.(next);
|
||||||
|
},
|
||||||
|
[isControlled, onOpenChange]
|
||||||
|
);
|
||||||
|
return [value, setValue];
|
||||||
|
}
|
||||||
|
|
||||||
|
function measureElement(element: View): Promise<Rect> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
element.measureInWindow((x, y, width, height) => {
|
||||||
|
resolve({ x, y, width, height });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function computePosition({
|
||||||
|
triggerRect,
|
||||||
|
contentSize,
|
||||||
|
displayArea,
|
||||||
|
placement,
|
||||||
|
alignment,
|
||||||
|
offset,
|
||||||
|
}: {
|
||||||
|
triggerRect: Rect;
|
||||||
|
contentSize: { width: number; height: number };
|
||||||
|
displayArea: Rect;
|
||||||
|
placement: Placement;
|
||||||
|
alignment: Alignment;
|
||||||
|
offset: number;
|
||||||
|
}): { x: number; y: number; actualPlacement: Placement } {
|
||||||
|
const { width: contentWidth, height: contentHeight } = contentSize;
|
||||||
|
|
||||||
|
// Calculate available space
|
||||||
|
const spaceTop = triggerRect.y - displayArea.y;
|
||||||
|
const spaceBottom = displayArea.y + displayArea.height - (triggerRect.y + triggerRect.height);
|
||||||
|
|
||||||
|
// Flip if needed
|
||||||
|
let actualPlacement = placement;
|
||||||
|
if (placement === "bottom" && spaceBottom < contentHeight && spaceTop > spaceBottom) {
|
||||||
|
actualPlacement = "top";
|
||||||
|
} else if (placement === "top" && spaceTop < contentHeight && spaceBottom > spaceTop) {
|
||||||
|
actualPlacement = "bottom";
|
||||||
|
}
|
||||||
|
|
||||||
|
let x: number;
|
||||||
|
let y: number;
|
||||||
|
|
||||||
|
// Position based on placement
|
||||||
|
if (actualPlacement === "bottom") {
|
||||||
|
y = triggerRect.y + triggerRect.height + offset;
|
||||||
|
} else if (actualPlacement === "top") {
|
||||||
|
y = triggerRect.y - contentHeight - offset;
|
||||||
|
} else if (actualPlacement === "left") {
|
||||||
|
x = triggerRect.x - contentWidth - offset;
|
||||||
|
y = triggerRect.y;
|
||||||
|
} else {
|
||||||
|
x = triggerRect.x + triggerRect.width + offset;
|
||||||
|
y = triggerRect.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alignment
|
||||||
|
if (actualPlacement === "top" || actualPlacement === "bottom") {
|
||||||
|
if (alignment === "start") {
|
||||||
|
x = triggerRect.x;
|
||||||
|
} else if (alignment === "end") {
|
||||||
|
x = triggerRect.x + triggerRect.width - contentWidth;
|
||||||
|
} else {
|
||||||
|
x = triggerRect.x + (triggerRect.width - contentWidth) / 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constrain to screen
|
||||||
|
const padding = 8;
|
||||||
|
x = Math.max(padding, Math.min(displayArea.width - contentWidth - padding, x!));
|
||||||
|
y = Math.max(displayArea.y + padding, Math.min(displayArea.y + displayArea.height - contentHeight - padding, y!));
|
||||||
|
|
||||||
|
return { x, y, actualPlacement };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DropdownMenu({
|
||||||
|
open,
|
||||||
|
defaultOpen,
|
||||||
|
onOpenChange,
|
||||||
|
children,
|
||||||
|
}: PropsWithChildren<{
|
||||||
|
open?: boolean;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
}>): ReactElement {
|
||||||
|
const triggerRef = useRef<View>(null);
|
||||||
|
const [isOpen, setIsOpen] = useControllableOpenState({
|
||||||
|
open,
|
||||||
|
defaultOpen,
|
||||||
|
onOpenChange,
|
||||||
|
});
|
||||||
|
|
||||||
|
const value = useMemo<DropdownMenuContextValue>(
|
||||||
|
() => ({
|
||||||
|
open: isOpen,
|
||||||
|
setOpen: setIsOpen,
|
||||||
|
triggerRef,
|
||||||
|
}),
|
||||||
|
[isOpen, setIsOpen]
|
||||||
|
);
|
||||||
|
|
||||||
|
return <DropdownMenuContext.Provider value={value}>{children}</DropdownMenuContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DropdownMenuTrigger({
|
||||||
|
children,
|
||||||
|
disabled,
|
||||||
|
...props
|
||||||
|
}: PropsWithChildren<PressableProps>): ReactElement {
|
||||||
|
const { setOpen, open, triggerRef } = useDropdownMenuContext("DropdownMenuTrigger");
|
||||||
|
|
||||||
|
const handlePress = useCallback(() => {
|
||||||
|
if (disabled) return;
|
||||||
|
setOpen(!open);
|
||||||
|
}, [disabled, open, setOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
{...props}
|
||||||
|
ref={triggerRef}
|
||||||
|
collapsable={false}
|
||||||
|
disabled={disabled}
|
||||||
|
onPress={handlePress}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DropdownMenuContent({
|
||||||
|
children,
|
||||||
|
side = "bottom",
|
||||||
|
align = "start",
|
||||||
|
offset = 4,
|
||||||
|
width,
|
||||||
|
minWidth = 180,
|
||||||
|
maxWidth,
|
||||||
|
fullWidth = false,
|
||||||
|
horizontalPadding = 16,
|
||||||
|
testID,
|
||||||
|
}: PropsWithChildren<{
|
||||||
|
side?: Placement;
|
||||||
|
align?: Alignment;
|
||||||
|
offset?: number;
|
||||||
|
width?: number;
|
||||||
|
minWidth?: number;
|
||||||
|
maxWidth?: number;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
horizontalPadding?: number;
|
||||||
|
testID?: string;
|
||||||
|
}>): ReactElement | null {
|
||||||
|
const { open, setOpen, triggerRef } = useDropdownMenuContext("DropdownMenuContent");
|
||||||
|
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
|
||||||
|
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
|
||||||
|
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setOpen(false);
|
||||||
|
}, [setOpen]);
|
||||||
|
|
||||||
|
// Measure trigger when opening
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && triggerRef.current) {
|
||||||
|
measureElement(triggerRef.current).then((rect) => {
|
||||||
|
// On Android with statusBarTranslucent, measureInWindow returns coordinates
|
||||||
|
// from screen top, but Modal content area also starts from screen top.
|
||||||
|
// However, the measured y doesn't account for status bar offset correctly,
|
||||||
|
// so we need to add it (same as react-native-popover-view does)
|
||||||
|
const statusBarAdjustment = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
|
||||||
|
const adjustedRect = {
|
||||||
|
...rect,
|
||||||
|
y: rect.y + statusBarAdjustment,
|
||||||
|
};
|
||||||
|
setTriggerRect(adjustedRect);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setTriggerRect(null);
|
||||||
|
setContentSize(null);
|
||||||
|
setPosition(null);
|
||||||
|
}
|
||||||
|
}, [open, triggerRef]);
|
||||||
|
|
||||||
|
// Calculate position when we have both measurements
|
||||||
|
useEffect(() => {
|
||||||
|
if (!triggerRect || !contentSize) return;
|
||||||
|
|
||||||
|
const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
|
||||||
|
// measureInWindow returns screen coordinates including status bar
|
||||||
|
// Modal also uses full screen coordinates, so displayArea should start at 0
|
||||||
|
const displayArea = {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: screenWidth,
|
||||||
|
height: screenHeight,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = computePosition({
|
||||||
|
triggerRect,
|
||||||
|
contentSize,
|
||||||
|
displayArea,
|
||||||
|
placement: side,
|
||||||
|
alignment: align,
|
||||||
|
offset,
|
||||||
|
});
|
||||||
|
|
||||||
|
// For fullWidth, x is simply the horizontal padding to center on screen
|
||||||
|
const x = fullWidth ? horizontalPadding : result.x;
|
||||||
|
setPosition({ x, y: result.y });
|
||||||
|
}, [triggerRect, contentSize, side, align, offset, fullWidth, horizontalPadding]);
|
||||||
|
|
||||||
|
const handleContentLayout = useCallback((event: { nativeEvent: { layout: { width: number; height: number } } }) => {
|
||||||
|
const { width: w, height: h } = event.nativeEvent.layout;
|
||||||
|
setContentSize({ width: w, height: h });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
const { width: screenWidth } = Dimensions.get("window");
|
||||||
|
const resolvedWidthStyle: ViewStyle = fullWidth
|
||||||
|
? { width: screenWidth - horizontalPadding * 2 }
|
||||||
|
: {
|
||||||
|
...(typeof width === "number" ? { width } : null),
|
||||||
|
...(typeof minWidth === "number" ? { minWidth } : null),
|
||||||
|
...(typeof maxWidth === "number" ? { maxWidth } : null),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={open}
|
||||||
|
transparent
|
||||||
|
animationType="none"
|
||||||
|
statusBarTranslucent={Platform.OS === "android"}
|
||||||
|
onRequestClose={handleClose}
|
||||||
|
>
|
||||||
|
<View style={styles.overlay}>
|
||||||
|
<Pressable
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="Menu backdrop"
|
||||||
|
style={styles.backdrop}
|
||||||
|
onPress={handleClose}
|
||||||
|
testID={testID ? `${testID}-backdrop` : undefined}
|
||||||
|
/>
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeIn.duration(100)}
|
||||||
|
exiting={FadeOut.duration(100)}
|
||||||
|
collapsable={false}
|
||||||
|
testID={testID}
|
||||||
|
onLayout={handleContentLayout}
|
||||||
|
style={[
|
||||||
|
styles.content,
|
||||||
|
resolvedWidthStyle,
|
||||||
|
{
|
||||||
|
position: "absolute",
|
||||||
|
top: position?.y ?? -9999,
|
||||||
|
left: position?.x ?? -9999,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<ScrollView bounces={false} showsVerticalScrollIndicator contentContainerStyle={{ flexGrow: 1 }}>
|
||||||
|
{children}
|
||||||
|
</ScrollView>
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DropdownMenuLabel({
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
testID,
|
||||||
|
}: PropsWithChildren<{ style?: ViewStyle | ViewStyle[]; testID?: string }>): ReactElement {
|
||||||
|
return (
|
||||||
|
<View style={[styles.labelContainer, style]} testID={testID}>
|
||||||
|
<Text style={styles.labelText}>{children}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DropdownMenuSeparator({ style, testID }: { style?: ViewStyle; testID?: string }): ReactElement {
|
||||||
|
return <View style={[styles.separator, style]} testID={testID} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DropdownMenuHint({
|
||||||
|
children,
|
||||||
|
testID,
|
||||||
|
}: PropsWithChildren<{ testID?: string }>): ReactElement {
|
||||||
|
return (
|
||||||
|
<View style={styles.hintContainer} testID={testID}>
|
||||||
|
<Text style={styles.hintText}>{children}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DropdownMenuItem({
|
||||||
|
children,
|
||||||
|
description,
|
||||||
|
onSelect,
|
||||||
|
disabled,
|
||||||
|
destructive,
|
||||||
|
selected,
|
||||||
|
showSelectedCheck = false,
|
||||||
|
selectedVariant = "default",
|
||||||
|
leading,
|
||||||
|
trailing,
|
||||||
|
closeOnSelect = true,
|
||||||
|
testID,
|
||||||
|
}: PropsWithChildren<{
|
||||||
|
description?: string;
|
||||||
|
onSelect?: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
destructive?: boolean;
|
||||||
|
selected?: boolean;
|
||||||
|
showSelectedCheck?: boolean;
|
||||||
|
selectedVariant?: "default" | "accent";
|
||||||
|
leading?: ReactElement | null;
|
||||||
|
trailing?: ReactElement | null;
|
||||||
|
closeOnSelect?: boolean;
|
||||||
|
testID?: string;
|
||||||
|
}>): ReactElement {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const { setOpen } = useDropdownMenuContext("DropdownMenuItem");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
testID={testID}
|
||||||
|
accessibilityRole="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onPress={() => {
|
||||||
|
if (disabled) return;
|
||||||
|
if (closeOnSelect) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
onSelect?.();
|
||||||
|
}}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.item,
|
||||||
|
selected ? (selectedVariant === "accent" ? styles.itemSelectedAccent : styles.itemSelected) : null,
|
||||||
|
destructive ? styles.itemDestructive : null,
|
||||||
|
disabled ? styles.itemDisabled : null,
|
||||||
|
pressed && !disabled ? styles.itemPressed : null,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{showSelectedCheck ? (
|
||||||
|
<View style={styles.checkSlot}>
|
||||||
|
{selected ? <Check size={16} color={theme.colors.foreground} /> : null}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{leading ? <View style={styles.leadingSlot}>{leading}</View> : null}
|
||||||
|
<View style={styles.itemContent}>
|
||||||
|
<Text
|
||||||
|
numberOfLines={1}
|
||||||
|
style={[
|
||||||
|
styles.itemText,
|
||||||
|
destructive ? styles.itemTextDestructive : null,
|
||||||
|
selected && selectedVariant === "accent" ? styles.itemTextSelectedAccent : null,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
{description ? (
|
||||||
|
<Text
|
||||||
|
numberOfLines={2}
|
||||||
|
style={[
|
||||||
|
styles.itemDescription,
|
||||||
|
selected && selectedVariant === "accent" ? styles.itemDescriptionSelectedAccent : null,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
{trailing ? <View style={styles.trailingSlot}>{trailing}</View> : null}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
overlay: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
backdrop: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
backgroundColor: theme.colors.surface0,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.borderAccent,
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
overflow: "hidden",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.2,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
labelContainer: {
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
paddingTop: theme.spacing[2],
|
||||||
|
paddingBottom: theme.spacing[1],
|
||||||
|
},
|
||||||
|
labelText: {
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
},
|
||||||
|
separator: {
|
||||||
|
height: 1,
|
||||||
|
backgroundColor: theme.colors.border,
|
||||||
|
},
|
||||||
|
hintContainer: {
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
paddingBottom: theme.spacing[2],
|
||||||
|
},
|
||||||
|
hintText: {
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
},
|
||||||
|
item: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
minHeight: 36,
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
paddingVertical: theme.spacing[2],
|
||||||
|
},
|
||||||
|
itemPressed: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
itemSelected: {
|
||||||
|
backgroundColor: theme.colors.surface2,
|
||||||
|
},
|
||||||
|
itemSelectedAccent: {
|
||||||
|
backgroundColor: theme.colors.accent,
|
||||||
|
},
|
||||||
|
itemDisabled: {
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
|
itemDestructive: {
|
||||||
|
backgroundColor: "rgba(248, 81, 73, 0.08)",
|
||||||
|
},
|
||||||
|
itemText: {
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontWeight: theme.fontWeight.medium,
|
||||||
|
},
|
||||||
|
itemTextDestructive: {
|
||||||
|
color: theme.colors.destructive,
|
||||||
|
},
|
||||||
|
itemTextSelectedAccent: {
|
||||||
|
color: theme.colors.accentForeground,
|
||||||
|
},
|
||||||
|
itemDescription: {
|
||||||
|
marginTop: 2,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
color: theme.colors.foregroundMuted,
|
||||||
|
},
|
||||||
|
itemDescriptionSelectedAccent: {
|
||||||
|
color: theme.colors.accentForeground,
|
||||||
|
opacity: 0.85,
|
||||||
|
},
|
||||||
|
checkSlot: {
|
||||||
|
width: 16,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
leadingSlot: {
|
||||||
|
width: 16,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
trailingSlot: {
|
||||||
|
marginLeft: "auto",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
itemContent: {
|
||||||
|
flexShrink: 1,
|
||||||
|
},
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user