mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Update files
This commit is contained in:
58
package-lock.json
generated
58
package-lock.json
generated
@@ -4643,6 +4643,34 @@
|
||||
"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": {
|
||||
"version": "5.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@gorhom/bottom-sheet/-/bottom-sheet-5.2.6.tgz",
|
||||
@@ -7176,6 +7204,12 @@
|
||||
"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": {
|
||||
"version": "0.82.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.82.1.tgz",
|
||||
@@ -11937,6 +11971,17 @@
|
||||
"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": {
|
||||
"version": "1.2.0",
|
||||
"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": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.3.tgz",
|
||||
@@ -25052,7 +25107,9 @@
|
||||
"dependencies": {
|
||||
"@boudra/expo-two-way-audio": "^0.1.3",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/css": "^1.3.0",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
@@ -25105,6 +25162,7 @@
|
||||
"react-native-markdown-display": "^7.0.2",
|
||||
"react-native-nitro-modules": "^0.30.0",
|
||||
"react-native-permissions": "^5.4.2",
|
||||
"react-native-popover-view": "^6.1.0",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
|
||||
@@ -21,7 +21,9 @@
|
||||
"dependencies": {
|
||||
"@boudra/expo-two-way-audio": "^0.1.3",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/css": "^1.3.0",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
@@ -74,6 +76,7 @@
|
||||
"react-native-markdown-display": "^7.0.2",
|
||||
"react-native-nitro-modules": "^0.30.0",
|
||||
"react-native-permissions": "^5.4.2",
|
||||
"react-native-popover-view": "^6.1.0",
|
||||
"react-native-reanimated": "~4.1.1",
|
||||
"react-native-safe-area-context": "~5.6.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 { GestureHandlerRootView, Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||
import { PortalProvider } from "@gorhom/portal";
|
||||
import { RealtimeProvider } from "@/contexts/realtime-context";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { useFaviconStatus } from "@/hooks/use-favicon-status";
|
||||
@@ -306,42 +307,44 @@ function MissingDaemonView() {
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<SafeAreaProvider>
|
||||
<KeyboardProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<QueryProvider>
|
||||
<DaemonRegistryProvider>
|
||||
<DaemonConnectionsProvider>
|
||||
<MultiDaemonSessionHost />
|
||||
<ProvidersWrapper>
|
||||
<SidebarAnimationProvider>
|
||||
<HorizontalScrollProvider>
|
||||
<AppWithSidebar>
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
animation: "none",
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="agents" />
|
||||
<Stack.Screen name="orchestrator" />
|
||||
<Stack.Screen name="agent/[id]" options={{ gestureEnabled: false }} />
|
||||
<Stack.Screen name="agent/[serverId]/[agentId]" options={{ gestureEnabled: false }} />
|
||||
<Stack.Screen name="settings" />
|
||||
<Stack.Screen name="audio-test" />
|
||||
<Stack.Screen name="pair-scan" />
|
||||
</Stack>
|
||||
</AppWithSidebar>
|
||||
</HorizontalScrollProvider>
|
||||
</SidebarAnimationProvider>
|
||||
</ProvidersWrapper>
|
||||
</DaemonConnectionsProvider>
|
||||
</DaemonRegistryProvider>
|
||||
</QueryProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</KeyboardProvider>
|
||||
</SafeAreaProvider>
|
||||
<PortalProvider>
|
||||
<SafeAreaProvider>
|
||||
<KeyboardProvider>
|
||||
<BottomSheetModalProvider>
|
||||
<QueryProvider>
|
||||
<DaemonRegistryProvider>
|
||||
<DaemonConnectionsProvider>
|
||||
<MultiDaemonSessionHost />
|
||||
<ProvidersWrapper>
|
||||
<SidebarAnimationProvider>
|
||||
<HorizontalScrollProvider>
|
||||
<AppWithSidebar>
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
animation: "none",
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="agents" />
|
||||
<Stack.Screen name="orchestrator" />
|
||||
<Stack.Screen name="agent/[id]" options={{ gestureEnabled: false }} />
|
||||
<Stack.Screen name="agent/[serverId]/[agentId]" options={{ gestureEnabled: false }} />
|
||||
<Stack.Screen name="settings" />
|
||||
<Stack.Screen name="audio-test" />
|
||||
<Stack.Screen name="pair-scan" />
|
||||
</Stack>
|
||||
</AppWithSidebar>
|
||||
</HorizontalScrollProvider>
|
||||
</SidebarAnimationProvider>
|
||||
</ProvidersWrapper>
|
||||
</DaemonConnectionsProvider>
|
||||
</DaemonRegistryProvider>
|
||||
</QueryProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</KeyboardProvider>
|
||||
</SafeAreaProvider>
|
||||
</PortalProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,9 +4,6 @@ import {
|
||||
Text,
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
Modal,
|
||||
useWindowDimensions,
|
||||
LayoutChangeEvent,
|
||||
ScrollView,
|
||||
Platform,
|
||||
BackHandler,
|
||||
@@ -58,6 +55,13 @@ import {
|
||||
import { extractAgentModel } from "@/utils/extract-agent-model";
|
||||
import { startPerfMonitor } from "@/utils/perf-monitor";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
const DROPDOWN_WIDTH = 220;
|
||||
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
||||
@@ -169,12 +173,7 @@ function AgentScreenContent({
|
||||
const { theme } = useUnistyles();
|
||||
const insets = useSafeAreaInsets();
|
||||
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 handleFilesDropped = useCallback((files: ImageAttachment[]) => {
|
||||
@@ -464,101 +463,22 @@ function AgentScreenContent({
|
||||
}
|
||||
}, [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(() => {
|
||||
handleCloseMenu();
|
||||
setExplorerTab("changes");
|
||||
openFileExplorer();
|
||||
}, [handleCloseMenu, setExplorerTab, openFileExplorer]);
|
||||
}, [setExplorerTab, openFileExplorer]);
|
||||
|
||||
const handleBrowseFiles = useCallback(() => {
|
||||
handleCloseMenu();
|
||||
setExplorerTab("files");
|
||||
openFileExplorer();
|
||||
}, [handleCloseMenu, setExplorerTab, openFileExplorer]);
|
||||
}, [setExplorerTab, openFileExplorer]);
|
||||
|
||||
const handleRefreshAgent = useCallback(() => {
|
||||
if (!resolvedAgentId || !refreshAgent) {
|
||||
return;
|
||||
}
|
||||
handleCloseMenu();
|
||||
refreshAgent({ agentId: resolvedAgentId });
|
||||
}, [handleCloseMenu, resolvedAgentId, refreshAgent]);
|
||||
}, [resolvedAgentId, refreshAgent]);
|
||||
|
||||
if (!agent) {
|
||||
return (
|
||||
@@ -601,11 +521,99 @@ function AgentScreenContent({
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
<View ref={menuButtonRef} collapsable={false}>
|
||||
<Pressable testID="agent-overflow-menu" onPress={handleOpenMenu} style={styles.menuButton}>
|
||||
<DropdownMenu
|
||||
onOpenChange={(open) => {
|
||||
if (open && agent?.cwd) {
|
||||
refetchRepoInfo().catch(() => {});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger testID="agent-overflow-menu" style={styles.menuButton}>
|
||||
<MoreVertical size={16} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</DropdownMenuTrigger>
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
@@ -640,114 +648,6 @@ function AgentScreenContent({
|
||||
<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>
|
||||
</FileDropZone>
|
||||
|
||||
@@ -924,25 +824,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
padding: theme.spacing[3],
|
||||
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: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
gap: theme.spacing[2],
|
||||
marginBottom: theme.spacing[2],
|
||||
},
|
||||
menuMetaRow: {
|
||||
gap: theme.spacing[1],
|
||||
@@ -969,28 +854,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
menuMetaValueError: {
|
||||
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: {
|
||||
marginLeft: "auto",
|
||||
},
|
||||
menuItemText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.foreground,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -7,9 +7,9 @@ import {
|
||||
Modal,
|
||||
TextInput,
|
||||
ActivityIndicator,
|
||||
useWindowDimensions,
|
||||
ScrollView,
|
||||
Platform,
|
||||
StatusBar,
|
||||
} from "react-native";
|
||||
import { StyleSheet, UnistylesRuntime } from "react-native-unistyles";
|
||||
import {
|
||||
@@ -21,6 +21,13 @@ import {
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import Animated from "react-native-reanimated";
|
||||
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 type {
|
||||
AgentMode,
|
||||
@@ -263,43 +270,62 @@ export function AdaptiveSelect({
|
||||
}: AdaptiveSelectProps): ReactElement {
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
||||
const bottomSheetRef = useRef<BottomSheetModal>(null);
|
||||
const snapPoints = useMemo(() => ["60%", "90%"], []);
|
||||
const [dropdownPosition, setDropdownPosition] = useState({
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: 0,
|
||||
const [availableSize, setAvailableSize] = useState<{ width?: number; height?: number } | null>(null);
|
||||
const [referenceWidth, setReferenceWidth] = useState<number | null>(null);
|
||||
|
||||
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(() => {
|
||||
if (!visible || isMobile) {
|
||||
setAvailableSize(null);
|
||||
setReferenceWidth(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const anchor = anchorRef.current;
|
||||
if (!anchor) {
|
||||
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]);
|
||||
const raf = requestAnimationFrame(() => update());
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [isMobile, update, visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile) return;
|
||||
@@ -367,19 +393,23 @@ export function AdaptiveSelect({
|
||||
visible={visible}
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<View style={styles.desktopDropdownOverlay}>
|
||||
<View ref={refs.setOffsetParent} collapsable={false} style={styles.desktopDropdownOverlay}>
|
||||
<Pressable style={styles.desktopDropdownBackdrop} onPress={onClose} />
|
||||
<View
|
||||
style={[
|
||||
styles.desktopDropdownContainer,
|
||||
{
|
||||
position: "absolute",
|
||||
top: dropdownPosition.top,
|
||||
left: dropdownPosition.left,
|
||||
width: dropdownPosition.width,
|
||||
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
|
||||
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 { ChevronDown } from "lucide-react-native";
|
||||
import { useState } from "react";
|
||||
import { ModeSelectorModal } from "./mode-selector-modal";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
interface AgentStatusBarProps {
|
||||
agentId: string;
|
||||
@@ -23,8 +28,6 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||
(state) => state.sessions[serverId]?.methods?.setAgentMode
|
||||
);
|
||||
|
||||
const [showModeSelector, setShowModeSelector] = useState(false);
|
||||
|
||||
if (!agent) {
|
||||
return null;
|
||||
}
|
||||
@@ -39,30 +42,48 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
|
||||
<View style={styles.container}>
|
||||
{/* Agent Mode Badge */}
|
||||
{agent.availableModes && agent.availableModes.length > 0 && (
|
||||
<Pressable
|
||||
onPress={() => setShowModeSelector(true)}
|
||||
style={({ pressed }) => [
|
||||
styles.modeBadge,
|
||||
pressed && styles.modeBadgePressed,
|
||||
]}
|
||||
>
|
||||
<Text style={styles.modeBadgeText}>
|
||||
{agent.availableModes?.find((m) => m.id === agent.currentModeId)
|
||||
?.label ||
|
||||
agent.currentModeId ||
|
||||
"default"}
|
||||
</Text>
|
||||
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
style={({ pressed }) => [
|
||||
styles.modeBadge,
|
||||
pressed && styles.modeBadgePressed,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Select agent mode"
|
||||
testID="agent-mode-selector"
|
||||
>
|
||||
<Text style={styles.modeBadgeText}>
|
||||
{agent.availableModes?.find((m) => m.id === agent.currentModeId)
|
||||
?.label ||
|
||||
agent.currentModeId ||
|
||||
"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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,9 +6,6 @@ import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
FlatList,
|
||||
Modal,
|
||||
useWindowDimensions,
|
||||
type LayoutChangeEvent,
|
||||
type NativeSyntheticEvent,
|
||||
type NativeScrollEvent,
|
||||
type ListRenderItem,
|
||||
@@ -32,6 +29,14 @@ import { useHorizontalScrollOptional } from "@/contexts/horizontal-scroll-contex
|
||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
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_FILE_LOG_TAG = "[DiffFileSection]";
|
||||
@@ -353,22 +358,12 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
||||
const client = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.client ?? null
|
||||
);
|
||||
const [diffMode, setDiffMode] = useState<"uncommitted" | "base">("uncommitted");
|
||||
const [actionError, setActionError] = 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 { status, isLoading: isStatusLoading, isFetching: isStatusFetching, isError: isStatusError, error: statusError, refresh: refreshStatus } =
|
||||
useCheckoutStatusQuery({ serverId, agentId });
|
||||
@@ -475,63 +470,6 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
||||
[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) => {
|
||||
setExpandedByPath((prev) => ({
|
||||
...prev,
|
||||
@@ -824,81 +762,6 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
||||
return hasPullRequest ? false : prMutation.isPending;
|
||||
}, [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 (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header} testID="changes-header">
|
||||
@@ -912,18 +775,35 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
||||
)}
|
||||
</View>
|
||||
{isGit ? (
|
||||
<Pressable
|
||||
testID="changes-view-selector"
|
||||
onPress={handleOpenViewMenu}
|
||||
style={styles.viewSelector}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Change diff view"
|
||||
>
|
||||
<Text style={styles.viewSelectorText}>
|
||||
{diffMode === "uncommitted" ? "Working" : "Base"}
|
||||
</Text>
|
||||
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
testID="changes-view-selector"
|
||||
style={styles.viewSelector}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Change diff view"
|
||||
>
|
||||
<Text style={styles.viewSelectorText}>
|
||||
{diffMode === "uncommitted" ? "Working" : "Base"}
|
||||
</Text>
|
||||
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
|
||||
</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}
|
||||
</View>
|
||||
|
||||
@@ -965,15 +845,17 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
||||
if (resolvedShipPrimary === "merge") {
|
||||
if (mergeDisabled) return;
|
||||
mergeMutation.mutate();
|
||||
return;
|
||||
}
|
||||
if (hasPullRequest && prStatus?.url) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasPullRequest && prStatus?.url) {
|
||||
void Linking.openURL(prStatus.url);
|
||||
return;
|
||||
}
|
||||
if (prDisabled) return;
|
||||
prMutation.mutate();
|
||||
}}
|
||||
}
|
||||
|
||||
if (prDisabled) return;
|
||||
prMutation.mutate();
|
||||
}}
|
||||
disabled={shipPrimaryDisabled}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Ship changes"
|
||||
@@ -991,27 +873,79 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
||||
</>
|
||||
)}
|
||||
</Pressable>
|
||||
<Pressable
|
||||
testID="changes-ship-caret"
|
||||
onPress={handleOpenShipMenu}
|
||||
style={styles.shipCaretButton}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="More ship options"
|
||||
>
|
||||
<ChevronDown size={16} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
testID="changes-ship-caret"
|
||||
style={styles.shipCaretButton}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="More ship options"
|
||||
>
|
||||
<ChevronDown size={16} color={theme.colors.foregroundMuted} />
|
||||
</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>
|
||||
) : null}
|
||||
<Pressable
|
||||
testID="changes-overflow-menu"
|
||||
onPress={handleOpenOverflowMenu}
|
||||
hitSlop={8}
|
||||
style={styles.iconButton}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="More actions"
|
||||
>
|
||||
<MoreVertical size={16} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
testID="changes-overflow-menu"
|
||||
hitSlop={8}
|
||||
style={styles.iconButton}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="More actions"
|
||||
>
|
||||
<MoreVertical size={16} color={theme.colors.foregroundMuted} />
|
||||
</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>
|
||||
) : null}
|
||||
@@ -1040,195 +974,6 @@ export function GitDiffPane({ serverId, agentId }: GitDiffPaneProps) {
|
||||
) : null}
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
259
packages/app/src/components/ui/dropdown-menu-floating.ts
Normal file
259
packages/app/src/components/ui/dropdown-menu-floating.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
557
packages/app/src/components/ui/dropdown-menu.tsx
Normal file
557
packages/app/src/components/ui/dropdown-menu.tsx
Normal file
@@ -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