Add `packages/app/src/constants/platform.ts` with canonical gates (isWeb, isNative, getIsElectron) and refactor all ~100 ad-hoc Platform.OS checks across 69 files to use shared imports. Fix sidebar hover crash on native (onPointerEnter → onHoverIn), fix tooltip to use breakpoint instead of platform detection, make sidebar action buttons always visible on native where hover doesn't work, and document the platform gating decision matrix in CLAUDE.md.
236 lines
7.7 KiB
TypeScript
236 lines
7.7 KiB
TypeScript
import { useCallback, useEffect, useMemo } from "react";
|
|
import { ActivityIndicator, Pressable, Text, View } from "react-native";
|
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
import { Check, ChevronDown } from "lucide-react-native";
|
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|
import type { EditorTargetDescriptorPayload, EditorTargetId } from "@server/shared/messages";
|
|
import { EditorAppIcon } from "@/components/icons/editor-app-icons";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { useToast } from "@/contexts/toast-context";
|
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
|
import { resolvePreferredEditorId, usePreferredEditor } from "@/hooks/use-preferred-editor";
|
|
import { isAbsolutePath } from "@/utils/path";
|
|
import { isWeb } from "@/constants/platform";
|
|
|
|
interface WorkspaceOpenInEditorButtonProps {
|
|
serverId: string;
|
|
cwd: string;
|
|
}
|
|
|
|
export function WorkspaceOpenInEditorButton({ serverId, cwd }: WorkspaceOpenInEditorButtonProps) {
|
|
const { theme } = useUnistyles();
|
|
const toast = useToast();
|
|
const client = useHostRuntimeClient(serverId);
|
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
|
const { preferredEditorId, updatePreferredEditor } = usePreferredEditor();
|
|
|
|
const shouldLoadEditors =
|
|
isWeb && Boolean(client && isConnected) && cwd.trim().length > 0 && isAbsolutePath(cwd);
|
|
|
|
const availableEditorsQuery = useQuery<EditorTargetDescriptorPayload[]>({
|
|
queryKey: ["available-editors", serverId],
|
|
enabled: shouldLoadEditors,
|
|
staleTime: 60_000,
|
|
retry: false,
|
|
queryFn: async () => {
|
|
if (!client) {
|
|
return [];
|
|
}
|
|
try {
|
|
const payload = await client.listAvailableEditors();
|
|
return payload.error ? [] : payload.editors;
|
|
} catch {
|
|
return [];
|
|
}
|
|
},
|
|
});
|
|
|
|
const availableEditors = availableEditorsQuery.data ?? [];
|
|
const availableEditorIds = useMemo(
|
|
() => availableEditors.map((editor: EditorTargetDescriptorPayload) => editor.id),
|
|
[availableEditors],
|
|
);
|
|
const effectivePreferredEditorId = useMemo(
|
|
() => resolvePreferredEditorId(availableEditorIds, preferredEditorId),
|
|
[availableEditorIds, preferredEditorId],
|
|
);
|
|
const primaryOption =
|
|
availableEditors.find(
|
|
(editor: EditorTargetDescriptorPayload) => editor.id === effectivePreferredEditorId,
|
|
) ?? null;
|
|
|
|
useEffect(() => {
|
|
if (!effectivePreferredEditorId || effectivePreferredEditorId === preferredEditorId) {
|
|
return;
|
|
}
|
|
void updatePreferredEditor(effectivePreferredEditorId).catch(() => undefined);
|
|
}, [effectivePreferredEditorId, preferredEditorId, updatePreferredEditor]);
|
|
|
|
const openMutation = useMutation({
|
|
mutationFn: async (editorId: EditorTargetId) => {
|
|
if (!client) {
|
|
throw new Error("Host is not connected");
|
|
}
|
|
const payload = await client.openInEditor(cwd, editorId);
|
|
if (payload.error) {
|
|
throw new Error(payload.error);
|
|
}
|
|
return editorId;
|
|
},
|
|
onError: (error: unknown) => {
|
|
toast.error(error instanceof Error ? error.message : "Failed to open in editor");
|
|
},
|
|
});
|
|
|
|
const handleOpenEditor = useCallback(
|
|
(editorId: EditorTargetId) => {
|
|
void updatePreferredEditor(editorId).catch(() => undefined);
|
|
openMutation.mutate(editorId);
|
|
},
|
|
[openMutation, updatePreferredEditor],
|
|
);
|
|
|
|
if (!shouldLoadEditors || !primaryOption || availableEditors.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<View style={styles.row}>
|
|
<View style={styles.splitButton}>
|
|
<Pressable
|
|
testID="workspace-open-in-editor-primary"
|
|
style={({ hovered, pressed }) => [
|
|
styles.splitButtonPrimary,
|
|
(hovered || pressed) && styles.splitButtonPrimaryHovered,
|
|
openMutation.isPending && styles.splitButtonPrimaryDisabled,
|
|
]}
|
|
onPress={() => handleOpenEditor(primaryOption.id)}
|
|
disabled={openMutation.isPending}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={`Open workspace in ${primaryOption.label}`}
|
|
>
|
|
{openMutation.isPending ? (
|
|
<ActivityIndicator
|
|
size="small"
|
|
color={theme.colors.foreground}
|
|
style={styles.splitButtonSpinnerOnly}
|
|
/>
|
|
) : (
|
|
<View style={styles.splitButtonContent}>
|
|
<EditorAppIcon
|
|
editorId={primaryOption.id}
|
|
size={16}
|
|
color={theme.colors.foregroundMuted}
|
|
/>
|
|
<Text style={styles.splitButtonText}>Open</Text>
|
|
</View>
|
|
)}
|
|
</Pressable>
|
|
{availableEditors.length > 1 ? (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
testID="workspace-open-in-editor-caret"
|
|
style={({ hovered, pressed, open }) => [
|
|
styles.splitButtonCaret,
|
|
(hovered || pressed || open) && styles.splitButtonCaretHovered,
|
|
]}
|
|
accessibilityRole="button"
|
|
accessibilityLabel="Choose editor"
|
|
>
|
|
<ChevronDown size={16} color={theme.colors.foregroundMuted} />
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
align="end"
|
|
minWidth={148}
|
|
maxWidth={176}
|
|
testID="workspace-open-in-editor-menu"
|
|
>
|
|
{availableEditors.map((editor: EditorTargetDescriptorPayload) => (
|
|
<DropdownMenuItem
|
|
key={editor.id}
|
|
testID={`workspace-open-in-editor-item-${editor.id}`}
|
|
leading={
|
|
<EditorAppIcon
|
|
editorId={editor.id}
|
|
size={16}
|
|
color={theme.colors.foregroundMuted}
|
|
/>
|
|
}
|
|
trailing={
|
|
editor.id === effectivePreferredEditorId ? (
|
|
<Check size={16} color={theme.colors.foregroundMuted} />
|
|
) : undefined
|
|
}
|
|
onSelect={() => handleOpenEditor(editor.id)}
|
|
>
|
|
{editor.label}
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
) : null}
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create((theme) => ({
|
|
row: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[1],
|
|
flexShrink: 0,
|
|
},
|
|
splitButton: {
|
|
flexDirection: "row",
|
|
alignItems: "stretch",
|
|
borderRadius: theme.borderRadius.md,
|
|
borderWidth: theme.borderWidth[1],
|
|
borderColor: theme.colors.borderAccent,
|
|
overflow: "hidden",
|
|
},
|
|
splitButtonPrimary: {
|
|
paddingLeft: theme.spacing[3],
|
|
paddingRight: 10,
|
|
paddingVertical: theme.spacing[1],
|
|
justifyContent: "center",
|
|
position: "relative",
|
|
},
|
|
splitButtonPrimaryHovered: {
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
splitButtonPrimaryDisabled: {
|
|
opacity: 0.6,
|
|
},
|
|
splitButtonText: {
|
|
fontSize: theme.fontSize.sm,
|
|
lineHeight: theme.fontSize.sm * 1.5,
|
|
color: theme.colors.foreground,
|
|
fontWeight: theme.fontWeight.normal,
|
|
},
|
|
splitButtonContent: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: theme.spacing[2],
|
|
},
|
|
splitButtonSpinnerOnly: {
|
|
transform: [{ scale: 0.8 }],
|
|
},
|
|
splitButtonCaret: {
|
|
width: 28,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
borderLeftWidth: theme.borderWidth[1],
|
|
borderLeftColor: theme.colors.borderAccent,
|
|
},
|
|
splitButtonCaretHovered: {
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
}));
|