379 lines
11 KiB
TypeScript
379 lines
11 KiB
TypeScript
import React, { 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, useUnistyles } 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";
|
|
import {
|
|
highlightCode,
|
|
darkHighlightColors,
|
|
lightHighlightColors,
|
|
type HighlightToken,
|
|
type HighlightStyle,
|
|
} from "@getpaseo/highlight";
|
|
|
|
interface CodeLineProps {
|
|
tokens: HighlightToken[];
|
|
lineNumber: number;
|
|
gutterWidth: number;
|
|
colorMap: Record<HighlightStyle, string>;
|
|
baseColor: string;
|
|
}
|
|
|
|
interface FilePreviewBodyProps {
|
|
preview: ExplorerFile | null;
|
|
isLoading: boolean;
|
|
showDesktopWebScrollbar: boolean;
|
|
isMobile: boolean;
|
|
filePath: string;
|
|
}
|
|
|
|
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`;
|
|
}
|
|
|
|
const CodeLine = React.memo(function CodeLine({
|
|
tokens,
|
|
lineNumber,
|
|
gutterWidth,
|
|
colorMap,
|
|
baseColor,
|
|
}: CodeLineProps) {
|
|
return (
|
|
<View style={codeLineStyles.line}>
|
|
<View style={[codeLineStyles.gutter, { width: gutterWidth }]}>
|
|
<Text style={[codeLineStyles.gutterText, { color: baseColor }]}>{String(lineNumber)}</Text>
|
|
</View>
|
|
<Text style={codeLineStyles.lineText}>
|
|
{tokens.map((token, index) => (
|
|
<Text
|
|
key={index}
|
|
style={{ color: token.style ? (colorMap[token.style] ?? baseColor) : baseColor }}
|
|
>
|
|
{token.text}
|
|
</Text>
|
|
))}
|
|
</Text>
|
|
</View>
|
|
);
|
|
});
|
|
|
|
const codeLineStyles = StyleSheet.create((theme) => ({
|
|
line: {
|
|
flexDirection: "row",
|
|
},
|
|
gutter: {
|
|
alignItems: "flex-end",
|
|
paddingRight: theme.spacing[3],
|
|
flexShrink: 0,
|
|
},
|
|
gutterText: {
|
|
fontFamily: Fonts.mono,
|
|
fontSize: theme.fontSize.sm,
|
|
lineHeight: theme.fontSize.sm * 1.45,
|
|
opacity: 0.4,
|
|
},
|
|
lineText: {
|
|
fontFamily: Fonts.mono,
|
|
fontSize: theme.fontSize.sm,
|
|
lineHeight: theme.fontSize.sm * 1.45,
|
|
flex: 1,
|
|
},
|
|
}));
|
|
|
|
function FilePreviewBody({
|
|
preview,
|
|
isLoading,
|
|
showDesktopWebScrollbar,
|
|
isMobile,
|
|
filePath,
|
|
}: FilePreviewBodyProps) {
|
|
const { theme } = useUnistyles();
|
|
const isDark = theme.colors.surface0 === "#18181c";
|
|
const colorMap = isDark ? darkHighlightColors : lightHighlightColors;
|
|
const baseColor = isDark ? "#c9d1d9" : "#24292f";
|
|
|
|
const enablePreviewDesktopScrollbar = showDesktopWebScrollbar;
|
|
const previewScrollRef = useRef<RNScrollView>(null);
|
|
const previewScrollbarMetrics = useWebDesktopScrollbarMetrics();
|
|
|
|
const highlightedLines = useMemo(() => {
|
|
if (!preview || preview.kind !== "text") {
|
|
return null;
|
|
}
|
|
|
|
return highlightCode(preview.content ?? "", filePath);
|
|
}, [preview?.kind, preview?.content, filePath]);
|
|
|
|
const gutterWidth = useMemo(() => {
|
|
if (!highlightedLines) return 0;
|
|
const digits = String(highlightedLines.length).length;
|
|
return Math.max(digits * 8 + 12, 28);
|
|
}, [highlightedLines]);
|
|
|
|
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") {
|
|
const lines = highlightedLines ?? [[{ text: preview.content ?? "", style: null }]];
|
|
const codeLines = (
|
|
<View>
|
|
{lines.map((tokens, index) => (
|
|
<CodeLine
|
|
key={index}
|
|
tokens={tokens}
|
|
lineNumber={index + 1}
|
|
gutterWidth={gutterWidth}
|
|
colorMap={colorMap}
|
|
baseColor={baseColor}
|
|
/>
|
|
))}
|
|
</View>
|
|
);
|
|
|
|
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}>{codeLines}</View>
|
|
) : (
|
|
<RNScrollView
|
|
horizontal
|
|
nestedScrollEnabled
|
|
showsHorizontalScrollIndicator
|
|
contentContainerStyle={styles.previewCodeScrollContent}
|
|
>
|
|
{codeLines}
|
|
</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}
|
|
filePath={filePath}
|
|
/>
|
|
</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],
|
|
},
|
|
previewImageScrollContent: {
|
|
flexGrow: 1,
|
|
padding: theme.spacing[4],
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
previewImage: {
|
|
width: "100%",
|
|
height: 420,
|
|
},
|
|
}));
|