import { useCallback, useEffect, useMemo } from "react";
import {
ActivityIndicator,
Pressable,
Text,
View,
type PressableStateCallbackType,
} 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;
hideLabels?: boolean;
}
interface EditorMenuItemProps {
editor: EditorTargetDescriptorPayload;
isPreferred: boolean;
onOpen: (editorId: EditorTargetId) => void;
foregroundMuted: string;
}
function EditorMenuItem({ editor, isPreferred, onOpen, foregroundMuted }: EditorMenuItemProps) {
const handleSelect = useCallback(() => onOpen(editor.id), [onOpen, editor.id]);
const leading = useMemo(
() => ,
[editor.id, foregroundMuted],
);
const trailing = useMemo(
() => (isPreferred ? : undefined),
[isPreferred, foregroundMuted],
);
return (
{editor.label}
);
}
export function WorkspaceOpenInEditorButton({
serverId,
cwd,
hideLabels,
}: 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({
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 availableEditorsRaw = availableEditorsQuery.data;
const availableEditors = useMemo(() => availableEditorsRaw ?? [], [availableEditorsRaw]);
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],
);
const primaryPressableStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.splitButtonPrimary,
(Boolean(hovered) || pressed) && styles.splitButtonPrimaryHovered,
openMutation.isPending && styles.splitButtonPrimaryDisabled,
],
[openMutation.isPending],
);
const caretTriggerStyle = useCallback(
({ hovered, pressed, open }: { hovered: boolean; pressed: boolean; open: boolean }) => [
styles.splitButtonCaret,
(hovered || pressed || open) && styles.splitButtonCaretHovered,
],
[],
);
const primaryId = primaryOption?.id;
const handlePrimaryPress = useCallback(() => {
if (primaryId) handleOpenEditor(primaryId);
}, [primaryId, handleOpenEditor]);
if (!shouldLoadEditors || !primaryOption || availableEditors.length === 0) {
return null;
}
return (
{openMutation.isPending ? (
) : (
{!hideLabels && Open}
)}
{availableEditors.length > 1 ? (
{availableEditors.map((editor: EditorTargetDescriptorPayload) => (
))}
) : null}
);
}
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: theme.spacing[3],
paddingVertical: theme.spacing[1],
justifyContent: "center",
position: "relative",
},
splitButtonPrimaryIconOnly: {
paddingLeft: theme.spacing[2],
paddingRight: theme.spacing[2],
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,
},
}));