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.
342 lines
9.0 KiB
TypeScript
342 lines
9.0 KiB
TypeScript
import React, { useMemo, useRef } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import {
|
|
ActivityIndicator,
|
|
Image as RNImage,
|
|
ScrollView as RNScrollView,
|
|
Text,
|
|
View,
|
|
} from "react-native";
|
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
|
import { Fonts } from "@/constants/theme";
|
|
import { useSessionStore, type ExplorerFile } from "@/stores/session-store";
|
|
import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar";
|
|
import {
|
|
highlightCode,
|
|
darkHighlightColors,
|
|
lightHighlightColors,
|
|
type HighlightToken,
|
|
type HighlightStyle,
|
|
} from "@getpaseo/highlight";
|
|
import { lineNumberGutterWidth } from "@/components/code-insets";
|
|
import { isWeb } from "@/constants/platform";
|
|
|
|
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,
|
|
userSelect: "none",
|
|
},
|
|
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.colorScheme === "dark";
|
|
const colorMap = isDark ? darkHighlightColors : lightHighlightColors;
|
|
const baseColor = isDark ? "#c9d1d9" : "#24292f";
|
|
|
|
const previewScrollRef = useRef<RNScrollView>(null);
|
|
const scrollbar = useWebScrollViewScrollbar(previewScrollRef, {
|
|
enabled: showDesktopWebScrollbar,
|
|
});
|
|
|
|
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;
|
|
return lineNumberGutterWidth(highlightedLines.length);
|
|
}, [highlightedLines]);
|
|
|
|
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={scrollbar.onLayout}
|
|
onScroll={scrollbar.onScroll}
|
|
onContentSizeChange={scrollbar.onContentSizeChange}
|
|
scrollEventThrottle={16}
|
|
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
|
>
|
|
{isMobile ? (
|
|
<View style={styles.previewCodeScrollContent}>{codeLines}</View>
|
|
) : (
|
|
<RNScrollView
|
|
horizontal
|
|
nestedScrollEnabled
|
|
showsHorizontalScrollIndicator
|
|
contentContainerStyle={styles.previewCodeScrollContent}
|
|
>
|
|
{codeLines}
|
|
</RNScrollView>
|
|
)}
|
|
</RNScrollView>
|
|
{scrollbar.overlay}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (preview.kind === "image" && preview.content) {
|
|
return (
|
|
<View style={styles.previewScrollContainer}>
|
|
<RNScrollView
|
|
ref={previewScrollRef}
|
|
style={styles.previewContent}
|
|
contentContainerStyle={styles.previewImageScrollContent}
|
|
onLayout={scrollbar.onLayout}
|
|
onScroll={scrollbar.onScroll}
|
|
onContentSizeChange={scrollbar.onContentSizeChange}
|
|
scrollEventThrottle={16}
|
|
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
|
>
|
|
<RNImage
|
|
source={{
|
|
uri: `data:${preview.mimeType ?? "image/png"};base64,${preview.content}`,
|
|
}}
|
|
style={styles.previewImage}
|
|
resizeMode="contain"
|
|
/>
|
|
</RNScrollView>
|
|
{scrollbar.overlay}
|
|
</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 = useIsCompactFormFactor();
|
|
const showDesktopWebScrollbar = isWeb && !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,
|
|
},
|
|
}));
|