* fix(app): stabilize streamed chat rendering Use deterministic Markdown AST keys and preserve unique row identities when an assistant message resumes after a tool. Keep native gesture and plan-card children correctly keyed to avoid unsupported Fabric reconciliation paths. * fix(app): stabilize retained native panels Keep retained workspace roots in a stable native order and make active panels present in the selection commit. Hidden panels now stop expensive subscriptions and animations without losing mounted state. * fix(app): harden retained panel activity
532 lines
15 KiB
TypeScript
532 lines
15 KiB
TypeScript
import React, { useEffect, useMemo, useRef } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import type { FileReadResult } from "@getpaseo/client/internal/daemon-client";
|
|
import {
|
|
ActivityIndicator,
|
|
Image as RNImage,
|
|
ScrollView as RNScrollView,
|
|
Text,
|
|
View,
|
|
} from "react-native";
|
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|
import { useTranslation } from "react-i18next";
|
|
import { MarkdownRenderer } from "@/components/markdown/renderer";
|
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
|
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, type HighlightToken } from "@getpaseo/highlight";
|
|
import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles";
|
|
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
|
import { lineNumberGutterWidth } from "@/components/code-insets";
|
|
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
|
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
|
|
import { isWeb } from "@/constants/platform";
|
|
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";
|
|
import { resolveFilePreviewReadTarget } from "@/file-explorer/preview-target";
|
|
import type { WorkspaceFileLocation } from "@/workspace/file-open";
|
|
import { useRetainedPanelActive } from "@/components/retained-panel";
|
|
import { useAppVisible } from "@/hooks/use-app-visible";
|
|
import { isFileQueryEnabled } from "@/components/file-pane-enabled";
|
|
|
|
interface CodeLineProps {
|
|
tokens: HighlightToken[];
|
|
lineNumber: number;
|
|
gutterWidth: number;
|
|
highlighted: boolean;
|
|
}
|
|
|
|
interface FilePreviewBodyProps {
|
|
preview: ExplorerFile | null;
|
|
isLoading: boolean;
|
|
showDesktopWebScrollbar: boolean;
|
|
isMobile: boolean;
|
|
location: WorkspaceFileLocation;
|
|
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;
|
|
}
|
|
|
|
interface FileLineSelection {
|
|
lineStart: number;
|
|
lineEnd: number;
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
function clampLineSelection(input: {
|
|
lineStart?: number;
|
|
lineEnd?: number;
|
|
lineCount: number;
|
|
}): FileLineSelection | null {
|
|
if (!input.lineStart || input.lineStart <= 0 || input.lineCount <= 0) {
|
|
return null;
|
|
}
|
|
const lineStart = Math.min(Math.floor(input.lineStart), input.lineCount);
|
|
const rawLineEnd =
|
|
input.lineEnd && input.lineEnd >= input.lineStart ? input.lineEnd : input.lineStart;
|
|
const lineEnd = Math.min(Math.floor(rawLineEnd), input.lineCount);
|
|
return { lineStart, lineEnd: Math.max(lineStart, lineEnd) };
|
|
}
|
|
|
|
const CodeLine = React.memo(function CodeLine({
|
|
tokens,
|
|
lineNumber,
|
|
gutterWidth,
|
|
highlighted,
|
|
}: CodeLineProps) {
|
|
const gutterStyle = useMemo(
|
|
() => [codeLineStyles.gutter, inlineUnistylesStyle({ width: gutterWidth })],
|
|
[gutterWidth],
|
|
);
|
|
const lineStyle = useMemo(
|
|
() => [codeLineStyles.line, highlighted && codeLineStyles.highlightedLine],
|
|
[highlighted],
|
|
);
|
|
const keyedTokens = useMemo(
|
|
() => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })),
|
|
[tokens],
|
|
);
|
|
return (
|
|
<View style={lineStyle}>
|
|
<View style={gutterStyle}>
|
|
<Text numberOfLines={1} style={codeLineStyles.gutterText}>
|
|
{String(lineNumber)}
|
|
</Text>
|
|
</View>
|
|
<Text selectable style={codeLineStyles.lineText}>
|
|
{keyedTokens.map(({ key, token }) => (
|
|
<CodeLineToken key={key} token={token} />
|
|
))}
|
|
</Text>
|
|
</View>
|
|
);
|
|
});
|
|
|
|
interface CodeLineTokenProps {
|
|
token: HighlightToken;
|
|
}
|
|
|
|
function CodeLineToken({ token }: CodeLineTokenProps) {
|
|
return <Text style={syntaxTokenStyleFor(token.style)}>{token.text}</Text>;
|
|
}
|
|
|
|
const codeLineStyles = StyleSheet.create((theme) => ({
|
|
line: {
|
|
flexDirection: "row",
|
|
},
|
|
highlightedLine: {
|
|
backgroundColor: theme.colors.accentBorder,
|
|
},
|
|
gutter: {
|
|
alignItems: "flex-end",
|
|
paddingRight: theme.spacing[3],
|
|
flexShrink: 0,
|
|
},
|
|
gutterText: {
|
|
color: theme.colors.foreground,
|
|
fontFamily: theme.fontFamily.mono,
|
|
fontSize: theme.fontSize.code,
|
|
lineHeight: theme.fontSize.code * 1.45,
|
|
opacity: 0.4,
|
|
userSelect: "none",
|
|
},
|
|
lineText: {
|
|
fontFamily: theme.fontFamily.mono,
|
|
fontSize: theme.fontSize.code,
|
|
lineHeight: theme.fontSize.code * 1.45,
|
|
flex: 1,
|
|
},
|
|
}));
|
|
|
|
function FilePreviewBody({
|
|
preview,
|
|
isLoading,
|
|
showDesktopWebScrollbar,
|
|
isMobile,
|
|
location,
|
|
imagePreviewUri,
|
|
}: FilePreviewBodyProps) {
|
|
const { theme } = useUnistyles();
|
|
const { t } = useTranslation();
|
|
const filePath = location.path;
|
|
const isMarkdownFile =
|
|
preview?.kind === "text" && isRenderedMarkdownFile(filePath) && !location.lineStart;
|
|
|
|
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.code);
|
|
}, [highlightedLines, theme.fontSize.code]);
|
|
const lineHeight = theme.fontSize.code * 1.45;
|
|
const lineSelection = useMemo(() => {
|
|
if (!highlightedLines) {
|
|
return null;
|
|
}
|
|
return clampLineSelection({
|
|
lineStart: location.lineStart,
|
|
lineEnd: location.lineEnd,
|
|
lineCount: highlightedLines.length,
|
|
});
|
|
}, [highlightedLines, location.lineEnd, location.lineStart]);
|
|
|
|
const imageSource = useMemo(
|
|
() => (imagePreviewUri ? { uri: imagePreviewUri } : null),
|
|
[imagePreviewUri],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!lineSelection) {
|
|
return;
|
|
}
|
|
const timeout = setTimeout(() => {
|
|
previewScrollRef.current?.scrollTo({
|
|
y: Math.max(0, (lineSelection.lineStart - 1) * lineHeight),
|
|
animated: false,
|
|
});
|
|
}, 0);
|
|
return () => clearTimeout(timeout);
|
|
}, [lineHeight, lineSelection]);
|
|
|
|
if (isLoading && !preview) {
|
|
return (
|
|
<View style={styles.centerState}>
|
|
<ActivityIndicator size="small" />
|
|
<Text style={styles.loadingText}>{t("panels.file.loading")}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (!preview) {
|
|
return (
|
|
<View style={styles.centerState}>
|
|
<Text style={styles.emptyText}>{t("panels.file.noPreview")}</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}
|
|
>
|
|
<MarkdownRenderer text={preview.content ?? ""} />
|
|
</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 dataSet={CODE_SURFACE_DATASET}>
|
|
{keyedLines.map(({ key, tokens, lineNumber }) => (
|
|
<CodeLine
|
|
key={key}
|
|
tokens={tokens}
|
|
lineNumber={lineNumber}
|
|
gutterWidth={gutterWidth}
|
|
highlighted={
|
|
Boolean(lineSelection) &&
|
|
lineNumber >= (lineSelection?.lineStart ?? 0) &&
|
|
lineNumber <= (lineSelection?.lineEnd ?? 0)
|
|
}
|
|
/>
|
|
))}
|
|
</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}>{t("panels.file.loading")}</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}>{t("panels.file.binaryPreviewUnavailable")}</Text>
|
|
<Text style={styles.binaryMetaText}>{formatFileSize({ size: preview.size })}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
export function FilePane({
|
|
serverId,
|
|
workspaceRoot,
|
|
location,
|
|
}: {
|
|
serverId: string;
|
|
workspaceRoot: string;
|
|
location: WorkspaceFileLocation;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
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(location.path), [location.path]);
|
|
const readTarget = useMemo(
|
|
() =>
|
|
normalizedFilePath
|
|
? resolveFilePreviewReadTarget({
|
|
path: normalizedFilePath,
|
|
workspaceRoot: normalizedWorkspaceRoot,
|
|
})
|
|
: null,
|
|
[normalizedFilePath, normalizedWorkspaceRoot],
|
|
);
|
|
|
|
// Re-read the file when this pane becomes visible again (#445). `isActive`
|
|
// covers tab switches, `isAppVisible` the whole-app background/foreground; the
|
|
// gate itself lives in isFileQueryEnabled.
|
|
const isActive = useRetainedPanelActive();
|
|
const isAppVisible = useAppVisible();
|
|
|
|
const query = useQuery({
|
|
queryKey: ["workspaceFile", serverId, readTarget?.cwd ?? null, readTarget?.path ?? null],
|
|
enabled: isFileQueryEnabled({
|
|
hasReadTarget: Boolean(client && readTarget),
|
|
isTabActive: isActive,
|
|
isAppVisible,
|
|
}),
|
|
queryFn: async () => {
|
|
if (!client || !readTarget) {
|
|
return {
|
|
file: null as ExplorerFile | null,
|
|
error: t("workspace.terminal.hostDisconnected"),
|
|
};
|
|
}
|
|
try {
|
|
const file = await client.readFile(readTarget.cwd, readTarget.path);
|
|
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 : t("panels.file.failedToLoad"),
|
|
};
|
|
}
|
|
},
|
|
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}
|
|
location={location}
|
|
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,
|
|
},
|
|
}));
|