288 lines
8.3 KiB
TypeScript
288 lines
8.3 KiB
TypeScript
import { useCallback, useMemo, useRef } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import {
|
|
ActivityIndicator,
|
|
Image as RNImage,
|
|
ScrollView as RNScrollView,
|
|
Text,
|
|
View,
|
|
Platform,
|
|
type LayoutChangeEvent,
|
|
type NativeScrollEvent,
|
|
type NativeSyntheticEvent,
|
|
} from "react-native";
|
|
import { StyleSheet, UnistylesRuntime } from "react-native-unistyles";
|
|
import { Fonts } from "@/constants/theme";
|
|
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
|
|
import {
|
|
WebDesktopScrollbarOverlay,
|
|
useWebDesktopScrollbarMetrics,
|
|
} from "@/components/web-desktop-scrollbar";
|
|
|
|
function trimNonEmpty(value: string | null | undefined): string | null {
|
|
if (typeof value !== "string") {
|
|
return null;
|
|
}
|
|
const trimmed = value.trim();
|
|
return trimmed.length > 0 ? trimmed : null;
|
|
}
|
|
|
|
function formatFileSize({ size }: { size: number }): string {
|
|
if (size < 1024) {
|
|
return `${size} B`;
|
|
}
|
|
if (size < 1024 * 1024) {
|
|
return `${(size / 1024).toFixed(1)} KB`;
|
|
}
|
|
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
|
}
|
|
|
|
function FilePreviewBody({
|
|
preview,
|
|
isLoading,
|
|
showDesktopWebScrollbar,
|
|
isMobile,
|
|
}: {
|
|
preview: ExplorerFile | null;
|
|
isLoading: boolean;
|
|
showDesktopWebScrollbar: boolean;
|
|
isMobile: boolean;
|
|
}) {
|
|
const enablePreviewDesktopScrollbar = showDesktopWebScrollbar;
|
|
const previewScrollRef = useRef<RNScrollView>(null);
|
|
const previewScrollbarMetrics = useWebDesktopScrollbarMetrics();
|
|
|
|
const handlePreviewScroll = useCallback(
|
|
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
if (enablePreviewDesktopScrollbar) {
|
|
previewScrollbarMetrics.onScroll(event);
|
|
}
|
|
},
|
|
[enablePreviewDesktopScrollbar, previewScrollbarMetrics]
|
|
);
|
|
|
|
const handlePreviewLayout = useCallback(
|
|
(event: LayoutChangeEvent) => {
|
|
if (enablePreviewDesktopScrollbar) {
|
|
previewScrollbarMetrics.onLayout(event);
|
|
}
|
|
},
|
|
[enablePreviewDesktopScrollbar, previewScrollbarMetrics]
|
|
);
|
|
|
|
if (isLoading && !preview) {
|
|
return (
|
|
<View style={styles.centerState}>
|
|
<ActivityIndicator size="small" />
|
|
<Text style={styles.loadingText}>Loading file…</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (!preview) {
|
|
return (
|
|
<View style={styles.centerState}>
|
|
<Text style={styles.emptyText}>No preview available</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (preview.kind === "text") {
|
|
return (
|
|
<View style={styles.previewScrollContainer}>
|
|
<RNScrollView
|
|
ref={previewScrollRef}
|
|
style={styles.previewContent}
|
|
onLayout={enablePreviewDesktopScrollbar ? handlePreviewLayout : undefined}
|
|
onScroll={enablePreviewDesktopScrollbar ? handlePreviewScroll : undefined}
|
|
onContentSizeChange={
|
|
enablePreviewDesktopScrollbar
|
|
? previewScrollbarMetrics.onContentSizeChange
|
|
: undefined
|
|
}
|
|
scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined}
|
|
showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar}
|
|
>
|
|
{isMobile ? (
|
|
<View style={styles.previewCodeScrollContent}>
|
|
<Text style={styles.codeText}>{preview.content}</Text>
|
|
</View>
|
|
) : (
|
|
<RNScrollView
|
|
horizontal
|
|
nestedScrollEnabled
|
|
showsHorizontalScrollIndicator
|
|
contentContainerStyle={styles.previewCodeScrollContent}
|
|
>
|
|
<Text style={styles.codeText}>{preview.content}</Text>
|
|
</RNScrollView>
|
|
)}
|
|
</RNScrollView>
|
|
<WebDesktopScrollbarOverlay
|
|
enabled={enablePreviewDesktopScrollbar}
|
|
metrics={previewScrollbarMetrics}
|
|
onScrollToOffset={(nextOffset) => {
|
|
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false });
|
|
}}
|
|
/>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (preview.kind === "image" && preview.content) {
|
|
return (
|
|
<View style={styles.previewScrollContainer}>
|
|
<RNScrollView
|
|
ref={previewScrollRef}
|
|
style={styles.previewContent}
|
|
contentContainerStyle={styles.previewImageScrollContent}
|
|
onLayout={enablePreviewDesktopScrollbar ? handlePreviewLayout : undefined}
|
|
onScroll={enablePreviewDesktopScrollbar ? handlePreviewScroll : undefined}
|
|
onContentSizeChange={
|
|
enablePreviewDesktopScrollbar
|
|
? previewScrollbarMetrics.onContentSizeChange
|
|
: undefined
|
|
}
|
|
scrollEventThrottle={enablePreviewDesktopScrollbar ? 16 : undefined}
|
|
showsVerticalScrollIndicator={!enablePreviewDesktopScrollbar}
|
|
>
|
|
<RNImage
|
|
source={{
|
|
uri: `data:${preview.mimeType ?? "image/png"};base64,${preview.content}`,
|
|
}}
|
|
style={styles.previewImage}
|
|
resizeMode="contain"
|
|
/>
|
|
</RNScrollView>
|
|
<WebDesktopScrollbarOverlay
|
|
enabled={enablePreviewDesktopScrollbar}
|
|
metrics={previewScrollbarMetrics}
|
|
onScrollToOffset={(nextOffset) => {
|
|
previewScrollRef.current?.scrollTo({ y: nextOffset, animated: false });
|
|
}}
|
|
/>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<View style={styles.centerState}>
|
|
<Text style={styles.emptyText}>Binary preview unavailable</Text>
|
|
<Text style={styles.binaryMetaText}>{formatFileSize({ size: preview.size })}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
export function FilePane({
|
|
serverId,
|
|
workspaceRoot,
|
|
filePath,
|
|
}: {
|
|
serverId: string;
|
|
workspaceRoot: string;
|
|
filePath: string;
|
|
}) {
|
|
const isMobile =
|
|
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
|
const showDesktopWebScrollbar = Platform.OS === "web" && !isMobile;
|
|
|
|
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
|
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
|
|
const normalizedFilePath = useMemo(() => trimNonEmpty(filePath), [filePath]);
|
|
|
|
const query = useQuery({
|
|
queryKey: ["workspaceFile", serverId, normalizedWorkspaceRoot, normalizedFilePath],
|
|
enabled: Boolean(client && normalizedWorkspaceRoot && normalizedFilePath),
|
|
queryFn: async () => {
|
|
if (!client || !normalizedWorkspaceRoot || !normalizedFilePath) {
|
|
return { file: null as ExplorerFile | null, error: "Host is not connected" };
|
|
}
|
|
const payload = await client.exploreFileSystem(
|
|
normalizedWorkspaceRoot,
|
|
normalizedFilePath,
|
|
"file"
|
|
);
|
|
return { file: payload.file ?? null, error: payload.error ?? null };
|
|
},
|
|
staleTime: 5_000,
|
|
});
|
|
|
|
return (
|
|
<View style={styles.container} testID="workspace-file-pane">
|
|
{query.data?.error ? (
|
|
<View style={styles.centerState}>
|
|
<Text style={styles.errorText}>{query.data.error}</Text>
|
|
</View>
|
|
) : null}
|
|
|
|
<FilePreviewBody
|
|
preview={query.data?.file ?? null}
|
|
isLoading={query.isFetching}
|
|
showDesktopWebScrollbar={showDesktopWebScrollbar}
|
|
isMobile={isMobile}
|
|
/>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create((theme) => ({
|
|
container: {
|
|
flex: 1,
|
|
minHeight: 0,
|
|
backgroundColor: theme.colors.surface0,
|
|
},
|
|
centerState: {
|
|
flex: 1,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
padding: theme.spacing[4],
|
|
},
|
|
loadingText: {
|
|
marginTop: theme.spacing[2],
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.sm,
|
|
},
|
|
errorText: {
|
|
color: theme.colors.destructive,
|
|
fontSize: theme.fontSize.sm,
|
|
textAlign: "center",
|
|
},
|
|
emptyText: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.sm,
|
|
textAlign: "center",
|
|
},
|
|
binaryMetaText: {
|
|
marginTop: theme.spacing[2],
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.sm,
|
|
},
|
|
previewScrollContainer: {
|
|
flex: 1,
|
|
minHeight: 0,
|
|
},
|
|
previewContent: {
|
|
flex: 1,
|
|
minHeight: 0,
|
|
},
|
|
previewCodeScrollContent: {
|
|
padding: theme.spacing[4],
|
|
},
|
|
codeText: {
|
|
fontFamily: Fonts.mono,
|
|
fontSize: theme.fontSize.sm,
|
|
lineHeight: theme.fontSize.sm * 1.45,
|
|
color: theme.colors.foreground,
|
|
},
|
|
previewImageScrollContent: {
|
|
flexGrow: 1,
|
|
padding: theme.spacing[4],
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
previewImage: {
|
|
width: "100%",
|
|
height: 420,
|
|
},
|
|
}));
|