mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Replace base64-in-JSON file transfers with binary WebSocket frames to
unblock the JS thread when assistant markdown messages contain images.
Receive cost (JSON.parse over multi-MB base64) and persist cost (base64
re-encode to disk) both removed.
- Split daemon-client `exploreFileSystem` into `listDirectory` + `readFile`;
`readFile` returns `{ bytes, mime, size, modifiedAt }`. Encoding stays
private to the seam.
- New `packages/server/src/shared/binary-frames/` module hosts terminal
frames (moved from `terminal-stream-protocol.ts`) and new file-transfer
opcodes (`FileBegin` / `FileChunk` / `FileEnd`) with request-id correlation.
- Add optional `acceptBinary` to `FileExplorerRequestSchema`. New daemons
emit binary frames when set; legacy JSON path remains for old clients.
Compat fallback lives only inside `readFile`.
- New `persistAttachmentFromBytes` writes bytes directly: native via
`expo-file-system` `File.write(Uint8Array)`, web via Blob -> IndexedDB,
desktop via new `write_attachment_bytes` IPC. `persistAttachmentFromBase64`
removed.
465 lines
13 KiB
TypeScript
465 lines
13 KiB
TypeScript
import React, { useMemo, useRef } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import type { FileReadResult } from "@server/client/daemon-client";
|
|
import Markdown, { MarkdownIt } from "react-native-markdown-display";
|
|
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 { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style";
|
|
import {
|
|
highlightCode,
|
|
darkHighlightColors,
|
|
lightHighlightColors,
|
|
type HighlightToken,
|
|
type HighlightStyle,
|
|
} from "@getpaseo/highlight";
|
|
import { lineNumberGutterWidth } from "@/components/code-insets";
|
|
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
|
|
import { isWeb } from "@/constants/platform";
|
|
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
|
import type { AttachmentMetadata } from "@/attachments/types";
|
|
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
|
import { persistAttachmentFromBytes } from "@/attachments/service";
|
|
import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils";
|
|
import { explorerFileFromReadResult } from "@/file-explorer/read-result";
|
|
|
|
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;
|
|
imagePreviewUri: string | null;
|
|
}
|
|
|
|
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`;
|
|
}
|
|
|
|
async function createFilePanePreview(file: FileReadResult | null): Promise<{
|
|
file: ExplorerFile | null;
|
|
imageAttachment: AttachmentMetadata | null;
|
|
}> {
|
|
if (!file) {
|
|
return { file: null, imageAttachment: null };
|
|
}
|
|
|
|
const explorerFile = explorerFileFromReadResult(file);
|
|
if (file.kind !== "image") {
|
|
return { file: explorerFile, imageAttachment: null };
|
|
}
|
|
|
|
const imageAttachment = await persistAttachmentFromBytes({
|
|
id: createPreviewAttachmentId({
|
|
mimeType: file.mime,
|
|
path: file.path,
|
|
size: file.size,
|
|
modifiedAt: file.modifiedAt,
|
|
contentLength: file.bytes.byteLength,
|
|
}),
|
|
bytes: file.bytes,
|
|
mimeType: file.mime,
|
|
fileName: getFileNameFromPath(file.path),
|
|
});
|
|
|
|
return {
|
|
file: explorerFile,
|
|
imageAttachment,
|
|
};
|
|
}
|
|
|
|
const CodeLine = React.memo(function CodeLine({
|
|
tokens,
|
|
lineNumber,
|
|
gutterWidth,
|
|
colorMap,
|
|
baseColor,
|
|
}: CodeLineProps) {
|
|
const gutterStyle = useMemo(() => [codeLineStyles.gutter, { width: gutterWidth }], [gutterWidth]);
|
|
const gutterTextStyle = useMemo(
|
|
() => [codeLineStyles.gutterText, { color: baseColor }],
|
|
[baseColor],
|
|
);
|
|
const keyedTokens = useMemo(
|
|
() => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })),
|
|
[tokens],
|
|
);
|
|
return (
|
|
<View style={codeLineStyles.line}>
|
|
<View style={gutterStyle}>
|
|
<Text numberOfLines={1} style={gutterTextStyle}>
|
|
{String(lineNumber)}
|
|
</Text>
|
|
</View>
|
|
<Text selectable style={codeLineStyles.lineText}>
|
|
{keyedTokens.map(({ key, token }) => (
|
|
<CodeLineToken
|
|
key={key}
|
|
color={token.style ? (colorMap[token.style] ?? baseColor) : baseColor}
|
|
text={token.text}
|
|
/>
|
|
))}
|
|
</Text>
|
|
</View>
|
|
);
|
|
});
|
|
|
|
interface CodeLineTokenProps {
|
|
color: string;
|
|
text: string;
|
|
}
|
|
|
|
function CodeLineToken({ color, text }: CodeLineTokenProps) {
|
|
const style = useMemo(() => ({ color }), [color]);
|
|
return <Text style={style}>{text}</Text>;
|
|
}
|
|
|
|
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,
|
|
imagePreviewUri,
|
|
}: FilePreviewBodyProps) {
|
|
const { theme } = useUnistyles();
|
|
const isDark = theme.colorScheme === "dark";
|
|
const colorMap = isDark ? darkHighlightColors : lightHighlightColors;
|
|
const baseColor = isDark ? "#c9d1d9" : "#24292f";
|
|
const markdownStyles = useMemo(() => createMarkdownStyles(theme), [theme]);
|
|
const markdownParser = useMemo(() => MarkdownIt({ typographer: true, linkify: true }), []);
|
|
const isMarkdownFile = preview?.kind === "text" && isRenderedMarkdownFile(filePath);
|
|
|
|
const previewScrollRef = useRef<RNScrollView>(null);
|
|
const webScrollbarStyle = useWebScrollbarStyle();
|
|
const scrollbar = useWebScrollViewScrollbar(previewScrollRef, {
|
|
enabled: showDesktopWebScrollbar,
|
|
});
|
|
|
|
const highlightedLines = useMemo(() => {
|
|
if (!preview || preview.kind !== "text" || isMarkdownFile) {
|
|
return null;
|
|
}
|
|
|
|
return highlightCode(preview.content ?? "", filePath);
|
|
}, [isMarkdownFile, preview, filePath]);
|
|
|
|
const gutterWidth = useMemo(() => {
|
|
if (!highlightedLines) return 0;
|
|
return lineNumberGutterWidth(highlightedLines.length, theme.fontSize.sm);
|
|
}, [highlightedLines, theme.fontSize.sm]);
|
|
|
|
const imageSource = useMemo(
|
|
() => (imagePreviewUri ? { uri: imagePreviewUri } : null),
|
|
[imagePreviewUri],
|
|
);
|
|
|
|
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") {
|
|
if (isMarkdownFile) {
|
|
return (
|
|
<View style={styles.previewScrollContainer}>
|
|
<RNScrollView
|
|
ref={previewScrollRef}
|
|
style={styles.previewContent}
|
|
contentContainerStyle={styles.previewMarkdownScrollContent}
|
|
onLayout={scrollbar.onLayout}
|
|
onScroll={scrollbar.onScroll}
|
|
onContentSizeChange={scrollbar.onContentSizeChange}
|
|
scrollEventThrottle={16}
|
|
showsVerticalScrollIndicator={!showDesktopWebScrollbar}
|
|
>
|
|
<Markdown style={markdownStyles} markdownit={markdownParser}>
|
|
{preview.content ?? ""}
|
|
</Markdown>
|
|
</RNScrollView>
|
|
{scrollbar.overlay}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const lines = highlightedLines ?? [[{ text: preview.content ?? "", style: null }]];
|
|
const keyedLines = lines.map((tokens, index) => ({
|
|
key: `line-${index}`,
|
|
tokens,
|
|
lineNumber: index + 1,
|
|
}));
|
|
const codeLines = (
|
|
<View>
|
|
{keyedLines.map(({ key, tokens, lineNumber }) => (
|
|
<CodeLine
|
|
key={key}
|
|
tokens={tokens}
|
|
lineNumber={lineNumber}
|
|
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
|
|
style={webScrollbarStyle}
|
|
contentContainerStyle={styles.previewCodeScrollContent}
|
|
>
|
|
{codeLines}
|
|
</RNScrollView>
|
|
)}
|
|
</RNScrollView>
|
|
{scrollbar.overlay}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (preview.kind === "image") {
|
|
if (!imagePreviewUri) {
|
|
return (
|
|
<View style={styles.centerState}>
|
|
<ActivityIndicator size="small" />
|
|
<Text style={styles.loadingText}>Loading file…</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
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={imageSource ?? undefined}
|
|
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" };
|
|
}
|
|
try {
|
|
const file = await client.readFile(normalizedWorkspaceRoot, normalizedFilePath);
|
|
const preview = await createFilePanePreview(file);
|
|
return {
|
|
file: preview.file,
|
|
imageAttachment: preview.imageAttachment,
|
|
error: null,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
file: null,
|
|
imageAttachment: null,
|
|
error: error instanceof Error ? error.message : "Failed to load file",
|
|
};
|
|
}
|
|
},
|
|
staleTime: 5_000,
|
|
refetchOnMount: true,
|
|
});
|
|
const imagePreviewUri = useAttachmentPreviewUrl(query.data?.imageAttachment ?? null);
|
|
|
|
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}
|
|
imagePreviewUri={imagePreviewUri}
|
|
/>
|
|
</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],
|
|
},
|
|
previewMarkdownScrollContent: {
|
|
padding: theme.spacing[4],
|
|
},
|
|
previewImageScrollContent: {
|
|
flexGrow: 1,
|
|
padding: theme.spacing[4],
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
previewImage: {
|
|
width: "100%",
|
|
height: 420,
|
|
},
|
|
}));
|